"""Immutable statement import pipeline with per-worksheet review lifecycle.

Every uploaded file is hashed (SHA-256) and written once to content-addressed
storage before parsing. A repeated upload of identical bytes never creates a
second set of facts: it records a ``duplicate`` batch that points at the
original batch. Parse failures keep the batch and its diagnostics as an
``exception`` batch without producing any confirmed source rows.

Each worksheet is parsed independently into a :class:`SheetResult`
(``parsed`` / ``exception`` / ``ignored``) that is persisted in
``sheet_reviews`` as immutable evidence. Whether a parsed sheet may take part
in later matching and calculation is a separate, auditable human decision
(``review_status``): only ``confirmed`` sheets participate.
"""

from __future__ import annotations

from dataclasses import dataclass
import hashlib
import json
import os
from pathlib import Path
import shutil
import sqlite3
import tempfile

from . import auth
from . import ledger_events
from . import matching
from .db import utc_now
from .models import SheetResult, StatementBatch
from .parser import StatementParseError, analyze_workbook
from .reader import CorruptWorkbookError


class SheetReviewError(ValueError):
    """A review decision cannot be applied to the worksheet (mapped to 409)."""


@dataclass(frozen=True)
class ImportResult:
    batch_id: int
    status: str  # parsed | duplicate | exception
    sha256: str
    source_file_id: int
    batches: tuple[StatementBatch, ...] = ()
    sheets: tuple[SheetResult, ...] = ()
    message: str | None = None
    duplicate_same_company: bool = False  # only meaningful when status == 'duplicate'


def import_statement(
    connection: sqlite3.Connection,
    storage_dir: str | Path,
    original_filename: str,
    content: bytes,
    company_id: int | None = None,
    upload_bank_account_id: int | None = None,
) -> ImportResult:
    sha256 = hashlib.sha256(content).hexdigest()
    existing_file = connection.execute(
        "SELECT id FROM source_files WHERE sha256 = ?", (sha256,)
    ).fetchone()

    if existing_file is not None:
        return _record_duplicate(connection, existing_file["id"], sha256, company_id)

    stored_path = _store_immutable(Path(storage_dir), original_filename, content, sha256)
    try:
        source_file_id, batch_id = _create_batch_records(
            connection, sha256, original_filename, len(content), stored_path,
            company_id, upload_bank_account_id,
        )
    except sqlite3.IntegrityError:
        # Lost a concurrent-insert race on the sha256 UNIQUE constraint: the
        # identical content was already persisted by another request, so this
        # upload is a duplicate. The content-addressed file already exists.
        existing = connection.execute(
            "SELECT id FROM source_files WHERE sha256 = ?", (sha256,)
        ).fetchone()
        if existing is not None:
            return _record_duplicate(connection, existing["id"], sha256, company_id)
        raise
    return _parse_and_persist(
        connection, stored_path, sha256, original_filename, source_file_id, batch_id
    )


def import_statement_path(
    connection: sqlite3.Connection,
    storage_dir: str | Path,
    original_filename: str,
    upload_path: str | Path,
    company_id: int | None = None,
    upload_bank_account_id: int | None = None,
) -> ImportResult:
    """Import from an already-downloaded upload file (streaming-friendly).

    ``upload_path`` names the temp file the multipart handler streamed to
    disk; it is hashed incrementally and either discarded (duplicate) or
    published into the content-addressed store. The upload file itself is
    never modified. The approved ``upload_bank_account_id`` used at upload time
    is persisted on the batch so ownership can be resolved later even when the
    source rows carry no own account.
    """
    source = Path(upload_path)
    sha256 = _file_sha256(source)
    existing_file = connection.execute(
        "SELECT id FROM source_files WHERE sha256 = ?", (sha256,)
    ).fetchone()

    if existing_file is not None:
        source.unlink(missing_ok=True)
        return _record_duplicate(connection, existing_file["id"], sha256, company_id)

    stored_path = _publish_immutable(Path(storage_dir), original_filename, source, sha256)
    size = stored_path.stat().st_size
    try:
        source_file_id, batch_id = _create_batch_records(
            connection, sha256, original_filename, size, stored_path,
            company_id, upload_bank_account_id,
        )
    except sqlite3.IntegrityError:
        # Lost a concurrent-insert race on the sha256 UNIQUE constraint: the
        # identical content was already persisted by another request, so this
        # upload is a duplicate. The content-addressed file already exists.
        existing = connection.execute(
            "SELECT id FROM source_files WHERE sha256 = ?", (sha256,)
        ).fetchone()
        if existing is not None:
            return _record_duplicate(connection, existing["id"], sha256, company_id)
        raise
    return _parse_and_persist(
        connection, stored_path, sha256, original_filename, source_file_id, batch_id
    )


def _create_batch_records(
    connection: sqlite3.Connection,
    sha256: str,
    original_filename: str,
    size_bytes: int,
    stored_path: Path,
    company_id: int | None,
    upload_bank_account_id: int | None,
) -> tuple[int, int]:
    now = utc_now()
    with connection:
        cursor = connection.execute(
            """
            INSERT INTO source_files (sha256, original_filename, size_bytes, storage_path, created_at)
            VALUES (?, ?, ?, ?, ?)
            """,
            (sha256, original_filename, size_bytes, str(stored_path), now),
        )
        source_file_id = cursor.lastrowid
        cursor = connection.execute(
            """
            INSERT INTO import_batches (source_file_id, status, company_id, upload_bank_account_id, created_at, updated_at)
            VALUES (?, 'parsing', ?, ?, ?, ?)
            """,
            (source_file_id, company_id, upload_bank_account_id, now, now),
        )
        batch_id = cursor.lastrowid
    return source_file_id, batch_id


def _parse_and_persist(
    connection: sqlite3.Connection,
    stored_path: Path,
    sha256: str,
    original_filename: str,
    source_file_id: int,
    batch_id: int,
) -> ImportResult:
    try:
        sheets = analyze_workbook(stored_path)
    except CorruptWorkbookError as exc:
        message = _clean_message(str(exc), stored_path, original_filename)
        with connection:
            _insert_exception(
                connection, batch_id, "parse", message, original_filename,
                diagnostics={"original_filename": original_filename},
            )
            _set_batch_status(connection, batch_id, "exception")
        return ImportResult(batch_id, "exception", sha256, source_file_id, message=message)
    except Exception as exc:
        message = f"文件解析失败，请检查文件是否完整。（{type(exc).__name__}）"
        with connection:
            _insert_exception(
                connection, batch_id, "internal", message, original_filename,
                diagnostics={"original_filename": original_filename},
            )
            _set_batch_status(connection, batch_id, "failed")
        raise

    with connection:
        parsed_batches: list[StatementBatch] = []
        for sheet in sheets:
            if sheet.outcome == "parsed" and sheet.batch is not None:
                sheet_batch_id = _insert_sheet_batch(connection, batch_id, sheet.batch)
                _insert_sheet_review(connection, batch_id, sheet, sheet_batch_id)
                parsed_batches.append(sheet.batch)
            else:
                _insert_sheet_review(connection, batch_id, sheet, None)
        if not parsed_batches:
            message = _whole_file_message(original_filename, sheets)
            with connection:
                _insert_exception(
                    connection, batch_id, "parse", message, original_filename,
                    diagnostics=_exception_diagnostics(original_filename, sheets),
                )
                _set_batch_status(connection, batch_id, "exception")
            return ImportResult(
                batch_id, "exception", sha256, source_file_id,
                message=message, sheets=sheets,
            )
        _set_batch_status(connection, batch_id, "parsed")
    return ImportResult(
        batch_id, "parsed", sha256, source_file_id,
        batches=tuple(parsed_batches), sheets=sheets,
    )


def _record_duplicate(
    connection: sqlite3.Connection,
    source_file_id: int,
    sha256: str,
    company_id: int | None = None,
) -> ImportResult:
    original = connection.execute(
        """
        SELECT id, company_id FROM import_batches
        WHERE source_file_id = ? AND status = 'parsed'
        ORDER BY id LIMIT 1
        """,
        (source_file_id,),
    ).fetchone()
    if original is None:
        original = connection.execute(
            """
            SELECT id, company_id FROM import_batches
            WHERE source_file_id = ? AND status != 'duplicate'
            ORDER BY id LIMIT 1
            """,
            (source_file_id,),
        ).fetchone()
    now = utc_now()
    with connection:
        cursor = connection.execute(
            """
            INSERT INTO import_batches
                (source_file_id, status, duplicate_of_id, company_id, diagnostics, created_at, updated_at)
            VALUES (?, 'duplicate', ?, ?, ?, ?, ?)
            """,
            (
                source_file_id,
                original["id"],
                company_id,
                json.dumps({"note": "内容哈希相同，复用已有批次，不产生第二份事实。"}, ensure_ascii=False),
                now,
                now,
            ),
        )
    # A duplicate is only "same company" when the uploader belongs to the
    # same tenant that owns the original batch. Cross-company duplicates
    # must stay opaque: the caller gets the new duplicate batch id (which
    # belongs to its own company) but never the other company's batch id.
    same_company = original["company_id"] == company_id
    if same_company:
        batch_id = original["id"]
    else:
        batch_id = int(cursor.lastrowid)
    return ImportResult(
        batch_id,
        "duplicate",
        sha256,
        source_file_id,
        message="相同内容的文件已导入，本次按重复上传处理。",
        duplicate_same_company=same_company,
    )


def _store_immutable(
    storage_dir: Path, original_filename: str, content: bytes, sha256: str
) -> Path:
    suffix = Path(original_filename).suffix.lower()
    target_dir = storage_dir / sha256[:2]
    target_dir.mkdir(parents=True, exist_ok=True)
    target = target_dir / f"{sha256}{suffix}"
    # Publish via a unique temporary file + hard link: the content-addressed
    # target either appears complete or not at all, and is never overwritten.
    # The temp name is unique per writer, so concurrent uploads of the same
    # bytes cannot corrupt each other's staging file (B-43 baseline fix).
    fd, temp_path = tempfile.mkstemp(prefix=f".{sha256}.", suffix=".tmp", dir=target_dir)
    try:
        with os.fdopen(fd, "wb") as handle:
            handle.write(content)
        try:
            os.link(temp_path, target)
        except FileExistsError:
            # Content-addressed name means identical bytes; never overwrite.
            pass
    finally:
        try:
            os.unlink(temp_path)
        except FileNotFoundError:
            pass
    return target


def _publish_immutable(
    storage_dir: Path, original_filename: str, upload_path: Path, sha256: str
) -> Path:
    suffix = Path(original_filename).suffix.lower()
    target_dir = storage_dir / sha256[:2]
    target_dir.mkdir(parents=True, exist_ok=True)
    target = target_dir / f"{sha256}{suffix}"
    fd, temp_path = tempfile.mkstemp(prefix=f".{sha256}.", suffix=".tmp", dir=target_dir)
    try:
        with os.fdopen(fd, "wb") as handle:
            # Copy keeps the same-filesystem guarantee even if the upload temp
            # lives elsewhere; the hard link then publishes atomically. The
            # staging file is unique per writer, so two concurrent uploads of
            # the same bytes never corrupt each other's copy.
            with upload_path.open("rb") as source:
                shutil.copyfileobj(source, handle, length=1024 * 1024)
        try:
            os.link(temp_path, target)
        except FileExistsError:
            pass
    finally:
        try:
            os.unlink(temp_path)
        except FileNotFoundError:
            pass
        upload_path.unlink(missing_ok=True)
    return target


def _file_sha256(path: Path) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as handle:
        for chunk in iter(lambda: handle.read(1024 * 1024), b""):
            digest.update(chunk)
    return digest.hexdigest()


def _insert_sheet_batch(
    connection: sqlite3.Connection, batch_id: int, batch: StatementBatch
) -> int:
    now = utc_now()
    cursor = connection.execute(
        """
        INSERT INTO sheet_batches (
            import_batch_id, sheet_name, bank_name, template_id, template_version,
            header_row, own_account, own_name, period_start, period_end,
            transaction_count, warnings, created_at
        ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
        """,
        (
            batch_id,
            batch.sheet_name,
            batch.bank_name,
            batch.template_id,
            batch.template_version,
            batch.header_row,
            batch.own_account,
            batch.own_name,
            batch.period_start.isoformat() if batch.period_start else None,
            batch.period_end.isoformat() if batch.period_end else None,
            len(batch.transactions),
            json.dumps(list(batch.warnings), ensure_ascii=False),
            now,
        ),
    )
    sheet_batch_id = cursor.lastrowid
    for transaction in batch.transactions:
        connection.execute(
            """
            INSERT INTO source_rows (
                sheet_batch_id, source_row, transaction_at, income, expense, balance,
                own_account, own_name, counterparty_account, counterparty_name,
                counterparty_bank, summary, purpose, reference, currency, created_at
            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            """,
            (
                sheet_batch_id,
                transaction.source_row,
                transaction.transaction_at.isoformat(),
                str(transaction.income),
                str(transaction.expense),
                str(transaction.balance) if transaction.balance is not None else None,
                transaction.own_account,
                transaction.own_name,
                transaction.counterparty_account,
                transaction.counterparty_name,
                transaction.counterparty_bank,
                transaction.summary,
                transaction.purpose,
                transaction.reference,
                transaction.currency,
                now,
            ),
        )
    return sheet_batch_id


def _insert_sheet_review(
    connection: sqlite3.Connection,
    batch_id: int,
    sheet: SheetResult,
    sheet_batch_id: int | None,
) -> None:
    # Parse-side ignored sheets are terminal: there is nothing to confirm, so
    # their review lifecycle is closed with a machine reason.
    if sheet.outcome == "ignored":
        review_status = "ignored"
        review_reason = "空表或无有效内容，自动忽略。"
    else:
        review_status = "pending"
        review_reason = None
    connection.execute(
        """
        INSERT INTO sheet_reviews (
            import_batch_id, sheet_name, outcome, message, scanned_rows,
            candidate_headers, sheet_batch_id, review_status, review_reason, created_at
        ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
        """,
        (
            batch_id,
            sheet.sheet_name,
            sheet.outcome,
            sheet.message,
            sheet.scanned_rows,
            json.dumps(list(sheet.candidate_headers), ensure_ascii=False)
            if sheet.candidate_headers
            else None,
            sheet_batch_id,
            review_status,
            review_reason,
            utc_now(),
        ),
    )


def _insert_exception(
    connection: sqlite3.Connection,
    batch_id: int,
    stage: str,
    message: str,
    original_filename: str,
    *,
    diagnostics: dict[str, object] | None = None,
) -> None:
    payload = {"original_filename": original_filename}
    if diagnostics:
        payload.update(diagnostics)
    connection.execute(
        """
        INSERT INTO import_exceptions (import_batch_id, stage, message, diagnostics, created_at)
        VALUES (?, ?, ?, ?, ?)
        """,
        (
            batch_id,
            stage,
            message,
            json.dumps(payload, ensure_ascii=False),
            utc_now(),
        ),
    )


def _set_batch_status(connection: sqlite3.Connection, batch_id: int, status: str) -> None:
    connection.execute(
        "UPDATE import_batches SET status = ?, updated_at = ? WHERE id = ?",
        (status, utc_now(), batch_id),
    )


def _whole_file_message(original_filename: str, sheets: tuple[SheetResult, ...]) -> str:
    detail = "；".join(
        f"{sheet.sheet_name}：{sheet.message}" for sheet in sheets if sheet.message
    )
    return f"文件「{original_filename}」没有可确认的工作表。{detail}"


def _exception_diagnostics(
    original_filename: str, sheets: tuple[SheetResult, ...]
) -> dict[str, object]:
    return {
        "original_filename": original_filename,
        "sheets": [
            {
                "sheet_name": sheet.sheet_name,
                "outcome": sheet.outcome,
                "message": sheet.message,
                "scanned_rows": sheet.scanned_rows,
                "candidate_headers": list(sheet.candidate_headers),
            }
            for sheet in sheets
        ],
    }


def _clean_message(message: str, stored_path: Path, original_filename: str) -> str:
    return message.replace(str(stored_path), original_filename).replace(
        stored_path.name, original_filename
    )


# ---------------------------------------------------------------------------
# Per-sheet review lifecycle
# ---------------------------------------------------------------------------


def sheet_review_rows(connection: sqlite3.Connection, batch_id: int) -> list[sqlite3.Row]:
    return connection.execute(
        """
        SELECT r.id, r.sheet_name, r.outcome, r.message, r.scanned_rows,
               r.candidate_headers, r.review_status, r.review_reason,
               r.reviewed_by, r.reviewed_at,
               s.bank_name, s.template_id, s.header_row, s.own_account, s.own_name,
               s.period_start, s.period_end, s.transaction_count, s.warnings
        FROM sheet_reviews r
        LEFT JOIN sheet_batches s ON s.id = r.sheet_batch_id
        WHERE r.import_batch_id = ?
        ORDER BY r.id
        """,
        (batch_id,),
    ).fetchall()


def scoped_batch(
    connection: sqlite3.Connection, batch_id: int, company_id: int | None
) -> sqlite3.Row | None:
    """Load a batch, scoping company users to their own tenant.

    ``company_id is None`` (admin) sees any batch; a company user only sees
    batches owned by that company. Returns None when out of scope so callers
    can answer 404 without leaking the batch's existence.
    """
    if company_id is None:
        return connection.execute(
            "SELECT id, company_id FROM import_batches WHERE id = ?", (batch_id,)
        ).fetchone()
    return connection.execute(
        "SELECT id, company_id FROM import_batches WHERE id = ? AND company_id = ?",
        (batch_id, company_id),
    ).fetchone()


def review_sheets(
    connection: sqlite3.Connection,
    batch_id: int,
    sheet_names: list[str],
    decision: str,
    actor: sqlite3.Row | None,
    company_id: int | None,
    reason: str | None = None,
) -> dict[str, object]:
    """Apply ``confirm`` or ``ignore`` to whole worksheets atomically.

    Multi-sheet writes run in one transaction; a failure rolls back every
    sheet in the request. Idempotent repeats of the same decision succeed
    without touching the audit log; changing a settled decision is a
    conflict. Returns ``{"updated": [...], "already": [...]}``.
    """
    if decision not in ("confirm", "ignore"):
        raise ValueError("decision 必须是 confirm 或 ignore。")
    names = [str(name).strip() for name in sheet_names]
    if not names:
        raise ValueError("必须至少指定一个工作表。")
    reason = (reason or "").strip() or None
    if decision == "ignore" and not reason:
        raise ValueError("忽略工作表必须填写原因。")

    batch = scoped_batch(connection, batch_id, company_id)
    if batch is None:
        raise LookupError("批次不存在。")

    placeholders = ",".join("?" for _ in names)
    rows = connection.execute(
        f"""
        SELECT * FROM sheet_reviews
        WHERE import_batch_id = ? AND sheet_name IN ({placeholders})
        """,
        (batch_id, *names),
    ).fetchall()
    found = {row["sheet_name"] for row in rows}
    missing = [name for name in names if name not in found]
    if missing:
        raise ValueError(f"工作表不存在或不属于该批次：{'、'.join(missing)}")

    target = "confirmed" if decision == "confirm" else "ignored"
    now = utc_now()
    updated: list[str] = []
    already: list[str] = []
    matching_result: dict[str, object] | None = None
    # BEGIN IMMEDIATE serializes concurrent confirmations: the write lock is
    # taken before the read, so two cashiers confirming matching worksheets can
    # never derive their match decisions from stale snapshots.
    began = False
    if not connection.in_transaction:
        connection.execute("BEGIN IMMEDIATE")
        began = True
    try:
        for row in rows:
            if row["review_status"] == target:
                already.append(row["sheet_name"])
                continue
            if decision == "confirm" and row["outcome"] != "parsed":
                raise SheetReviewError(
                    f"工作表「{row['sheet_name']}」没有可确认的交易，只能忽略或保留待处理。"
                )
            if row["review_status"] != "pending":
                raise SheetReviewError(
                    f"工作表「{row['sheet_name']}」已处理，不能更改决定。"
                )
            cursor = connection.execute(
                """
                UPDATE sheet_reviews
                SET review_status = ?, review_reason = ?, reviewed_by = ?, reviewed_at = ?
                WHERE id = ? AND review_status = 'pending'
                """,
                (target, reason, actor["id"] if actor is not None else None, now, row["id"]),
            )
            if cursor.rowcount:
                updated.append(row["sheet_name"])

        # Confirming a worksheet and reconciling its rows must succeed or fail
        # together: any matching failure rolls the confirmation back, so there
        # is never a "confirmed but unmatched" half state.
        if updated and decision == "confirm":
            placeholders = ",".join("?" for _ in updated)
            confirmed_rows = connection.execute(
                f"""
                SELECT r.id FROM source_rows r
                JOIN sheet_batches s ON s.id = r.sheet_batch_id
                JOIN sheet_reviews rv ON rv.sheet_batch_id = s.id
                WHERE rv.sheet_name IN ({placeholders})
                  AND rv.import_batch_id = ?
                ORDER BY r.id
                """,
                (*updated, batch_id),
            ).fetchall()
            matching_result = matching.reconcile_rows(
                connection,
                [item["id"] for item in confirmed_rows],
                actor=actor,
            )
            ledger_events.reconcile_bank_events(connection, actor=actor)
        if began:
            connection.commit()
    except Exception:
        if began:
            connection.rollback()
        raise

    if updated:
        auth.audit(
            connection,
            f"sheet_{decision}",
            actor=actor,
            target=f"batch:{batch_id}",
            detail=f"sheets:{','.join(updated)}" + (f";reason:{reason}" if reason else ""),
        )
    payload: dict[str, object] = {"updated": updated, "already": already}
    if matching_result is not None:
        payload["matching"] = matching_result
    return payload
