"""Three-tier reconciliation decision (CONTRACT §5).

Tier 1 — deterministic green/red (no model).
Tier 2 — the ambiguous residue; calls the optional ``adjudicate`` callback so an
         agent can plug in. With no callback the residue defaults to amber with
         ranked candidates and reasoning "needs review".
Tier 3 — reverse check: Software invoices with no planned line → billed_unplanned.

Memory rules adjust tiers 1/2: ``quarterly`` suppresses a red in off-months;
``partial`` / ``bundled`` pre-explain an expected amber; ``ignore`` drops a line.
Pure stdlib + sibling modules only.
"""

from __future__ import annotations

from typing import Callable, Iterable, Optional, Sequence

from .candidates import get_candidates
from .models import (
    Candidate,
    Invoice,
    MatchResult,
    MatchSettings,
    MemoryRule,
    NameMapEntry,
    PlannedLine,
)
from .variance import variance as compute_variance
from .variance import within_tolerance

# Callback an agent supplies to decide a residue line. It receives the planned
# line and the full invoice set and returns a ready MatchResult (tier "agent").
AdjudicateFn = Callable[[PlannedLine, Sequence[Invoice]], MatchResult]

_MAX_CANDIDATES = 5


def _norm(text: str) -> str:
    return " ".join(str(text).strip().lower().split())


def _coerce_name_map(name_map: Iterable[object]) -> tuple[dict, dict]:
    """Split the name map into (manual, learned) indices keyed by normalised name."""
    manual: dict[str, NameMapEntry] = {}
    learned: dict[str, NameMapEntry] = {}
    for raw in name_map or []:
        entry = raw if isinstance(raw, NameMapEntry) else NameMapEntry.from_dict(raw)  # type: ignore[arg-type]
        key = _norm(entry.planning_name)
        (learned if entry.source == "learned" else manual)[key] = entry
    return manual, learned


def _coerce_rules(memory_rules: Iterable[object]) -> list[MemoryRule]:
    rules: list[MemoryRule] = []
    for raw in memory_rules or []:
        rules.append(raw if isinstance(raw, MemoryRule) else MemoryRule.from_dict(raw))  # type: ignore[arg-type]
    return rules


def _rules_for(rules: Sequence[MemoryRule], client_name: str) -> list[MemoryRule]:
    key = _norm(client_name)
    return [r for r in rules if r.scope == "*" or _norm(r.scope) == key]


def _resolve(
    line: PlannedLine,
    manual: dict,
    learned: dict,
    invoices_by_customer: dict[str, Invoice],
) -> None:
    """Populate the line's resolution fields in place (CONTRACT §5)."""
    key = _norm(line.client_name)
    entry: Optional[NameMapEntry] = manual.get(key) or learned.get(key)
    if entry is not None:
        line.odoo_customer_id = entry.odoo_customer_id
        line.canonical_client = entry.odoo_customer_name or line.client_name
        line.match_source = "map" if key in manual else "learned"
        return
    exact = invoices_by_customer.get(key)
    if exact is not None and exact.odoo_customer_id is not None:
        line.odoo_customer_id = exact.odoo_customer_id
        line.canonical_client = exact.customer
        line.match_source = "exact"
        return
    line.match_source = "unresolved"


def _result_skeleton(line: PlannedLine) -> dict:
    return {
        "source_row": line.source_row,
        "client_name": line.client_name,
        "planned_amount": line.planned_amount,
    }


def _quarterly_off_month(
    rules: Sequence[MemoryRule], settings: MatchSettings
) -> Optional[MemoryRule]:
    """Return a quarterly rule if the reconciled month is an off-month for it."""
    if settings.period_month is None:
        return None
    for rule in rules:
        if rule.rule_type != "quarterly":
            continue
        months = rule.params.get("months") or []
        if settings.period_month not in months:
            return rule
    return None


def _adjudicate_residue(
    line: PlannedLine,
    invoices: Sequence[Invoice],
    client_invoices: Sequence[Invoice],
    line_rules: Sequence[MemoryRule],
    settings: MatchSettings,
    adjudicate: Optional[AdjudicateFn],
) -> MatchResult:
    """Decide a tier-2 residue line → amber (rule, agent, or default)."""
    candidates = get_candidates(line, invoices)[:_MAX_CANDIDATES]

    # Best client-matched invoice (if the client resolved) becomes the matched
    # figure so a reviewer sees a concrete variance for partials/over-bills.
    best_client_inv = client_invoices[0] if client_invoices else None
    matched_id = best_client_inv.odoo_id if best_client_inv else None
    invoiced = best_client_inv.amount_untaxed if best_client_inv else None
    var = (
        compute_variance(line.planned_amount, invoiced, settings.rounding)
        if invoiced is not None
        else None
    )

    # Memory pre-explain: partial / bundled rules describe an expected amber.
    for rule in line_rules:
        if rule.rule_type in ("partial", "bundled"):
            note = rule.note or f"Expected {rule.rule_type} billing (memory rule)."
            return MatchResult(
                status="amber",
                tier="deterministic",
                matched_invoice_id=matched_id,
                variance=var,
                confidence=0.7,
                reasoning=note,
                candidates=candidates,
                invoiced_amount=invoiced,
                **_result_skeleton(line),
            )

    # Agent plug-in point.
    if adjudicate is not None:
        res = adjudicate(line, invoices)
        # Fill context the agent may have left blank; keep its decision/tier.
        res.tier = res.tier or "agent"
        if res.source_row is None:
            res.source_row = line.source_row
        if res.client_name is None:
            res.client_name = line.client_name
        if res.planned_amount is None:
            res.planned_amount = line.planned_amount
        if not res.candidates:
            res.candidates = candidates
        return res

    # Default residue: amber, needs human review.
    if line.match_source == "unresolved":
        top = candidates[0] if candidates else None
        hint = (
            f" Closest invoice: {top.number} ({top.customer})."
            if top and settings.unresolved_policy == "fuzzy"
            else ""
        )
        reasoning = f"Unresolved client — needs review.{hint}"
    else:
        reasoning = "needs review"
    return MatchResult(
        status="amber",
        tier="agent",
        matched_invoice_id=matched_id,
        variance=var,
        confidence=0.5,
        reasoning=reasoning,
        candidates=candidates,
        invoiced_amount=invoiced,
        **_result_skeleton(line),
    )


def classify(
    planned_lines: Sequence[PlannedLine],
    invoices: Sequence[Invoice],
    name_map: Iterable[object],
    memory_rules: Iterable[object],
    settings: MatchSettings,
    adjudicate: Optional[AdjudicateFn] = None,
) -> list[MatchResult]:
    """Classify every planned line, then reverse-check for billed_unplanned.

    Args:
        planned_lines: Parsed planned lines (resolution fields filled in place).
        invoices: Software invoices for the month.
        name_map: ``name_map`` rows (NameMapEntry or dicts) for client resolution.
        memory_rules: ``memory_rules`` rows (MemoryRule or dicts).
        settings: Tolerances, policy and the reconciled period.
        adjudicate: Optional agent callback for the tier-2 residue; when ``None``
            the residue defaults to amber with ranked candidates.

    Returns:
        One :class:`MatchResult` per kept planned line, followed by one per
        billed_unplanned invoice. ``matched_invoice_id`` carries the Odoo move id;
        the DB layer remaps it to the ``invoices.id`` PK on persist.
    """
    manual, learned = _coerce_name_map(name_map)
    rules = _coerce_rules(memory_rules)
    invoices_by_customer = {_norm(inv.customer): inv for inv in invoices}

    results: list[MatchResult] = []
    resolved_customer_ids: set[int] = set()

    for line in planned_lines:
        _resolve(line, manual, learned, invoices_by_customer)
        line_rules = _rules_for(rules, line.client_name)

        # `ignore` rule: drop the line entirely.
        if any(r.rule_type == "ignore" for r in line_rules):
            continue

        if line.odoo_customer_id is not None:
            resolved_customer_ids.add(line.odoo_customer_id)
            client_invoices = [
                inv for inv in invoices if inv.odoo_customer_id == line.odoo_customer_id
            ]
            within = [
                inv
                for inv in client_invoices
                if within_tolerance(
                    line.planned_amount,
                    inv.amount_untaxed,
                    settings.abs_tolerance,
                    settings.rel_tolerance,
                    settings.rounding,
                )
            ]

            if not client_invoices:
                # Tier 1 red — unless a quarterly off-month suppresses it.
                off_month = _quarterly_off_month(line_rules, settings)
                if off_month is not None:
                    results.append(
                        MatchResult(
                            status="green",
                            tier="deterministic",
                            confidence=1.0,
                            reasoning=(
                                off_month.note
                                or "Quarterly billing — no invoice expected this "
                                "(off) month."
                            ),
                            **_result_skeleton(line),
                        )
                    )
                else:
                    results.append(
                        MatchResult(
                            status="red",
                            tier="deterministic",
                            confidence=1.0,
                            reasoning="No Software invoice found for resolved client.",
                            **_result_skeleton(line),
                        )
                    )
                continue

            if len(within) == 1:
                # Tier 1 green: exactly one candidate within tolerance.
                inv = within[0]
                results.append(
                    MatchResult(
                        status="green",
                        tier="deterministic",
                        matched_invoice_id=inv.odoo_id,
                        variance=compute_variance(
                            line.planned_amount, inv.amount_untaxed, settings.rounding
                        ),
                        confidence=1.0,
                        reasoning="Resolved client, single invoice within tolerance.",
                        invoiced_amount=inv.amount_untaxed,
                        **_result_skeleton(line),
                    )
                )
                continue

            # Residue: zero within tolerance (partial/over) or multiple within.
            results.append(
                _adjudicate_residue(
                    line, invoices, client_invoices, line_rules, settings, adjudicate
                )
            )
            continue

        # Unresolved client → tier 2.
        results.append(
            _adjudicate_residue(line, invoices, [], line_rules, settings, adjudicate)
        )

    # Tier 3 — reverse check for billed_unplanned.
    for inv in invoices:
        if inv.odoo_customer_id is not None and inv.odoo_customer_id in resolved_customer_ids:
            continue
        results.append(
            MatchResult(
                status="billed_unplanned",
                tier="reverse",
                matched_invoice_id=inv.odoo_id,
                confidence=1.0,
                reasoning="Software invoice with no planned line.",
                client_name=inv.customer,
                invoiced_amount=inv.amount_untaxed,
            )
        )

    return results
