from __future__ import annotations

import json
import re
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Sequence

from excel_sanitization import sanitize_excel_string

__all__ = [
    "ReviewSessionResult",
    "RunIntakeResult",
    "WORKBOOK_REQUIRED_HEADERS",
    "WORKBOOK_REQUIRED_SHEETS",
    "refresh_final_artifacts",
    "refresh_review_execution_trace",
    "write_review_session_artifacts",
    "write_run_intake",
]

SCHEMA_VERSION = "1.0"
PLUGIN_NAME = "journal-bank-reconciliation"
WORKFLOW_NAME = "journal-bank-reconciliation"
MAX_MATCH_ITEMS = 200
MAX_UNMATCHED_ITEMS = 500
FINAL_ARTIFACT_EXCLUDED_NAMES = frozenset(
    {
        "run_intake.json",
        "review_payload.json",
        "ui_decisions.json",
        "final_artifacts.json",
    }
)
RECONCILIATION_COMMAND = (
    "python",
    "plugins/journal-bank-reconciliation/scripts/run_reconciliation.py",
)


@dataclass(frozen=True)
class RunIntakeResult:
    """Run intake artifact written before journal-bank reconciliation."""

    run_id: str
    path: Path


@dataclass(frozen=True)
class ReviewSessionResult:
    """Review-session artifacts for one journal-bank reconciliation run."""

    run_id: str
    run_intake_path: Path
    review_payload_path: Path
    ui_decisions_path: Path
    final_artifacts_path: Path
    review_item_count: int


def _utc_now() -> str:
    return datetime.now(timezone.utc).replace(microsecond=0).isoformat()


def _safe_slug(value: str) -> str:
    slug = re.sub(r"[^a-zA-Z0-9_.-]+", "-", value).strip("-._").lower()
    return slug or "run"


def _run_id(bank_path: Path, journal_path: Path) -> str:
    timestamp = re.sub(r"[^0-9]", "", _utc_now())
    return (
        f"{PLUGIN_NAME}-{_safe_slug(bank_path.stem)}-"
        f"{_safe_slug(journal_path.stem)}-{timestamp}"
    )


def _write_json(path: Path, payload: dict[str, Any]) -> Path:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(
        json.dumps(payload, ensure_ascii=False, indent=2, default=str) + "\n",
        encoding="utf-8",
    )
    return path


def _write_review_handoff_card(
    output_dir: Path,
    *,
    run_id: str,
    title: str,
    validate_tool: str,
    render_tool: str,
    save_tool: str,
    apply_tool: str,
    language: str,
) -> Path:
    path = output_dir / "review_handoff.md"
    if _is_spanish(language):
        lines = [
            f"# Entrega para revisión: {title}",
            "",
            f"- ID de ejecución: `{run_id}`",
            "- Datos de revisión: `review_payload.json`",
            "- Datos de entrada de la ejecución: `run_intake.json`",
            "- Decisiones pendientes: `ui_decisions.json`",
            "- Decisiones aplicadas: `applied_decisions.json`",
            "- Artefactos finales: `final_artifacts.json`",
            "",
            "## Revisión profesional",
            f"1. Valide los datos con `{validate_tool}`.",
            f"2. Muestre el espacio de revisión con `{render_tool}`.",
            f"3. Guarde las acciones de revisión con `{save_tool}`.",
            f"4. Aplique las acciones de revisión con `{apply_tool}`.",
            "",
            "El guardado y la aplicación persistentes requieren la interfaz de revisión MCP o del servidor local. "
            "La alternativa HTML estática solo permite copiar o descargar el JSON de decisiones.",
            "",
            "<!-- Review Handoff -->",
        ]
    else:
        lines = [
            f"# {title} Review Handoff",
            "",
            f"- Run ID: `{run_id}`",
            "- Review payload: `review_payload.json`",
            "- Run intake: `run_intake.json`",
            "- Pending decisions: `ui_decisions.json`",
            "- Applied decisions: `applied_decisions.json`",
            "- Final artifacts: `final_artifacts.json`",
            "",
            "## Professional Review",
            f"1. Validate the payload with `{validate_tool}`.",
            f"2. Render the review workbench with `{render_tool}`.",
            f"3. Save reviewer actions with `{save_tool}`.",
            f"4. Apply reviewer actions with `{apply_tool}`.",
            "",
            "Persistent save/apply requires the MCP or local-server review surface. "
            "Static HTML fallback can copy or download decision JSON only.",
        ]
    path.write_text("\n".join(lines) + "\n", encoding="utf-8")
    return path


def _review_handoff_output_record(path: Path, language: str) -> dict[str, Any]:
    return {
        "path": path.name,
        "size_bytes": path.stat().st_size,
        "kind": "md",
        "status": "written",
        "required_text": [
            "Entrega para revisión" if _is_spanish(language) else "Review Handoff",
            *(["Review Handoff"] if _is_spanish(language) else []),
            "review_payload.json",
            "ui_decisions.json",
            "applied_decisions.json",
            "final_artifacts.json",
        ],
        "qa_checks": ["nonempty_text", "required_text"],
    }


def _local_output_refs(final_artifacts_path: Path) -> list[str]:
    refs = [
        "run_intake.json",
        "review_payload.json",
        "ui_decisions.json",
        "final_artifacts.json",
    ]
    payload = json.loads(final_artifacts_path.read_text(encoding="utf-8"))
    outputs = payload.get("outputs")
    if isinstance(outputs, list):
        for output in outputs:
            if not isinstance(output, dict):
                continue
            path_value = output.get("path")
            if (
                isinstance(path_value, str)
                and path_value.strip()
                and "://" not in path_value
            ):
                refs.append(path_value.strip())
    return list(dict.fromkeys(refs))


def _append_execution_trace(
    run_intake_path: Path,
    final_artifacts_path: Path,
    *,
    command: Sequence[str],
) -> None:
    payload = json.loads(run_intake_path.read_text(encoding="utf-8"))
    data_posture = payload.get("data_posture")
    local_files = (
        data_posture.get("local_files_read") if isinstance(data_posture, dict) else None
    )
    inputs = (
        local_files if isinstance(local_files, list) else payload.get("input_paths", [])
    )
    payload["execution_trace"] = [
        {
            "step_id": f"{WORKFLOW_NAME}_review_session",
            "kind": "deterministic_review_session",
            "status": "passed",
            "execution_location": "cowork_connected_folder",
            "command": list(command),
            "inputs": [str(entry) for entry in inputs if entry],
            "outputs": _local_output_refs(final_artifacts_path),
        }
    ]
    _write_json(run_intake_path, payload)


def refresh_review_execution_trace(
    run_intake_path: Path,
    final_artifacts_path: Path,
) -> None:
    """Refresh the review-session trace against the current final manifest."""

    _append_execution_trace(
        run_intake_path,
        final_artifacts_path,
        command=RECONCILIATION_COMMAND,
    )


def _as_output_ref(path: str | Path | None, output_dir: Path) -> str | None:
    if path is None:
        return None
    candidate = Path(path)
    try:
        return candidate.relative_to(output_dir).as_posix()
    except ValueError:
        return candidate.as_posix()


def _json_object_if_present(path: Path) -> dict[str, Any]:
    if not path.is_file():
        return {}
    payload = json.loads(path.read_text(encoding="utf-8"))
    return payload if isinstance(payload, dict) else {}


def _clean_text(value: Any) -> str:
    return " ".join(str(value or "").strip().split())


def _is_spanish(language: object) -> bool:
    return (
        str(language or "").strip().lower().replace("_", "-").split("-", 1)[0] == "es"
    )


def _rows(frame: Any) -> list[dict[str, Any]]:
    if frame is None:
        return []
    to_dicts = getattr(frame, "to_dicts", None)
    if callable(to_dicts):
        return [row for row in to_dicts() if isinstance(row, dict)]
    if isinstance(frame, list):
        return [row for row in frame if isinstance(row, dict)]
    return []


def _base_item(
    item_id: str,
    item_type: str,
    title: str,
    *,
    allowed_actions: Sequence[str],
    recommended_action: str,
    source_path: str | None = None,
    output_path: str | None = None,
    evidence: Sequence[dict[str, Any]] = (),
    data: dict[str, Any] | None = None,
) -> dict[str, Any]:
    return {
        "id": item_id,
        "item_type": item_type,
        "title": title,
        "source_path": source_path,
        "output_path": output_path,
        "allowed_actions": list(allowed_actions),
        "recommended_action": recommended_action,
        "evidence": list(evidence),
        "data": data or {},
        "status": "needs_review",
    }


def _review_columns(language: str) -> list[dict[str, str]]:
    if _is_spanish(language):
        return [
            {"field": "item_type", "label": "Tipo"},
            {"field": "title", "label": "Movimiento"},
            {"field": "recommended_action", "label": "Acción sugerida"},
            {"field": "source_path", "label": "Fuente"},
            {"field": "output_path", "label": "Salida"},
            {"field": "status", "label": "Estado"},
        ]
    return [
        {"field": "item_type", "label": "Type"},
        {"field": "title", "label": "Movement"},
        {"field": "recommended_action", "label": "Suggested action"},
        {"field": "source_path", "label": "Source"},
        {"field": "output_path", "label": "Output"},
        {"field": "status", "label": "Status"},
    ]


def _transaction_title(row: dict[str, Any], fallback: str) -> str:
    parts = [
        _clean_text(row.get("transaction_date")),
        _clean_text(row.get("amount_signed") or row.get("amount_abs")),
        _clean_text(row.get("reference") or row.get("movement_number")),
        _clean_text(row.get("beneficiary") or row.get("description")),
    ]
    return " | ".join(part for part in parts if part) or fallback


def _match_title(row: dict[str, Any], index: int, language: str) -> str:
    parts = [
        _clean_text(row.get("bank_amount")),
        _clean_text(row.get("shared_references")),
        _clean_text(row.get("stage")),
    ]
    fallback = (
        f"Par conciliado {index}" if _is_spanish(language) else f"Matched pair {index}"
    )
    return " | ".join(part for part in parts if part) or fallback


def _requested_reconciliation_evidence(
    row: dict[str, Any], side: str, language: str
) -> tuple[str, str]:
    reference = _clean_text(row.get("reference") or row.get("movement_number"))
    amount = _clean_text(row.get("amount_signed") or row.get("amount_abs"))
    descriptor = (
        reference
        or amount
        or (
            "transacción sin conciliar"
            if _is_spanish(language)
            else "unmatched transaction"
        )
    )
    if side == "bank":
        return (
            (
                f"Justificante del diario o del mayor para la transacción bancaria {descriptor}"
                if _is_spanish(language)
                else f"Journal or ledger support for bank transaction {descriptor}"
            ),
            (
                "La transacción bancaria no tiene una correspondencia determinista en el diario."
                if _is_spanish(language)
                else "Bank transaction has no deterministic journal match."
            ),
        )
    return (
        (
            f"Extracto bancario o justificante de pago para la transacción del diario {descriptor}"
            if _is_spanish(language)
            else f"Bank statement or payment evidence for journal transaction {descriptor}"
        ),
        (
            "La transacción del diario no tiene una correspondencia bancaria determinista."
            if _is_spanish(language)
            else "Journal transaction has no deterministic bank match."
        ),
    )


def _unmatched_items(
    rows: Sequence[dict[str, Any]],
    *,
    side: str,
    output_path: str,
    language: str,
) -> list[dict[str, Any]]:
    item_type = "unmatched_bank" if side == "bank" else "unmatched_journal"
    if _is_spanish(language):
        source_label = "Banco" if side == "bank" else "Diario"
        row_label = "fila"
    else:
        source_label = "Bank" if side == "bank" else "Journal"
        row_label = "row"
    items: list[dict[str, Any]] = []
    for index, row in enumerate(rows[:MAX_UNMATCHED_ITEMS], start=1):
        requested_document, reason = _requested_reconciliation_evidence(
            row, side, language
        )
        data = dict(row)
        data["requested_document"] = requested_document
        data["reason"] = reason
        items.append(
            _base_item(
                f"{item_type}-{index}",
                item_type,
                _transaction_title(row, f"{source_label} row {index}"),
                source_path="; ".join(
                    part
                    for part in (
                        _clean_text(row.get("source_file")),
                        (
                            f"{row_label} {_clean_text(row.get('source_row'))}"
                            if _clean_text(row.get("source_row"))
                            else ""
                        ),
                    )
                    if part
                )
                or None,
                output_path=output_path,
                allowed_actions=(
                    "accept",
                    "edit",
                    "mark_unclear",
                    "request_more_documents",
                    "skip",
                ),
                recommended_action="request_more_documents",
                evidence=[
                    {
                        "kind": "unmatched_transaction",
                        "side": side,
                        "transaction_id": row.get("transaction_id"),
                        "amount_abs": row.get("amount_abs"),
                        "reference": row.get("reference"),
                        "movement_number": row.get("movement_number"),
                    },
                    {
                        "kind": "missing_reconciliation_evidence",
                        "side": side,
                        "requested_document": requested_document,
                        "reason": reason,
                        "status": "needs_evidence",
                    },
                ],
                data=data,
            )
        )
    if len(rows) > MAX_UNMATCHED_ITEMS:
        items.append(
            _base_item(
                f"{item_type}-truncated",
                "review_artifact",
                (
                    f"Filas sin conciliar del {source_label.lower()} truncadas en el widget"
                    if _is_spanish(language)
                    else f"{source_label} unmatched rows truncated in widget"
                ),
                output_path=output_path,
                allowed_actions=("accept", "mark_unclear", "skip"),
                recommended_action="mark_unclear",
                data={
                    "shown_count": MAX_UNMATCHED_ITEMS,
                    "total_count": len(rows),
                    "full_results": output_path,
                },
            )
        )
    return items


def _match_items(rows: Sequence[dict[str, Any]], language: str) -> list[dict[str, Any]]:
    items: list[dict[str, Any]] = []
    for index, row in enumerate(rows[:MAX_MATCH_ITEMS], start=1):
        data = dict(row)
        data["target_artifact"] = "reconciliation_matches.csv"
        data["target_id_field"] = "bank_transaction_id"
        data["target_record_id"] = str(row.get("bank_transaction_id") or "")
        data["target_field"] = "review_note"
        data["edit_hint"] = (
            "Editar este par conciliado actualiza review_note en reconciliation_matches.csv para el bank_transaction_id correspondiente."
            if _is_spanish(language)
            else "Editing this matched pair updates review_note in reconciliation_matches.csv for the matching bank_transaction_id."
        )
        items.append(
            _base_item(
                f"matched-pair-{index}",
                "matched_pair",
                _match_title(row, index, language),
                output_path="reconciliation_matches.csv",
                allowed_actions=("accept", "edit", "mark_unclear", "skip"),
                recommended_action="accept",
                evidence=[
                    {
                        "kind": "deterministic_match",
                        "stage": row.get("stage"),
                        "amount_delta": row.get("amount_delta"),
                        "date_diff_days": row.get("date_diff_days"),
                        "shared_references": row.get("shared_references"),
                    }
                ],
                data=data,
            )
        )
    return items


def _artifact_items(
    audit: dict[str, Any], output_dir: Path, language: str
) -> list[dict[str, Any]]:
    outputs = audit.get("outputs") if isinstance(audit.get("outputs"), dict) else {}
    spanish = _is_spanish(language)
    labels = {
        "normalized_bank_csv": (
            "review_artifact",
            "CSV bancario normalizado" if spanish else "Normalized bank CSV",
        ),
        "normalized_journal_csv": (
            "review_artifact",
            "CSV del diario normalizado" if spanish else "Normalized journal CSV",
        ),
        "reconciliation_matches_csv": (
            "review_artifact",
            (
                "CSV de coincidencias de conciliación"
                if spanish
                else "Reconciliation matches CSV"
            ),
        ),
        "relationship_residuals_csv": (
            "review_artifact",
            (
                "CSV de residuales de la relación"
                if spanish
                else "Relationship residuals CSV"
            ),
        ),
        "unmatched_bank_csv": (
            "review_artifact",
            "CSV bancario sin conciliar" if spanish else "Unmatched bank CSV",
        ),
        "unmatched_journal_csv": (
            "review_artifact",
            "CSV del diario sin conciliar" if spanish else "Unmatched journal CSV",
        ),
        "workbook_xlsx": (
            "workpaper_artifact",
            (
                "Libro de conciliación entre diario y banco"
                if spanish
                else "Journal-bank reconciliation workbook"
            ),
        ),
        "audit_json": (
            "review_artifact",
            (
                "JSON de auditoría de la conciliación"
                if spanish
                else "Reconciliation audit JSON"
            ),
        ),
        "review_notes_md": (
            "review_artifact",
            "Notas de revisión" if spanish else "Review notes",
        ),
        "material_value_ledger_json": (
            "review_artifact",
            (
                "Libro de direcciones de valores materiales"
                if spanish
                else "Material-value address ledger"
            ),
        ),
    }
    items: list[dict[str, Any]] = []
    for index, (field, (item_type, title)) in enumerate(labels.items(), start=1):
        path_value = outputs.get(field)
        if not path_value:
            continue
        path_ref = _as_output_ref(path_value, output_dir)
        exists = Path(path_value).exists()
        items.append(
            _base_item(
                f"artifact-{index}",
                item_type,
                title,
                output_path=path_ref,
                allowed_actions=("accept", "edit", "mark_unclear", "skip"),
                recommended_action="accept" if exists else "mark_unclear",
                evidence=[
                    {
                        "kind": "artifact_status",
                        "field": field,
                        "path": path_ref,
                        "exists": exists,
                    }
                ],
                data={"field": field, "path": path_ref, "exists": exists},
            )
        )
    return items


MATCH_WORKBOOK_COLUMNS = [
    "status",
    "stage",
    "bank_transaction_id",
    "journal_transaction_id",
    "bank_date",
    "journal_date",
    "date_diff_days",
    "bank_amount",
    "journal_amount",
    "amount_delta",
    "bank_description",
    "journal_description",
    "shared_references",
    "review_note",
]
RESIDUAL_WORKBOOK_COLUMNS = [
    "side",
    "record_ref",
    "transaction_id",
    "record_amount",
    "allocated_amount",
    "residual",
    "currency",
    "unit",
    "entity_ref",
    "party_ref",
]
TRANSACTION_WORKBOOK_COLUMNS = [
    "side",
    "transaction_id",
    "transaction_date",
    "amount_signed",
    "amount_abs",
    "description",
    "beneficiary",
    "reference",
    "movement_number",
    "account",
    "currency",
    "unit",
    "entity_ref",
    "party_ref",
    "direction",
    "source_file",
    "source_sheet",
    "source_row",
]
NON_MOVEMENT_WORKBOOK_COLUMNS = [
    "side",
    "source_file",
    "source_sheet",
    "source_row",
    "classification",
    "reason",
    "transaction_date",
    "amount_signed",
    "amount_abs",
    "description",
]
WORKBOOK_REQUIRED_HEADERS = {
    "matches": MATCH_WORKBOOK_COLUMNS,
    "relationship_residuals": RESIDUAL_WORKBOOK_COLUMNS,
    "unmatched_bank": TRANSACTION_WORKBOOK_COLUMNS,
    "unmatched_journal": TRANSACTION_WORKBOOK_COLUMNS,
    "bank_pdf_non_movements": NON_MOVEMENT_WORKBOOK_COLUMNS,
    "normalized_bank": TRANSACTION_WORKBOOK_COLUMNS,
    "normalized_journal": TRANSACTION_WORKBOOK_COLUMNS,
}
WORKBOOK_REQUIRED_SHEETS = [
    "matches",
    "relationship_residuals",
    "unmatched_bank",
    "unmatched_journal",
    "bank_pdf_non_movements",
    "normalized_bank",
    "normalized_journal",
]


def _column_letters(index: int) -> str:
    letters = ""
    while index > 0:
        index, remainder = divmod(index - 1, 26)
        letters = chr(65 + remainder) + letters
    return letters


def _cell_reference(columns: Sequence[str], field: str, row: int) -> str:
    return f"{_column_letters(list(columns).index(field) + 1)}{row}"


def _add_cell_check(cells: dict[str, str], reference: str, value: object) -> None:
    text = sanitize_excel_string(_clean_text(value))
    if text:
        cells[reference] = text


def _required_sheet_cells(
    *,
    columns: Sequence[str],
    fields: Sequence[str],
    first_row: dict[str, Any] | None,
) -> dict[str, str]:
    cells: dict[str, str] = {}
    for field in fields:
        if field not in columns:
            continue
        cells[_cell_reference(columns, field, 1)] = field
        if first_row:
            _add_cell_check(
                cells, _cell_reference(columns, field, 2), first_row.get(field)
            )
    return cells


def _workbook_required_cells(
    match_rows: Sequence[dict[str, Any]],
    relationship_residual_rows: Sequence[dict[str, Any]],
    unmatched_bank_rows: Sequence[dict[str, Any]],
    unmatched_journal_rows: Sequence[dict[str, Any]],
    bank_pdf_non_movement_rows: Sequence[dict[str, Any]],
) -> dict[str, dict[str, str]]:
    return {
        "matches": _required_sheet_cells(
            columns=MATCH_WORKBOOK_COLUMNS,
            fields=[
                "status",
                "stage",
                "bank_transaction_id",
                "journal_transaction_id",
                "shared_references",
            ],
            first_row=match_rows[0] if match_rows else None,
        ),
        "relationship_residuals": _required_sheet_cells(
            columns=RESIDUAL_WORKBOOK_COLUMNS,
            fields=[
                "side",
                "record_ref",
                "transaction_id",
                "record_amount",
                "allocated_amount",
                "residual",
            ],
            first_row=(
                relationship_residual_rows[0] if relationship_residual_rows else None
            ),
        ),
        "unmatched_bank": _required_sheet_cells(
            columns=TRANSACTION_WORKBOOK_COLUMNS,
            fields=["side", "transaction_id", "transaction_date", "reference"],
            first_row=unmatched_bank_rows[0] if unmatched_bank_rows else None,
        ),
        "unmatched_journal": _required_sheet_cells(
            columns=TRANSACTION_WORKBOOK_COLUMNS,
            fields=["side", "transaction_id", "transaction_date", "reference"],
            first_row=unmatched_journal_rows[0] if unmatched_journal_rows else None,
        ),
        "bank_pdf_non_movements": _required_sheet_cells(
            columns=NON_MOVEMENT_WORKBOOK_COLUMNS,
            fields=[
                "source_file",
                "source_sheet",
                "source_row",
                "classification",
                "description",
                "amount_abs",
            ],
            first_row=(
                bank_pdf_non_movement_rows[0] if bank_pdf_non_movement_rows else None
            ),
        ),
    }


def _output_records(
    output_dir: Path,
    audit: dict[str, Any],
    *,
    match_rows: Sequence[dict[str, Any]],
    relationship_residual_rows: Sequence[dict[str, Any]],
    unmatched_bank_rows: Sequence[dict[str, Any]],
    unmatched_journal_rows: Sequence[dict[str, Any]],
    bank_pdf_non_movement_rows: Sequence[dict[str, Any]],
) -> list[dict[str, Any]]:
    outputs: list[dict[str, Any]] = []
    for path in sorted(output_dir.rglob("*")):
        if not path.is_file() or path.name in FINAL_ARTIFACT_EXCLUDED_NAMES:
            continue
        relative = path.relative_to(output_dir).as_posix()
        output = {
            "path": relative,
            "size_bytes": path.stat().st_size,
            "kind": path.suffix.lower().lstrip(".") or "file",
            "status": "written",
        }
        if relative == "journal_bank_reconciliation.xlsx":
            output["required_sheets"] = WORKBOOK_REQUIRED_SHEETS
            output["required_sheet_headers"] = WORKBOOK_REQUIRED_HEADERS
            output["required_cells"] = _workbook_required_cells(
                match_rows,
                relationship_residual_rows,
                unmatched_bank_rows,
                unmatched_journal_rows,
                bank_pdf_non_movement_rows,
            )
            output["source_row_counts"] = {
                "matches": int(audit.get("matched_count", 0)),
                "relationship_residuals": int(
                    audit.get("relationship_residual_row_count", 0)
                ),
                "unmatched_bank": int(audit.get("unmatched_bank_count", 0)),
                "unmatched_journal": int(audit.get("unmatched_journal_count", 0)),
                "bank_pdf_non_movements": int(
                    audit.get("bank_pdf_non_movement_row_count", 0)
                ),
                "normalized_bank": int(audit.get("bank_row_count", 0)),
                "normalized_journal": int(audit.get("journal_row_count", 0)),
            }
            output["qa_checks"] = [
                "office_zip",
                "workbook_xml",
                "required_sheets",
                "required_sheet_headers",
                "required_cells",
            ]
        elif relative == "reconciliation_matches.csv":
            output["row_count"] = int(audit.get("matched_count", 0))
            output["required_columns"] = [
                "status",
                "bank_transaction_id",
                "journal_transaction_id",
                "amount_delta",
            ]
        elif relative == "relationship_residuals.csv":
            output["row_count"] = int(audit.get("relationship_residual_row_count", 0))
            output["required_columns"] = RESIDUAL_WORKBOOK_COLUMNS
        elif relative == "unmatched_bank.csv":
            output["row_count"] = int(audit.get("unmatched_bank_count", 0))
            output["required_columns"] = [
                "transaction_id",
                "transaction_date",
                "amount_abs",
            ]
        elif relative == "unmatched_journal.csv":
            output["row_count"] = int(audit.get("unmatched_journal_count", 0))
            output["required_columns"] = [
                "transaction_id",
                "transaction_date",
                "amount_abs",
            ]
        elif relative == "bank_pdf_non_movement_rows.csv":
            output["row_count"] = int(audit.get("bank_pdf_non_movement_row_count", 0))
            output["required_columns"] = [
                "source_file",
                "source_row",
                "classification",
                "description",
                "amount_abs",
            ]
        elif relative == "review_notes.md":
            output["required_text"] = (
                [
                    "# Notas de revisión de la conciliación entre diario y banco",
                    "## Recuento por etapa",
                    "## Política de revisión",
                ]
                if audit.get("language") == "es"
                else [
                    "# Journal-Bank Reconciliation Review Notes",
                    "## Stage Counts",
                    "## Review Policy",
                ]
            )
            output["qa_checks"] = ["nonempty_text", "required_text"]
        outputs.append(output)
    return outputs


def refresh_final_artifacts(final_artifacts_path: Path) -> dict[str, Any]:
    """Refresh the manifest against the current output files without losing QA."""

    payload = json.loads(final_artifacts_path.read_text(encoding="utf-8"))
    if not isinstance(payload, dict):
        raise ValueError("final_artifacts.json must contain an object")
    outputs = payload.get("outputs")
    existing_by_path = (
        {
            str(output["path"]): dict(output)
            for output in outputs
            if isinstance(output, dict) and isinstance(output.get("path"), str)
        }
        if isinstance(outputs, list)
        else {}
    )

    refreshed: list[dict[str, Any]] = []
    output_dir = final_artifacts_path.parent
    for path in sorted(output_dir.rglob("*")):
        if not path.is_file() or path.name in FINAL_ARTIFACT_EXCLUDED_NAMES:
            continue
        relative = path.relative_to(output_dir).as_posix()
        output = existing_by_path.get(relative, {})
        output.update(
            {
                "path": relative,
                "size_bytes": path.stat().st_size,
                "kind": path.suffix.lower().lstrip(".") or "file",
            }
        )
        output.setdefault("status", "written")
        refreshed.append(output)

    payload["outputs"] = refreshed
    _write_json(final_artifacts_path, payload)
    return payload


def write_run_intake(
    output_dir: Path,
    *,
    bank_path: Path,
    journal_path: Path,
    recipe_path: Path | None,
    sample_path: Path | None,
    language: str,
    document_language: str,
    tolerance: str,
    date_window_days: int,
    client_run_id: str | None = None,
    client_run_root: Path | None = None,
) -> RunIntakeResult:
    """Write run intake before deterministic matching."""

    run_id = client_run_id or _run_id(bank_path, journal_path)
    spanish = _is_spanish(language)

    def run_reference(path_value: Path) -> str:
        if client_run_root is None:
            return path_value.as_posix()
        run_root = client_run_root.expanduser().resolve()
        try:
            relative = path_value.expanduser().resolve().relative_to(run_root)
        except ValueError as exc:
            raise ValueError(
                "Journal-Bank Reconciliation path is outside the run root."
            ) from exc
        if not relative.parts:
            raise ValueError(
                "Journal-Bank Reconciliation path must identify a run artifact."
            )
        return relative.as_posix()

    bank_ref = run_reference(bank_path)
    journal_ref = run_reference(journal_path)
    sample_ref = run_reference(sample_path) if sample_path is not None else None
    recipe_ref = run_reference(recipe_path) if recipe_path is not None else None
    output_ref = run_reference(output_dir)
    local_files_read = [bank_ref, journal_ref]
    if recipe_path is not None:
        local_files_read.append(recipe_ref)
    if sample_path is not None:
        local_files_read.append(sample_ref)
    payload = {
        "schema_version": SCHEMA_VERSION,
        "plugin": PLUGIN_NAME,
        "workflow": WORKFLOW_NAME,
        "run_id": run_id,
        **(
            {"path_reference": "run_root_relative"}
            if client_run_root is not None
            else {}
        ),
        "created_at": _utc_now(),
        "language": language,
        "input_paths": [
            bank_ref,
            journal_ref,
            *([sample_ref] if sample_ref else []),
        ],
        "output_dir": output_ref,
        "inferred_task": "journal_bank_reconciliation_review_payload",
        "assumptions": {
            "bank_path": bank_ref,
            "journal_path": journal_ref,
            "sample_path": sample_ref,
            "recipe_path": recipe_ref,
            "language": language,
            "document_language": document_language,
            "currency": "EUR",
            "tolerance": tolerance,
            "date_window_days": date_window_days,
        },
        "unresolved_questions": [],
        "dependency_check": {
            "status": "not_run_by_script",
            "note": (
                "Claude debe ejecutar scripts/check_dependencies.py antes de los scripts auxiliares."
                if spanish
                else "Claude should run scripts/check_dependencies.py before helper scripts."
            ),
        },
        "data_posture": {
            "local_files_read": local_files_read,
            "external_connectors_used": [],
            "upload_paths_used": [],
            "remote_sql_execution_used": False,
            "hosted_notebook_execution_used": False,
            "notes": [
                (
                    "Los scripts de conciliación leen localmente los archivos bancarios, del diario, de la receta opcional y de la muestra opcional."
                    if spanish
                    else "Matching scripts read bank, journal, optional recipe, and optional sample files locally."
                ),
                (
                    "De forma predeterminada no se utiliza ningún conector externo, ruta de carga, SQL remoto ni cuaderno alojado."
                    if spanish
                    else "No external connector, upload path, remote SQL, or hosted notebook execution is used by default."
                ),
            ],
        },
        "status": "ready_for_reconciliation_run",
    }
    return RunIntakeResult(
        run_id=run_id,
        path=_write_json(output_dir / "run_intake.json", payload),
    )


def write_review_session_artifacts(
    output_dir: Path,
    *,
    run_id: str,
    run_intake_path: Path,
    matches: Any,
    unmatched_bank: Any,
    unmatched_journal: Any,
    audit: dict[str, Any],
    bank_pdf_non_movements: Any = None,
    relationship_residuals: Any = None,
) -> ReviewSessionResult:
    """Write review payload, pending decisions, and final artifacts."""

    match_rows = _rows(matches)
    unmatched_bank_rows = _rows(unmatched_bank)
    unmatched_journal_rows = _rows(unmatched_journal)
    bank_pdf_non_movement_rows = _rows(bank_pdf_non_movements)
    relationship_residual_rows = _rows(relationship_residuals)
    language = str(audit.get("language") or "en")
    items: list[dict[str, Any]] = []
    items.extend(
        _unmatched_items(
            unmatched_bank_rows,
            side="bank",
            output_path="unmatched_bank.csv",
            language=language,
        )
    )
    items.extend(
        _unmatched_items(
            unmatched_journal_rows,
            side="journal",
            output_path="unmatched_journal.csv",
            language=language,
        )
    )
    items.extend(_match_items(match_rows, language))
    items.extend(_artifact_items(audit, output_dir, language))
    gate_register = _json_object_if_present(output_dir / "assurance_gates.json")
    gate_entries = gate_register.get("gates")
    gate_statuses = (
        {
            str(name): str(gate.get("status") or "not_assessed")
            for name, gate in gate_entries.items()
            if isinstance(gate, dict)
        }
        if isinstance(gate_entries, dict)
        else {}
    )

    review_payload = {
        "schema_version": SCHEMA_VERSION,
        "plugin": PLUGIN_NAME,
        "workflow": WORKFLOW_NAME,
        "run_id": run_id,
        "created_at": _utc_now(),
        "language": language,
        "source_paths": [
            audit.get("bank_path"),
            audit.get("journal_path"),
            audit.get("sample_path"),
        ],
        "review_type": "journal_bank_reconciliation_review",
        "items": items,
        "item_count": len(items),
        "columns": _review_columns(language),
        "source_artifacts": {
            "run_intake": _as_output_ref(run_intake_path, output_dir),
            "audit": "reconciliation_audit.json",
            "input_receipts": "input_receipts.json",
            "source_qualifications": "source_qualifications.json",
            "reviewed_decisions": "reviewed_decisions.json",
            "lineage": "lineage.json",
            "relationship_ledger": "relationship_ledger.json",
            "relationship_residuals": "relationship_residuals.csv",
            "material_value_ledger": "material_value_ledger.json",
            "assurance_gates": "assurance_gates.json",
            "artifact_receipts": "artifact_receipts.json",
            "review_notes": "review_notes.md",
            "matches": "reconciliation_matches.csv",
            "unmatched_bank": "unmatched_bank.csv",
            "unmatched_journal": "unmatched_journal.csv",
            "bank_pdf_non_movements": "bank_pdf_non_movement_rows.csv",
            "workbook": "journal_bank_reconciliation.xlsx",
        },
        "allowed_actions": [
            "accept",
            "reject",
            "edit",
            "mark_unclear",
            "request_more_documents",
            "skip",
        ],
        "status": "ready_for_review",
        "assurance": {
            "gate_statuses": gate_statuses,
            "report_ready": bool(gate_register.get("report_ready", False)),
            "relationship_balanced": bool(audit.get("relationship_balanced", False)),
            "unresolved_relationship_rows": int(audit.get("unmatched_bank_count") or 0)
            + int(audit.get("unmatched_journal_count") or 0),
        },
        "summary": {
            "bank_row_count": audit.get("bank_row_count", len(match_rows)),
            "journal_row_count": audit.get("journal_row_count", 0),
            "matched_count": audit.get("matched_count", len(match_rows)),
            "relationship_residual_row_count": audit.get(
                "relationship_residual_row_count",
                len(relationship_residual_rows),
            ),
            "unmatched_bank_count": audit.get(
                "unmatched_bank_count", len(unmatched_bank_rows)
            ),
            "unmatched_journal_count": audit.get(
                "unmatched_journal_count", len(unmatched_journal_rows)
            ),
            "bank_pdf_non_movement_row_count": audit.get(
                "bank_pdf_non_movement_row_count", len(bank_pdf_non_movement_rows)
            ),
            "bank_pdf_non_movement_classifications": audit.get(
                "bank_pdf_non_movement_classifications", {}
            ),
            "stage_counts": audit.get("stage_counts", {}),
            "sample_movement_count": audit.get("sample_movement_count", 0),
            "tolerance": audit.get("tolerance"),
            "date_window_days": audit.get("date_window_days"),
        },
    }
    review_payload_path = _write_json(
        output_dir / "review_payload.json",
        review_payload,
    )

    ui_decisions_path = _write_json(
        output_dir / "ui_decisions.json",
        {
            "schema_version": SCHEMA_VERSION,
            "plugin": PLUGIN_NAME,
            "workflow": WORKFLOW_NAME,
            "run_id": run_id,
            "decided_at": None,
            "decision_source": "not_collected",
            "review_payload_path": review_payload_path.name,
            "decisions": [],
            "decision_count": 0,
            "status": "pending_review",
        },
    )

    review_handoff_path = _write_review_handoff_card(
        output_dir,
        run_id=run_id,
        title=(
            "Conciliación entre diario y banco"
            if _is_spanish(language)
            else "Journal-Bank Reconciliation"
        ),
        validate_tool="validate_journal_bank_review",
        render_tool="render_journal_bank_review",
        save_tool="save_journal_bank_decisions",
        apply_tool="apply_journal_bank_decisions",
        language=language,
    )
    outputs = _output_records(
        output_dir,
        audit,
        match_rows=match_rows,
        relationship_residual_rows=relationship_residual_rows,
        unmatched_bank_rows=unmatched_bank_rows,
        unmatched_journal_rows=unmatched_journal_rows,
        bank_pdf_non_movement_rows=bank_pdf_non_movement_rows,
    )
    outputs = [
        output
        for output in outputs
        if not (
            isinstance(output, dict) and output.get("path") == review_handoff_path.name
        )
    ]
    outputs.append(_review_handoff_output_record(review_handoff_path, language))

    spanish = _is_spanish(language)

    final_artifacts_path = _write_json(
        output_dir / "final_artifacts.json",
        {
            "schema_version": SCHEMA_VERSION,
            "plugin": PLUGIN_NAME,
            "workflow": WORKFLOW_NAME,
            "run_id": run_id,
            "completed_at": _utc_now(),
            "outputs": outputs,
            "caveats": [
                (
                    "Las coincidencias deterministas solo se aceptan conforme a las reglas del script; las filas sin conciliar requieren la interpretación explícita de Claude o de la persona revisora."
                    if spanish
                    else "Deterministic matches are accepted only by the script rules; unmatched rows require explicit Claude or reviewer interpretation."
                ),
                (
                    "Los datos de revisión MCP tienen un límite; use las salidas CSV, XLSX y JSON como conjunto completo de evidencias."
                    if spanish
                    else "The MCP review payload is bounded; use CSV/XLSX/JSON outputs as the complete evidence set."
                ),
                (
                    "ui_decisions.json queda pendiente hasta que Claude, el widget MCP o la revisión alternativa registren las decisiones."
                    if spanish
                    else "ui_decisions.json is pending until Claude, the MCP widget, or fallback review records decisions."
                ),
            ],
            "next_actions": [
                (
                    "Ejecute validate_journal_bank_review y, cuando MCP esté disponible, render_journal_bank_review."
                    if spanish
                    else "Call validate_journal_bank_review, then render_journal_bank_review when MCP is available."
                ),
                (
                    "Revise las filas bancarias y del diario sin conciliar antes de considerar completo el paquete."
                    if spanish
                    else "Review unmatched bank and journal rows before treating the package as complete."
                ),
                (
                    "No convierta filas ambiguas en coincidencias sin modificar las reglas deterministas y volver a ejecutar el proceso."
                    if spanish
                    else "Do not promote ambiguous rows to matched without changing deterministic rules and rerunning."
                ),
            ],
            "status": "written_pending_review",
        },
    )
    refresh_review_execution_trace(run_intake_path, final_artifacts_path)

    return ReviewSessionResult(
        run_id=run_id,
        run_intake_path=run_intake_path,
        review_payload_path=review_payload_path,
        ui_decisions_path=ui_decisions_path,
        final_artifacts_path=final_artifacts_path,
        review_item_count=len(items),
    )
