"""Lightweight agent orchestrator for the tier-2 residue (CONTRACT §5).

The orchestrator takes the ambiguous residue lines, splits them across ``N``
parallel worker agents, and each worker is a *narrow adjudicator*: it decides
``partial`` / ``bundled`` / ``unmatched`` / ``low-confidence`` and returns an
**amber** result with confidence + reasoning (tier ``agent``). No worker can turn
a line green/red — tier 1 owns those.

``strands`` is imported lazily so the rest of the service runs without it; when it
is missing (or any call fails) the adjudicator degrades gracefully to a default
amber so the pipeline never breaks.
"""

from __future__ import annotations

import json
import logging
from concurrent.futures import ThreadPoolExecutor
from typing import Any, Callable, Optional, Sequence

from app.matching.candidates import get_candidates
from app.matching.models import Invoice, MatchResult, MatchSettings, PlannedLine
from app.matching.variance import variance as compute_variance

logger = logging.getLogger(__name__)

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

_SYSTEM_PROMPT = (
    "You are a billing reconciliation adjudicator. A planned billing line could "
    "not be auto-matched to exactly one Software invoice within tolerance. Using "
    "the candidate invoices provided, decide the single best explanation: "
    "'partial' (invoice covers only part of the plan), 'bundled' (one invoice "
    "covers several planned lines), 'unmatched' (no suitable invoice), or "
    "'low_confidence' (a plausible but uncertain match). You NEVER confirm a clean "
    "match — your output is always amber for human review. Respond with ONLY a "
    "JSON object: {\"classification\": <one of the four>, \"confidence\": <0..1>, "
    "\"matched_invoice_id\": <odoo id or null>, \"reasoning\": <one sentence>}."
)

_MAX_CANDIDATES = 5


def _candidate_payload(line: PlannedLine, invoices: Sequence[Invoice]) -> list[dict]:
    return [c.to_dict() for c in get_candidates(line, invoices)[:_MAX_CANDIDATES]]


def _default_amber(
    line: PlannedLine, invoices: Sequence[Invoice], settings: MatchSettings, note: str
) -> MatchResult:
    """Fallback amber used when no model is available or a worker fails."""
    cands = get_candidates(line, invoices)[:_MAX_CANDIDATES]
    matched = None
    invoiced = None
    var = None
    if line.odoo_customer_id is not None:
        client_inv = [i for i in invoices if i.odoo_customer_id == line.odoo_customer_id]
        if client_inv:
            matched = client_inv[0].odoo_id
            invoiced = client_inv[0].amount_untaxed
            var = compute_variance(line.planned_amount, invoiced, settings.rounding)
    return MatchResult(
        status="amber",
        tier="agent",
        matched_invoice_id=matched,
        variance=var,
        confidence=0.5,
        reasoning=note,
        candidates=cands,
        source_row=line.source_row,
        client_name=line.client_name,
        planned_amount=line.planned_amount,
        invoiced_amount=invoiced,
    )


def _parse_agent_json(text: str) -> Optional[dict[str, Any]]:
    """Extract the first JSON object from a model response."""
    start = text.find("{")
    end = text.rfind("}")
    if start == -1 or end == -1 or end < start:
        return None
    try:
        return json.loads(text[start : end + 1])
    except json.JSONDecodeError:
        return None


class Orchestrator:
    """Splits residue lines across N worker adjudicators (parallel)."""

    def __init__(
        self,
        provider: str,
        settings: MatchSettings,
        app_settings: "Any",
        num_workers: int = 4,
        emit: Optional[EmitFn] = None,
    ) -> None:
        self.provider = provider
        self.settings = settings
        self.app_settings = app_settings
        self.num_workers = max(1, num_workers)
        self.emit = emit
        self._model: Any = None
        self._tools: Any = None
        self._model_ready = False

    # -- model wiring (lazy) -----------------------------------------------

    def _ensure_model(self) -> bool:
        """Build the Strands model + tools once; return False if unavailable."""
        if self._model_ready:
            return self._model is not None
        self._model_ready = True
        try:
            from app.agent.providers import build_model
            from app.agent.tools import build_strands_tools

            self._model = build_model(self.provider, self.app_settings)
            self._tools = build_strands_tools()
        except Exception as exc:  # strands missing, bad creds, etc.
            logger.warning("Agent model unavailable (%s); defaulting residue to amber.", exc)
            self._model = None
        return self._model is not None

    # -- single line adjudication ------------------------------------------

    def adjudicate(
        self, planned_line: PlannedLine, invoices: Sequence[Invoice]
    ) -> MatchResult:
        """Adjudicate one residue line → amber MatchResult (tier ``agent``)."""
        if not self._ensure_model():
            return _default_amber(
                planned_line, invoices, self.settings, "needs review"
            )

        cands = _candidate_payload(planned_line, invoices)
        prompt = json.dumps(
            {
                "client_name": planned_line.client_name,
                "canonical_client": planned_line.canonical_client,
                "planned_amount": planned_line.planned_amount,
                "match_source": planned_line.match_source,
                "candidates": cands,
            }
        )
        try:
            from strands import Agent

            agent = Agent(
                model=self._model, tools=self._tools, system_prompt=_SYSTEM_PROMPT
            )
            raw = agent(prompt)
            text = getattr(raw, "message", None) or str(raw)
            parsed = _parse_agent_json(text if isinstance(text, str) else str(text))
        except Exception as exc:  # pragma: no cover - needs live SDK
            logger.warning("Worker adjudication failed (%s); default amber.", exc)
            parsed = None

        if not parsed:
            return _default_amber(
                planned_line, invoices, self.settings, "needs review"
            )

        matched_id = parsed.get("matched_invoice_id")
        invoiced = None
        var = None
        if matched_id is not None:
            inv = next((i for i in invoices if i.odoo_id == matched_id), None)
            if inv is not None:
                invoiced = inv.amount_untaxed
                var = compute_variance(
                    planned_line.planned_amount, invoiced, self.settings.rounding
                )
        classification = parsed.get("classification", "low_confidence")
        try:
            confidence = max(0.0, min(1.0, float(parsed.get("confidence", 0.5))))
        except (TypeError, ValueError):
            confidence = 0.5
        reasoning = parsed.get("reasoning") or f"Agent: {classification}."

        return MatchResult(
            status="amber",  # tier 2 never confirms a clean match
            tier="agent",
            matched_invoice_id=matched_id,
            variance=var,
            confidence=confidence,
            reasoning=f"[{classification}] {reasoning}",
            candidates=get_candidates(planned_line, invoices)[:_MAX_CANDIDATES],
            source_row=planned_line.source_row,
            client_name=planned_line.client_name,
            planned_amount=planned_line.planned_amount,
            invoiced_amount=invoiced,
        )

    # -- parallel batch -----------------------------------------------------

    def adjudicate_batch(
        self, lines: Sequence[PlannedLine], invoices: Sequence[Invoice]
    ) -> dict[int, MatchResult]:
        """Adjudicate many residue lines in parallel, keyed by source_row.

        Lines are sharded round-robin across ``num_workers``; each shard is a
        worker agent. Emits ``agent.spawned`` / ``agent.state`` SSE events.
        """
        if not lines:
            return {}

        shards: list[list[PlannedLine]] = [[] for _ in range(self.num_workers)]
        for idx, line in enumerate(lines):
            shards[idx % self.num_workers].append(line)
        shards = [s for s in shards if s]

        self._emit("agent.spawned", {"agent": "orchestrator", "role": "coordinator",
                                     "lines": [l.source_row for l in lines]})

        results: dict[int, MatchResult] = {}

        def run_shard(worker_idx: int, shard: list[PlannedLine]) -> dict[int, MatchResult]:
            name = f"worker-{worker_idx}"
            self._emit("agent.spawned", {"agent": name, "role": "adjudicator",
                                         "lines": [l.source_row for l in shard]})
            local: dict[int, MatchResult] = {}
            for line in shard:
                self._emit("agent.state", {"agent": name, "state": "working",
                                           "line": line.source_row})
                res = self.adjudicate(line, invoices)
                local[line.source_row] = res
                self._emit("agent.state", {"agent": name, "state": "done",
                                           "line": line.source_row})
            return local

        with ThreadPoolExecutor(max_workers=len(shards)) as pool:
            futures = [pool.submit(run_shard, i, s) for i, s in enumerate(shards)]
            for fut in futures:
                results.update(fut.result())
        return results

    def _emit(self, event: str, data: dict[str, Any]) -> None:
        if self.emit is not None:
            try:
                self.emit(event, data)
            except Exception:  # pragma: no cover - emit is best-effort
                logger.debug("SSE emit failed for %s", event, exc_info=True)


def build_adjudicator(
    provider: str,
    settings: MatchSettings,
    app_settings: "Any",
    num_workers: int = 4,
    emit: Optional[EmitFn] = None,
) -> Callable[[PlannedLine, Sequence[Invoice]], MatchResult]:
    """Return an ``adjudicate(line, invoices)`` callback for ``classifier.classify``.

    Binds the provider/settings into an :class:`Orchestrator` and exposes its
    single-line adjudicator with the 2-arg signature ``classify`` expects.
    """
    orch = Orchestrator(provider, settings, app_settings, num_workers, emit)
    return orch.adjudicate
