"""Run pipeline orchestration (CONTRACT §3/§4/§5).

Pipeline: parse → fetch invoices → load memory → classify (deterministic + agent)
→ persist snapshot → emit SSE/status transitions.

Also ships an **offline CLI** that runs parse + classify with no Odoo/DB/model:

    python -m app.runner --offline --sheet <xlsx> --invoices <json> \
        --name-map <json> --month 2026-10 [--rules <json>]

The offline path imports only ``openpyxl`` + stdlib (``config``/``odoo``/``agent``
are imported lazily inside the online path only), so it runs in the lean image.
"""

from __future__ import annotations

import argparse
import json
import logging
import os
import sys
from datetime import date
from pathlib import Path
from typing import Any, Callable, Optional, Sequence

from app.db import InMemoryStore, Store
from app.matching import Invoice, MatchResult, MatchSettings, PlannedLine, classify, parse_planned_lines
from app.telemetry.otel import TraceCollector

logger = logging.getLogger(__name__)

EmitFn = Callable[[str, dict[str, Any]], None]

_STATUS_KEYS = ("green", "amber", "red", "unplanned", "total")


def parse_month(value: str) -> tuple[int, int]:
    """Parse ``YYYY-MM`` into ``(year, month)``."""
    parts = value.strip().split("-")
    if len(parts) != 2:
        raise ValueError(f"month must be YYYY-MM, got {value!r}")
    year, month = int(parts[0]), int(parts[1])
    if not 1 <= month <= 12:
        raise ValueError(f"month out of range: {value!r}")
    return year, month


def compute_totals(results: Sequence[MatchResult]) -> dict[str, Any]:
    """Count + sum results into the ``runs.totals_json`` shape (CONTRACT §2)."""
    counts = {k: 0 for k in _STATUS_KEYS}
    amounts = {k: 0.0 for k in _STATUS_KEYS}
    for res in results:
        key = "unplanned" if res.status == "billed_unplanned" else res.status
        if key not in counts:  # defensive
            continue
        amount = (
            res.invoiced_amount if res.status == "billed_unplanned" else res.planned_amount
        ) or 0.0
        counts[key] += 1
        counts["total"] += 1
        amounts[key] += amount
        amounts["total"] += amount
    return {
        "counts": counts,
        "amounts": {k: round(v, 2) for k, v in amounts.items()},
    }


def _finalize(
    store: Store,
    run_id: int,
    planned: Sequence[PlannedLine],
    invoices: Sequence[Invoice],
    results: Sequence[MatchResult],
    trace: Optional[TraceCollector],
    emit: Optional[EmitFn],
) -> dict[str, Any]:
    """Persist the snapshot, emit per-line decisions, mark the run done."""
    planned_map = store.save_planned_lines(run_id, planned)
    invoice_map = store.save_invoices(run_id, invoices)
    store.save_results(run_id, results, planned_map, invoice_map)
    if trace is not None:
        store.save_traces(run_id, trace.as_trace_rows())

    if emit is not None:
        for res in results:
            pid = (
                planned_map.get(res.source_row) if res.source_row is not None else None
            )
            emit(
                "line.decided",
                {
                    "planned_line_id": pid,
                    "status": res.status,
                    "confidence": res.confidence,
                    "reasoning": res.reasoning,
                },
            )

    totals = compute_totals(results)
    store.finish_run(run_id, "done", totals)
    if emit is not None:
        emit("run.done", {"totals": totals})
    return totals


def run_classification(
    store: Store,
    run_id: int,
    sheet_path: str | Path,
    year: int,
    month: int,
    invoices: Sequence[Invoice],
    name_map: Sequence[dict[str, Any]],
    memory_rules: Sequence[dict[str, Any]],
    settings: MatchSettings,
    adjudicate: Optional[Callable] = None,
    emit: Optional[EmitFn] = None,
    trace: Optional[TraceCollector] = None,
    already_parsed: Optional[Sequence[PlannedLine]] = None,
) -> dict[str, Any]:
    """Parse (unless supplied), classify, persist and finalize a run.

    This is the shared core used by both the offline CLI and the online run. It
    never touches Odoo/config, so it stays import-light.
    """
    trace = trace or TraceCollector(emit=emit)

    if emit is not None:
        emit("run.status", {"status": "matching"})
    store.set_run_status(run_id, "matching")

    if already_parsed is not None:
        planned = list(already_parsed)
    else:
        with trace.span("parse_sheet", {"month": f"{year}-{month:02d}"}):
            planned = parse_planned_lines(sheet_path, year, month)

    with trace.span("classify", {"planned": len(planned), "invoices": len(invoices)}):
        results = classify(planned, invoices, name_map, memory_rules, settings, adjudicate)

    return _finalize(store, run_id, planned, invoices, results, trace, emit)


def execute_online_run(
    settings: "Any",
    store: Store,
    run_id: int,
    sheet_path: str | Path,
    year: int,
    month: int,
    provider: str,
    emit: Optional[EmitFn] = None,
) -> dict[str, Any]:
    """Full online pipeline (blocking) — intended to run via ``asyncio.to_thread``.

    Lazily imports Odoo + the agent layer so the offline path never pulls them in.
    """
    from app.odoo import build_odoo_client  # lazy: pulls httpx

    trace = TraceCollector(emit=emit)
    try:
        if emit is not None:
            emit("run.status", {"status": "parsing"})
        store.set_run_status(run_id, "parsing")
        with trace.span("parse_sheet", {"month": f"{year}-{month:02d}"}):
            planned = parse_planned_lines(sheet_path, year, month)

        if emit is not None:
            emit("run.status", {"status": "fetching"})
        store.set_run_status(run_id, "fetching")
        client = build_odoo_client(settings)
        with trace.span("fetch_invoices", {"month": f"{year}-{month:02d}"}):
            invoices = client.fetch_software_invoices(year, month)

        name_map = store.load_name_map()
        memory_rules = store.load_memory_rules()

        match_settings = settings.match_settings(year, month)
        adjudicate = None
        try:
            from app.agent import build_adjudicator  # lazy: may import strands

            adjudicate = build_adjudicator(
                provider, match_settings, settings, emit=emit
            )
        except Exception as exc:  # pragma: no cover - strands optional
            logger.warning("Agent unavailable (%s); residue stays amber.", exc)

        return run_classification(
            store,
            run_id,
            sheet_path,
            year,
            month,
            invoices,
            name_map,
            memory_rules,
            match_settings,
            adjudicate=adjudicate,
            emit=emit,
            trace=trace,
            already_parsed=planned,
        )
    except Exception as exc:
        logger.exception("Run %s failed", run_id)
        store.finish_run(run_id, "failed", error=str(exc))
        if emit is not None:
            emit("run.error", {"error": str(exc)})
        raise


# ---------------------------------------------------------------------------
# Offline CLI
# ---------------------------------------------------------------------------

def _settings_from_env() -> MatchSettings:
    """Build MatchSettings from env without importing pydantic config."""
    def _f(name: str, default: float) -> float:
        try:
            return float(os.getenv(name, default))
        except (TypeError, ValueError):
            return default

    return MatchSettings(
        abs_tolerance=_f("MATCH_ABS_TOLERANCE", 1.00),
        rel_tolerance=_f("MATCH_REL_TOLERANCE", 0.005),
        rounding=int(_f("MATCH_ROUNDING", 2)),
        unresolved_policy=os.getenv("UNRESOLVED_POLICY", "amber"),  # type: ignore[arg-type]
    )


def _load_invoices(path: str) -> list[Invoice]:
    raw = json.loads(Path(path).read_text(encoding="utf-8"))
    return [Invoice.from_dict(item) for item in raw]


def _load_json_list(path: Optional[str]) -> list[dict[str, Any]]:
    if not path:
        return []
    return json.loads(Path(path).read_text(encoding="utf-8"))


def _print_summary(run: dict[str, Any], results: Sequence[MatchResult]) -> None:
    """Print a readable table + JSON of results to stdout."""
    header = f"{'ROW':>4}  {'STATUS':<16} {'TIER':<13} {'CLIENT':<32} " \
             f"{'PLANNED':>12} {'INVOICED':>12} {'VAR':>10}  REASONING"
    print(header)
    print("-" * len(header))
    for res in results:
        row = "" if res.source_row is None else str(res.source_row)
        client = (res.client_name or "")[:32]
        planned = "" if res.planned_amount is None else f"{res.planned_amount:,.2f}"
        invoiced = "" if res.invoiced_amount is None else f"{res.invoiced_amount:,.2f}"
        var = "" if res.variance is None else f"{res.variance:,.2f}"
        reasoning = (res.reasoning or "")[:60]
        print(
            f"{row:>4}  {res.status:<16} {res.tier:<13} {client:<32} "
            f"{planned:>12} {invoiced:>12} {var:>10}  {reasoning}"
        )

    totals = run.get("totals_json") or {}
    print("\nTOTALS:", json.dumps(totals))
    print("\nRESULTS JSON:")
    print(json.dumps([r.to_dict() for r in results], indent=2, default=str))


def run_offline(args: argparse.Namespace) -> int:
    """Offline reconciliation: parse + classify, deterministic (adjudicate=None)."""
    year, month = parse_month(args.month)
    settings = _settings_from_env()
    settings.period_year, settings.period_month = year, month

    invoices = _load_invoices(args.invoices)
    name_map = _load_json_list(args.name_map)
    memory_rules = _load_json_list(args.rules)

    store = InMemoryStore()
    store.seed_name_map(name_map)
    store.seed_memory_rules(memory_rules)
    run_id = store.create_run(
        f"{year}-{month:02d}", "offline", "none", Path(args.sheet).name
    )

    # Re-run classify once so we can also print the MatchResult objects directly
    # (the store keeps only serialized rows). This keeps the offline path simple.
    planned = parse_planned_lines(args.sheet, year, month)
    results = classify(planned, invoices, name_map, memory_rules, settings, None)
    trace = TraceCollector(emit=None)
    _finalize(store, run_id, planned, invoices, results, trace, emit=None)

    run = store.get_run(run_id) or {}
    _print_summary(run, results)
    return 0


def build_arg_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="app.runner",
        description="Invoice Match run pipeline (offline CLI).",
    )
    parser.add_argument("--offline", action="store_true", help="run without Odoo/DB/model")
    parser.add_argument("--sheet", help="path to the planning .xlsx")
    parser.add_argument("--invoices", help="path to invoices JSON")
    parser.add_argument("--name-map", dest="name_map", help="path to name_map JSON")
    parser.add_argument("--rules", help="path to memory_rules JSON (optional)")
    parser.add_argument("--month", help="reconciled month, YYYY-MM")
    return parser


def main(argv: Optional[list[str]] = None) -> int:
    logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
    parser = build_arg_parser()
    args = parser.parse_args(argv)
    if not args.offline:
        parser.error("only --offline mode is available from the CLI; the online "
                     "pipeline runs via the FastAPI service (app.main).")
    missing = [n for n in ("sheet", "invoices", "name_map", "month") if not getattr(args, n)]
    if missing:
        parser.error(f"--offline requires: {', '.join('--' + m.replace('_', '-') for m in missing)}")
    return run_offline(args)


if __name__ == "__main__":
    raise SystemExit(main())
