"""Dataclasses for the matching core.

Field names mirror the canonical DB schema in ``docs/CONTRACT.md`` §2 so a
:class:`MatchResult` maps straight onto a ``results`` row and a
:class:`PlannedLine` onto a ``planned_lines`` row. Pure stdlib only.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from datetime import date
from typing import Any, Literal, Optional

# ---------------------------------------------------------------------------
# Controlled vocabularies (kept as Literals for clarity; validated loosely)
# ---------------------------------------------------------------------------

Status = Literal["green", "amber", "red", "billed_unplanned"]
Tier = Literal["deterministic", "agent", "reverse"]
MatchSource = Literal["map", "exact", "learned", "unresolved"]
RuleType = Literal["quarterly", "partial", "bundled", "ignore"]


@dataclass
class MatchSettings:
    """Tolerances and policy knobs handed to the classifier.

    This is a plain stdlib dataclass (not pydantic) so the ``matching`` package
    stays import-pure. :mod:`app.config` builds one of these from env settings.
    ``period_year`` / ``period_month`` describe the reconciled month and let
    memory rules such as ``quarterly`` reason about on/off months.
    """

    abs_tolerance: float = 1.00
    rel_tolerance: float = 0.005
    rounding: int = 2
    unresolved_policy: Literal["amber", "fuzzy"] = "amber"
    period_year: Optional[int] = None
    period_month: Optional[int] = None


@dataclass
class PlannedLine:
    """One planned billing line read from the Project Planning sheet."""

    source_row: int
    client_name: str
    planned_amount: float
    project_value: Optional[float] = None
    # Resolution fields populated by the classifier (null until resolved).
    canonical_client: Optional[str] = None
    odoo_customer_id: Optional[int] = None
    match_source: MatchSource = "unresolved"

    def to_dict(self) -> dict[str, Any]:
        return {
            "source_row": self.source_row,
            "client_name": self.client_name,
            "planned_amount": self.planned_amount,
            "project_value": self.project_value,
            "canonical_client": self.canonical_client,
            "odoo_customer_id": self.odoo_customer_id,
            "match_source": self.match_source,
        }


@dataclass
class Invoice:
    """A posted Software invoice pulled from Odoo for the reconciled month."""

    odoo_id: int
    number: str
    customer: str
    invoice_date: date
    amount_untaxed: float
    amount_total: float
    sent: bool = False
    odoo_customer_id: Optional[int] = None
    description: str = ""

    def to_dict(self) -> dict[str, Any]:
        return {
            "odoo_id": self.odoo_id,
            "number": self.number,
            "customer": self.customer,
            "odoo_customer_id": self.odoo_customer_id,
            "invoice_date": self.invoice_date.isoformat(),
            "amount_untaxed": self.amount_untaxed,
            "amount_total": self.amount_total,
            "sent": bool(self.sent),
            "description": self.description,
        }

    @classmethod
    def from_dict(cls, raw: dict[str, Any]) -> "Invoice":
        """Build an Invoice from the JSON shape in ``invoices_sample.json``."""
        raw_date = raw["invoice_date"]
        if isinstance(raw_date, date):
            inv_date = raw_date
        else:
            inv_date = date.fromisoformat(str(raw_date)[:10])
        return cls(
            odoo_id=int(raw["odoo_id"]),
            number=str(raw["number"]),
            customer=str(raw["customer"]),
            invoice_date=inv_date,
            amount_untaxed=float(raw["amount_untaxed"]),
            amount_total=float(raw.get("amount_total", raw["amount_untaxed"])),
            sent=bool(raw.get("sent", False)),
            odoo_customer_id=(
                int(raw["odoo_customer_id"])
                if raw.get("odoo_customer_id") is not None
                else None
            ),
            description=str(raw.get("description", "")),
        )


@dataclass
class Candidate:
    """A ranked invoice candidate for one planned line (see CONTRACT §5)."""

    invoice_id: int
    amount_delta: float
    date_proximity_days: int
    desc_similarity: float
    score: float
    # Extra context carried for readable ``candidates_json`` / UI display.
    number: str = ""
    customer: str = ""
    amount_untaxed: float = 0.0
    client_match: bool = False

    def to_dict(self) -> dict[str, Any]:
        return {
            "invoice_id": self.invoice_id,
            "number": self.number,
            "customer": self.customer,
            "amount_untaxed": self.amount_untaxed,
            "amount_delta": self.amount_delta,
            "date_proximity_days": self.date_proximity_days,
            "desc_similarity": self.desc_similarity,
            "client_match": self.client_match,
            "score": self.score,
        }


@dataclass
class MatchResult:
    """One reconciliation outcome — maps onto a ``results`` row."""

    status: Status
    tier: Tier
    planned_line_id: Optional[int] = None
    matched_invoice_id: Optional[int] = None
    variance: Optional[float] = None
    confidence: Optional[float] = None
    reasoning: Optional[str] = None
    candidates: list[Candidate] = field(default_factory=list)
    # Convenience context (not DB columns) for offline summaries / SSE.
    source_row: Optional[int] = None
    client_name: Optional[str] = None
    planned_amount: Optional[float] = None
    invoiced_amount: Optional[float] = None

    def to_dict(self) -> dict[str, Any]:
        return {
            "status": self.status,
            "tier": self.tier,
            "planned_line_id": self.planned_line_id,
            "matched_invoice_id": self.matched_invoice_id,
            "variance": self.variance,
            "confidence": self.confidence,
            "reasoning": self.reasoning,
            "candidates_json": [c.to_dict() for c in self.candidates],
            "source_row": self.source_row,
            "client_name": self.client_name,
            "planned_amount": self.planned_amount,
            "invoiced_amount": self.invoiced_amount,
        }


@dataclass
class NameMapEntry:
    """A ``name_map`` memory row resolving a planning name to an Odoo customer."""

    planning_name: str
    odoo_customer_id: int
    odoo_customer_name: str
    source: Literal["manual", "learned"] = "manual"
    confidence: float = 1.0

    @classmethod
    def from_dict(cls, raw: dict[str, Any]) -> "NameMapEntry":
        return cls(
            planning_name=str(raw["planning_name"]),
            odoo_customer_id=int(raw["odoo_customer_id"]),
            odoo_customer_name=str(raw.get("odoo_customer_name", "")),
            source=str(raw.get("source", "manual")),  # type: ignore[arg-type]
            confidence=float(raw.get("confidence", 1.0)),
        )


@dataclass
class MemoryRule:
    """A ``memory_rules`` row (quarterly / partial / bundled / ignore)."""

    scope: str
    rule_type: RuleType
    params: dict[str, Any] = field(default_factory=dict)
    note: Optional[str] = None

    @classmethod
    def from_dict(cls, raw: dict[str, Any]) -> "MemoryRule":
        params = raw.get("params_json", raw.get("params", {})) or {}
        if isinstance(params, str):
            import json

            params = json.loads(params)
        return cls(
            scope=str(raw["scope"]),
            rule_type=str(raw["rule_type"]),  # type: ignore[arg-type]
            params=params,
            note=raw.get("note"),
        )
