"""The precedent register: every kind of judgement a person has answered, and what was answered.

Sources of truth: this module, `models/decisions.py` (`Precedent`), `attention.py` (the floor that
reads it), `proposals.py` (the loader that computes a row's shape and the approval that updates the
register), `docs/REVIEW-PROCESS.md` section 4, `tests/test_precedents.py`.

Why (the plan of 18 Sep, B2): rian approves in bulk, so a pass that under-rates a new kind of
question as routine puts every row of that kind through silently, for ever. Nothing the pass says
about its own novelty can be the check. So every proposal names the KIND of judgement it is (a
slug), and this register, which a pass reads and cannot edit, decides three things mechanically:

* a slug not registered is a new kind, and the row is `critical` by construction;
* a row filed under a registered slug is compared with the SHAPES decided under it (which hint
  lists fired on the cited text, which listed words those lists found, the entity type, the field,
  the vertical); anything never seen under the slug marks the row `unlike` its precedent, which
  floors it at `high` and names the element. A "for men" line filed under a plain membership slug
  trips the audience list, which no plain membership decision has carried: the check knows nothing
  about gender, only that the register has never seen that shape;
* the counts (`streak`, reset by an overturn or a "something else" on a lead) are what let a kind
  decay from `high` to bulk, counted from the ledger and never from a model's confidence.

The register is generated from decisions and notes, never edited by hand (B5). A precedent's note
is the person's own words, required on the decision that set it, so the record is complete by
construction. Nothing here decides a catalogue question: it records that one was answered.
"""

from __future__ import annotations

from datetime import UTC, datetime
from typing import Any

from sqlalchemy import select
from sqlalchemy.orm import Session

from app.models import Decision, Precedent, Proposal
from app.services import proposal_rules

#: The rules whose firing on a row's cited text is part of its shape (every grouping list).
SHAPE_RULES = frozenset(proposal_rules.GROUPING_RULES)


def shape_of(entity_type: str, field: str, vertical: str | None, texts: list[str], *, category: str | None = None) -> dict[str, Any]:
    """The shape of one proposal: a pure function of what it is about and the listed text it cites.

    `hints` are the grouping lists that fired on any cited text and `words` the listed words they
    took: a closed vocabulary (drink, format, audience, pack, age, region words), so a difference
    between two shapes is meaningful, where a difference in free text never would be. A shade or a
    cask reading counts as a hint too."""
    hints: set[str] = set()
    words: set[str] = set()
    for text in texts:
        if not text:
            continue
        reading = proposal_rules.read_line(text, vertical=vertical, category=category, rules=SHAPE_RULES)
        for rule, taken in reading.removed.items():
            hints.add(rule)
            words.update(f"{rule}:{w}" for w in taken)
        # The audience vocabulary is part of the shape in every vertical, folded or not: the
        # reader records "homme" (it folds to men) but not "men" (it folds to itself), and the
        # shape must see both, or the one word that most often marks an unasked kind is invisible.
        for token in proposal_rules.product_lines._fold(text).split():
            if token in proposal_rules.AUDIENCE:
                hints.add("audience")
                words.add(f"audience:{token}")
        if proposal_rules.read_shade(text, vertical, category):
            hints.add("shade_shapes")
        if proposal_rules.read_cask(text, vertical):
            hints.add("cask_words")
    return {"entity_types": [entity_type], "fields": [field], "verticals": [vertical] if vertical else [],
            "hints": sorted(hints), "words": sorted(words)}


def union(a: dict | None, b: dict | None) -> dict[str, Any]:
    out: dict[str, list] = {}
    for key in ("entity_types", "fields", "verticals", "hints", "words"):
        out[key] = sorted(set((a or {}).get(key) or []) | set((b or {}).get(key) or []))
    return out


def unlike(register_shape: dict | None, row_shape: dict | None) -> dict[str, list] | None:
    """What in a row's shape the register has never seen under its slug, or None when nothing.
    Words are the loudest part and the most specific: a hint list that fired on this row and on
    no decided case, or a listed word on this row no decided case carried."""
    if not row_shape:
        return None
    seen = register_shape or {}
    out = {}
    for key in ("entity_types", "fields", "verticals", "hints", "words"):
        missing = sorted(set(row_shape.get(key) or []) - set(seen.get(key) or []))
        if missing:
            out[key] = missing
    return out or None


def register(db: Session) -> dict[str, Precedent]:
    """Every precedent by slug, active and overturned; a pass reads both."""
    return {p.slug: p for p in db.scalars(select(Precedent).order_by(Precedent.slug))}


def _answer_of(row: Proposal, outcome: str) -> str:
    value = row.corrected_value if row.corrected_value is not None else row.value
    if isinstance(value, dict) and "decision" in value:
        text = "Confirm same" if value.get("decision") == "same" else "Keep separate"
    else:
        text = str(value)[:200]
    return f"{outcome}: {text}"


def on_decision(db: Session, row: Proposal, decision: Decision | None, *, outcome: str, note: str | None, by: str | None) -> Precedent | None:
    """The approval calls this for every row answered (approved, corrected, or a pair kept
    separate). A row under an unregistered slug SETS the precedent: the question is its reason,
    the answer what was done, the note the person's words. A row under a registered slug adds a
    decision and grows the slug's shape. Returns the register row, or None for a row with no slug."""
    slug = (row.precedent or "").strip()
    if not slug:
        return None
    now = datetime.now(UTC)
    existing = db.scalar(select(Precedent).where(Precedent.slug == slug))
    if existing is None:
        verticals = (row.shape or {}).get("verticals") or []
        existing = Precedent(slug=slug, vertical=verticals[0] if verticals else None,
                             question=row.reason, answer=_answer_of(row, outcome), note=(note or "").strip() or None,
                             first_case=row.natural_key, set_by=by, set_at=now, decision_id=getattr(decision, "id", None),
                             shape=union(None, row.shape), decisions_count=1, streak=1)
        db.add(existing)
        db.flush()
        return existing
    existing.shape = union(existing.shape, row.shape)
    existing.decisions_count = (existing.decisions_count or 0) + 1
    existing.streak = (existing.streak or 0) + 1
    if existing.note is None and (note or "").strip():
        existing.note = note.strip()
    existing.updated_at = now
    return existing


def on_reset(db: Session, slug: str | None) -> Precedent | None:
    """"Something else" on a lead under a registered slug: the person disagrees with how the kind
    is being answered, so its streak returns to zero and the floor holds it at `high` again."""
    if not slug:
        return None
    row = db.scalar(select(Precedent).where(Precedent.slug == slug))
    if row is None:
        return None
    row.resets_count = (row.resets_count or 0) + 1
    row.streak = 0
    row.updated_at = datetime.now(UTC)
    return row


def overturn(db: Session, slug: str, *, note: str, by, force: bool = False) -> dict[str, Any]:
    """One act: a decision on the precedent row (field `status`, value `overturned`), undoable.
    It regroups nothing. The decisions made under the slug stay in force; the packet lists them so
    the next pass proposes against them, and rian approves those as one group (the plan, B6)."""
    from app.services.decisions import writer

    row = db.scalar(select(Precedent).where(Precedent.slug == slug))
    if row is None:
        raise writer.Refused("PRECEDENT_MISSING", f"No precedent {slug!r}.")
    if row.status == "overturned":
        raise writer.Refused("VALUE_INVALID", f"{slug} is already overturned.")
    if not (note or "").strip():
        raise writer.Refused("NOTE_REQUIRED", "An overturn says the new answer in one sentence: the note is what the next pass reads.")
    from app.services.proposals import refuse_if_collecting

    refuse_if_collecting(db, force)
    with writer.batch(db, "route", "individual", by, note=note.strip(), commit=True) as b:
        dec = writer.record(b, "precedent", row, "status", "overturned", origin="person", reason=note.strip(),
                            detail={"note": note.strip()})
    under = decisions_under(db, slug)
    return {"slug": slug, "decision_uid": str(dec.uid), "batch_uid": str(b.row.uid), "decisions_under": len(under)}


def decisions_under(db: Session, slug: str) -> list[Decision]:
    """Every decision recorded from a proposal that named this slug (the detail carries it)."""
    return list(db.scalars(select(Decision).where(Decision.detail["precedent"].as_string() == slug).order_by(Decision.id)))


def describe(db: Session) -> list[dict[str, Any]]:
    out = []
    for p in db.scalars(select(Precedent).order_by(Precedent.status, Precedent.slug)):
        out.append({"slug": p.slug, "vertical": p.vertical, "question": p.question, "answer": p.answer, "note": p.note,
                    "first_case": p.first_case, "set_by": p.set_by, "set_at": p.set_at.isoformat() if p.set_at else None,
                    "status": p.status, "overturned_by": p.overturned_by,
                    "overturned_at": p.overturned_at.isoformat() if p.overturned_at else None, "overturn_note": p.overturn_note,
                    "shape": p.shape or {}, "decisions_count": int(p.decisions_count or 0), "streak": int(p.streak or 0),
                    "resets_count": int(p.resets_count or 0)})
    return out


def export_markdown(db: Session) -> str:
    """`docs/PRECEDENTS.md`: generated, never edited by hand; one `## slug` per precedent."""
    lines = ["# Precedents", "",
             "<!-- GENERATED by `app.cli precedents export` from the register; never edited by hand. One",
             "     `## slug` per kind of judgement a person has answered: the question as first asked, the",
             "     answer, the person's note verbatim, the first case, and the counts the attention floor",
             "     reads. An overturned precedent stays, marked, so the record never loses an answer. -->", ""]
    rows = describe(db)
    if not rows:
        lines.append("No precedent has been set yet: every kind of question is new, and critical.")
    for r in rows:
        lines += [f"## {r['slug']}", "",
                  f"- **Status:** {r['status']}" + (f" (overturned by {r['overturned_by']}: {r['overturn_note']})" if r["status"] == "overturned" else ""),
                  f"- **Vertical:** {r['vertical'] or 'any'}",
                  f"- **Question:** {r['question'] or ''}",
                  f"- **Answer:** {r['answer'] or ''}",
                  f"- **Note:** {r['note'] or ''}",
                  f"- **First case:** `{r['first_case'] or ''}` by {r['set_by'] or ''} on {(r['set_at'] or '')[:10]}",
                  f"- **Decisions under it:** {r['decisions_count']} (streak {r['streak']}, resets {r['resets_count']})",
                  f"- **Shape seen:** hints {', '.join(r['shape'].get('hints') or []) or 'none'}; words "
                  f"{', '.join(r['shape'].get('words') or []) or 'none'}", ""]
    return "\n".join(lines).rstrip() + "\n"
