"""The decided layer: reading and writing `overrides`, the one record of a human decision
over a collected or computed value (build plan §4 item 1; rian, 14 Sep: decided beats
standard, standard beats listed, and a machine never overwrites a human value).

Sources of truth: this module, `models/accounts.py` (Override), `services/keying.py` (the
reader in the key path: `Maps.decided`, `product_key`), `tests/test_decided.py`. A row is
`(entity_type, entity_key, field)` unique; `value` is what a person decided, `collected_value`
what the rules had, `set_by`/`set_at`/`reason` who, when and why. Product fields (entity_type
`product`, key `str(product.id)`): `name` (display only for the key: every name-derived key
part is computed from `collected_value`, the spelling the listings arrive under, so a survivor
renamed "1 Million" never loses its `edt`), `line_id`, `variation`, `quantity` (a dict with
`value` and `unit`, optionally `form`, `pack_count`, `pack_unit_value`, `set_contents`).

Writers: the merge session (a typed name at confirm), the override route on `/collectors`,
and `merge_products` moving a loser's rows to its survivor. Readers: `keying.load_maps`
(every rekey and rederive reads it first), `backfill lines`, `merges._ENRICH_FIELDS`.
`decide` upserts inside a savepoint and re-reads on IntegrityError (agents.md: every shared row
tolerates a race).
"""

from __future__ import annotations

from collections.abc import Iterable
from datetime import UTC, datetime
from typing import Any

from sqlalchemy import inspect, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session

from app.models.accounts import Override

#: The fields a person may decide, per entity type.
FIELDS: dict[str, tuple[str, ...]] = {"product": ("name", "line_id", "variation", "quantity")}

#: Product columns a field's override protects from the machine (`merges._ENRICH_FIELDS`).
GUARDED_COLUMNS: dict[str, frozenset[str]] = {
    "name": frozenset({"name"}),
    "line_id": frozenset({"line_id"}),
    "variation": frozenset(),
    "quantity": frozenset({"size_ml", "size_value", "size_unit", "quantity_value", "quantity_unit",
                           "pack_count", "pack_unit_value", "form", "set_contents", "quantity_state"}),
}


_present: dict[int, bool] = {}


def table_present(db: Session) -> bool:
    """Whether `overrides` exists on this database: the pure-logic suites build only the
    catalogue tables, and a reader in the merge path must not fail them. Remembered per
    engine; a suite that creates the table on a fresh engine is a fresh answer."""
    key = id(db.get_bind())
    if key not in _present:
        _present[key] = "overrides" in inspect(db.connection()).get_table_names()
    return _present[key]


def read(db: Session, entity_type: str, keys: Iterable[str] | None = None,
         fields: Iterable[str] | None = None) -> dict[str, dict[str, Override]]:
    """Every override of an entity type, by key then field; `keys` and `fields` narrow it."""
    if not table_present(db):
        return {}
    stmt = select(Override).where(Override.entity_type == entity_type)
    if keys is not None:
        keys = [str(k) for k in keys]
        if not keys:
            return {}
        stmt = stmt.where(Override.entity_key.in_(keys))
    if fields is not None:
        stmt = stmt.where(Override.field.in_(list(fields)))
    out: dict[str, dict[str, Override]] = {}
    for row in db.scalars(stmt):
        out.setdefault(row.entity_key, {})[row.field] = row
    return out


def fields_of(db: Session, entity_type: str, key: str) -> dict[str, Override]:
    return read(db, entity_type, [key]).get(str(key), {})


def guarded_columns(overrides: dict[str, Override]) -> frozenset[str]:
    """The columns the given overrides protect."""
    out: set[str] = set()
    for field in overrides:
        out |= GUARDED_COLUMNS.get(field, frozenset())
    return frozenset(out)


def decide(db: Session, entity_type: str, key: str, field: str, value: Any, *, set_by: int | None,
        reason: str | None = None, collected_value: Any = None) -> Override:
    """Record a decision: insert the row, or update the one that exists. `collected_value` is
    kept from the first row when the caller passes none, so the collected spelling survives a
    second decision on the same field."""
    if field not in FIELDS.get(entity_type, ()):
        raise ValueError(f"{entity_type} has no decidable field {field!r}")
    key = str(key)
    row = db.scalar(select(Override).where(
        Override.entity_type == entity_type, Override.entity_key == key, Override.field == field))
    if row is None:
        savepoint = db.begin_nested()
        try:
            row = Override(entity_type=entity_type, entity_key=key, field=field, value=value,
                           collected_value=collected_value, set_by=set_by, reason=reason,
                           set_at=datetime.now(UTC))
            db.add(row)
            savepoint.commit()
            return row
        except IntegrityError:
            savepoint.rollback()
            row = db.scalar(select(Override).where(
                Override.entity_type == entity_type, Override.entity_key == key, Override.field == field))
            if row is None:
                raise
    row.value = value
    if collected_value is not None:
        row.collected_value = collected_value
    row.set_by, row.reason, row.set_at = set_by, reason, datetime.now(UTC)
    db.flush()
    return row


def clear(db: Session, entity_type: str, key: str, field: str) -> bool:
    """Withdraw a decision; the rules take over again on the next rekey."""
    row = db.scalar(select(Override).where(
        Override.entity_type == entity_type, Override.entity_key == str(key), Override.field == field))
    if row is None:
        return False
    db.delete(row)
    db.flush()
    return True


def move(db: Session, entity_type: str, from_key: str, to_key: str) -> dict[str, list[str]]:
    """A merge: the loser's decisions go to the survivor where it has none for that field;
    the rest are recorded as kept on the loser (`detail`) and left there."""
    ours = fields_of(db, entity_type, str(to_key))
    theirs = fields_of(db, entity_type, str(from_key))
    moved: list[str] = []
    kept: list[str] = []
    for field, row in theirs.items():
        if field in ours:
            kept.append(field)
            continue
        row.entity_key = str(to_key)
        moved.append(field)
    if moved:
        db.flush()
    return {"moved": sorted(moved), "kept": sorted(kept)}


# --------------------------------------------------------------------------- the decisions a route applies

class Refused(Exception):
    """A decision the service will not record, with the code and the sentence the page shows."""

    def __init__(self, code: str, summary: str) -> None:
        super().__init__(summary)
        self.code, self.summary = code, summary


def _live_product(db: Session, product_id: int):
    from app.models import Product

    product = db.get(Product, product_id)
    if product is None:
        raise Refused("PRODUCT_NOT_FOUND", "No such product.")
    if product.merged_into_id is not None:
        raise Refused("PRODUCT_MERGED", "This product was merged away; decide on its survivor.")
    return product


def decide_product(db: Session, product_id: int, field: str, value: Any, *, set_by: int | None,
                   reason: str | None = None) -> dict[str, Any]:
    """Record a product decision and let the key follow: the override row, the product's own
    column for the page, then a rekey through the same call ingest uses and a fold of what
    then agrees (`merges.rekey_products`, `merges._fold`). A `name` keeps the collected
    spelling for the key (`keyed_name`); a `line_id` must be a line of the product's house; a
    `variation` may be "" (none); a `quantity` is `{value, unit}` in a canonical unit with an
    optional form. Commits."""
    from app.models import ProductLine
    from app.services import keying, merges
    from app.services import quantity as quantity_service

    product = _live_product(db, product_id)
    if field not in FIELDS["product"]:
        raise Refused("FIELD_UNKNOWN", f"A product has no decidable field {field!r}.")
    collected: Any = None
    if field == "name":
        if not isinstance(value, str) or not value.strip():
            raise Refused("VALUE_INVALID", "A name is a non-empty string.")
        value = value.strip()[:400]
        existing = fields_of(db, "product", str(product.id)).get("name")
        collected = existing.collected_value if existing is not None and existing.collected_value else product.name
    elif field == "line_id":
        try:
            value = int(value)
        except (TypeError, ValueError) as exc:
            raise Refused("VALUE_INVALID", "A line is chosen by its id.") from exc
        line = db.get(ProductLine, value)
        if line is None:
            raise Refused("LINE_NOT_FOUND", "No such line.")
        if product.brand_id is not None and line.brand_id != product.brand_id:
            from app.models import Brand
            from app.services.lines import resolve_alias

            brands = {b.id: b for b in db.scalars(select(Brand))}
            house = resolve_alias(brands, product.brand_id)
            if house is None or line.brand_id != house.id:
                raise Refused("LINE_HOUSES_DIFFER", "That line belongs to another house; confirm the brand alias first.")
        collected = product.line_id
    elif field == "variation":
        if value is None:
            value = ""
        if not isinstance(value, str):
            raise Refused("VALUE_INVALID", "A variation is a string; empty means none.")
        value = value.strip().lower()[:80]
        collected = (product.attributes or {}).get("variation", "")
    else:
        if not isinstance(value, dict):
            raise Refused("VALUE_INVALID", "A quantity is {value, unit} with an optional form.")
        unit = value.get("unit")
        raw_value = value.get("value")
        if unit not in quantity_service.UNITS and not (unit is None and value.get("set_contents")):
            raise Refused("VALUE_INVALID", "The unit is ml, g or pcs.")
        try:
            number = float(raw_value) if raw_value is not None else None
        except (TypeError, ValueError) as exc:
            raise Refused("VALUE_INVALID", "The value is a number.") from exc
        form = value.get("form") or "single"
        if form not in quantity_service.FORMS:
            raise Refused("VALUE_INVALID", "The form is single, pack, set or refill.")
        value = {"value": number, "unit": unit, "form": form,
                 "pack_count": value.get("pack_count"), "pack_unit_value": value.get("pack_unit_value"),
                 "set_contents": value.get("set_contents")}
        collected = {"value": float(product.quantity_value) if product.quantity_value is not None else None,
                     "unit": product.quantity_unit, "form": product.form,
                     "pack_count": product.pack_count, "set_contents": product.set_contents}
    row = decide(db, "product", str(product.id), field, value, set_by=set_by, reason=reason, collected_value=collected)
    # The product's own column follows, for the page; the key follows through the rekey.
    if field == "name":
        product.name = value
    elif field == "line_id":
        product.line_id = value
    elif field == "variation":
        attributes = {k: v for k, v in (product.attributes or {}).items() if k != "variation"}
        if value:
            attributes["variation"] = value
        product.attributes = attributes
    else:
        q = keying.product_quantity(product, keying.Maps(decided={product.id: {"quantity": row}}))
        product.quantity_value, product.quantity_unit = q.value, q.unit
        product.pack_count, product.pack_unit_value = q.pack_count, q.pack_unit_value
        product.form, product.set_contents, product.quantity_state = q.form, q.set_contents, q.state
        product.size_ml = q.ml
    db.flush()
    keying.invalidate()
    maps = keying.load_maps(db)
    touched = merges.rekey_products(db, [product], maps)
    folded = merges._fold(db, touched, set_by)
    db.commit()
    keying.invalidate()
    return {"product_id": product.id, "field": field, "value": value, "match_key": product.match_key,
            "groups_merged": folded["groups"], "rows_merged": folded["merged_rows"]}


def _listing(db: Session, listing_id: int):
    from app.models import Listing

    listing = db.get(Listing, listing_id)
    if listing is None:
        raise Refused("LISTING_NOT_FOUND", "No such listing.")
    return listing


def pin_listing(db: Session, listing_id: int, product_id: int, *, set_by: int | None, reason: str | None = None) -> dict:
    """Move a listing to a product and keep it there: ingest honours the pin every sighting
    (`_resolve_listing`). The pin names the product; its survivor is what the listing points
    at. Commits."""
    from app.services.ingest import survivor_of

    listing = _listing(db, listing_id)
    target = survivor_of(db, _live_product(db, product_id))
    listing.pinned_product_id, listing.pinned_by, listing.pinned_at = product_id, set_by, datetime.now(UTC)
    listing.product_id = target.id
    db.commit()
    return {"listing_id": listing.id, "product_id": target.id, "pinned_product_id": product_id, "reason": reason}


def unpin_listing(db: Session, listing_id: int, *, set_by: int | None) -> dict:
    """Withdraw the pin; the listing stays where it is until the next sighting re-resolves it."""
    listing = _listing(db, listing_id)
    listing.pinned_product_id, listing.pinned_by, listing.pinned_at = None, None, None
    db.commit()
    return {"listing_id": listing.id, "product_id": listing.product_id, "pinned_product_id": None}


def ignore_listing(db: Session, listing_id: int, *, set_by: int | None, reason: str | None = None) -> dict:
    """Keep collecting a listing's prices but keep it out of the site's readers
    (`catalog_queries.live_listings()`) and the collectors page. Tonight that reaches
    `catalog_queries.py` and `/collectors` only: the trip comparison (`services/trip.py`),
    the home counts and the multi-airport filter (`routers/catalog.py`), IndexNow, coverage
    and the audit query `Listing` directly and still see it (a filed `do`). Commits."""
    listing = _listing(db, listing_id)
    listing.ignored_at, listing.ignored_by = datetime.now(UTC), set_by
    listing.ignore_reason = (reason or "").strip()[:2000] or None
    db.commit()
    return {"listing_id": listing.id, "ignored": True, "reason": listing.ignore_reason}


def unignore_listing(db: Session, listing_id: int, *, set_by: int | None) -> dict:
    listing = _listing(db, listing_id)
    listing.ignored_at, listing.ignored_by, listing.ignore_reason = None, None, None
    db.commit()
    return {"listing_id": listing.id, "ignored": False}
