"""Undo and batch undo (spec §7): a reversing row, never a delete; consequences first; a
merge reversed from its own record; a batch undone in one transaction with its tail folds
first and its rows in descending id order, a row superseded by a later batch skipped and named.
`verify` is the read-only drift report every after-deploy chain runs.

Sources of truth: this module, `writer.py`, `appliers.py`, `merges.py` (the record),
`tests/test_undo.py`, the brief's signature block. What it cost before: a confirm had no undo
at all (the 15 Sep decisions §4 refused any 200-row batch until one existed).
"""

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 Award, Decision, DecisionBatch, Listing, Merge, ProductVariant
from app.services.decisions.effective import effective
from app.services.decisions.writer import MODELS, Batch, Refused, batch as open_batch, record, refuse_if_collecting


def unmerge(b: Batch, row: Decision, loser: ProductVariant) -> Merge:
    """Reverse one merge from its `merges` record: the listed listing ids back to the loser, the
    moved awards back, the dropped awards recreated, a gained barcode returned, `merged_into_id`
    cleared, the record stamped `reversed_by_id`. Refuses `UNMERGE_CHAIN` when the survivor has
    since merged again outside this batch."""
    db = b.db
    rec = db.scalar(select(Merge).where(Merge.from_id == loser.id, Merge.reversed_by_id.is_(None)).order_by(Merge.id.desc()))
    if rec is None:
        raise Refused("REVERSAL_TARGET_MISSING", f"No unreversed merge record for variant {loser.id}.")
    survivor = db.get(ProductVariant, rec.to_id)
    if survivor is None:
        raise Refused("REVERSAL_TARGET_MISSING", f"The survivor {rec.to_id} no longer exists.")
    later = db.scalar(select(Merge).where(Merge.from_id == survivor.id, Merge.reversed_by_id.is_(None)))
    if later is not None and later.batch_id != b.row.id:
        raise Refused("UNMERGE_CHAIN", f"The survivor {survivor.id} has since merged into {later.to_id} (merge {later.id}); reverse that first.")
    detail = rec.detail or {}
    for listing_id in detail.get("listings", []):
        listing = db.get(Listing, listing_id)
        if listing is not None and listing.variant_id == survivor.id:
            listing.variant_id = loser.id
    for award_id in detail.get("awards_moved", []):
        award = db.get(Award, award_id)
        if award is not None:
            award.variant_id = loser.id
    for dropped in detail.get("awards_dropped", []):
        db.add(Award(variant_id=loser.id, competition=dropped["competition"], year=dropped["year"], medal=dropped.get("medal")))
    barcode = detail.get("barcode") or {}
    if barcode.get("gained") and survivor.gtin == barcode["gained"] and survivor.gtin_source == "merge":
        survivor.gtin, survivor.gtin_source = None, None
        db.flush()
        loser.gtin = barcode["gained"]
    loser.merged_into_id = None
    rec.reversed_by_id, rec.reversed_at = row.id, datetime.now(UTC)
    db.flush()
    b.touch(loser.id)
    b.touch(survivor.id)
    return rec


def _effective_row(db: Session, x: Decision) -> Decision | None:
    return effective(db, x.entity_type, [x.entity_id], [x.field]).get((x.entity_id, x.field))


def undo(b: Batch, decision_id: int, reason: str | None, *, skipped: list[dict] | None = None) -> Decision:
    """Reverse one decision (spec §7). The target must be in force on its field: as the effective
    row, or through a restoring undo row (`restores_id` names it); otherwise `DECISION_SUPERSEDED`.
    Refuses `ENTITY_MERGED` when its variant has since merged away. Its consequences go first
    (newest first, recursively; one no longer effective is skipped and reported). The reversing
    row restores what was in force before the target (`effect = set`, `restores_id`), releases to
    the rules when nothing was, and reinstates the reversed act when the target is itself an undo."""
    db = b.db
    x = db.get(Decision, decision_id)
    if x is None:
        raise Refused("REVERSAL_TARGET_MISSING", f"No decision {decision_id}.")
    now = _effective_row(db, x)
    if now is None:
        raise Refused("DECISION_SUPERSEDED", f"Decision {x.uid} is not in force on {x.field}: the field is released.")
    if now.id == x.id:
        reversed_row = x
    elif now.restores_id == x.id:
        reversed_row = now  # x's value is in force through a restore: reversing that restore undoes x
    else:
        raise Refused("DECISION_SUPERSEDED", f"Decision {x.uid} is not in force on {x.field}; the newer row is {now.uid}.")
    model = MODELS[x.entity_type]
    entity = db.get(model, x.entity_id)
    if entity is None:
        raise Refused("ENTITY_NOT_FOUND", f"The {x.entity_type} {x.entity_id} no longer exists.")
    if x.entity_type == "product_variant" and entity.merged_into_id is not None and x.field != "merged_into":
        carried = db.scalar(select(Decision).where(Decision.detail["carried_from"].as_string() == x.natural_key, Decision.field == x.field))
        raise Refused("ENTITY_MERGED", f"Variant {x.entity_id} merged away; undo the carried row {carried.uid if carried else ''} on its survivor.")
    # Consequences first, newest first, recursively.
    for consequence in db.scalars(select(Decision).where(Decision.caused_by_id == x.id).order_by(Decision.id.desc())):
        c_now = _effective_row(db, consequence)
        if c_now is None or c_now.id != consequence.id:
            (skipped if skipped is not None else []).append({"decision": str(consequence.uid), "why": "no longer effective"})
            continue
        undo(b, consequence.id, f"undo of {x.uid}", skipped=skipped)
    if x.reverses_id is not None:
        predecessor = db.get(Decision, x.reverses_id)  # undo of an undo: reinstate what it reversed
    else:
        predecessor = db.get(Decision, x.supersedes_id) if x.supersedes_id else None
    text = f"undo of {x.uid}" + (f": {reason}" if reason else "")
    if predecessor is not None and predecessor.effect == "set":
        value = predecessor.value_ref_id if predecessor.value_ref_id is not None else predecessor.value
        row = record(b, x.entity_type, entity, x.field, value, effect="set", reason=text, reverses=reversed_row, restores=predecessor,
                     detail={"undo": True})
    else:
        row = record(b, x.entity_type, entity, x.field, None, effect="release", reason=text, reverses=reversed_row, detail={"undo": True})
    db.flush()
    return row


def undo_batch(db: Session, batch_uid, by, reason: str | None, *, force: bool = False) -> DecisionBatch:
    """Reverse a whole batch in one transaction (spec §7): a new batch of kind `undo`; first the
    batch's own tail folds (from their records, newest first); then its `set` rows in descending
    id order, each still effective, a row superseded by a later batch skipped and named; one rekey
    and fold at the tail; one commit. An exception rolls the whole undo back."""
    import uuid as _uuid

    refuse_if_collecting(db, force)
    target = db.scalar(select(DecisionBatch).where(DecisionBatch.uid == _uuid.UUID(str(batch_uid))))
    if target is None:
        raise Refused("REVERSAL_TARGET_MISSING", f"No batch {batch_uid}.")
    if db.scalar(select(DecisionBatch.id).where(DecisionBatch.reverses_batch_id == target.id)) is not None:
        raise Refused("DECISION_SUPERSEDED", f"Batch {batch_uid} was already undone.")
    with open_batch(db, "undo", target.mode, by, note=reason, scope={"reverses": str(target.uid)}, commit=True,
                    reverses_batch_id=target.id) as b:
        skipped = b.skipped
        for rec in db.scalars(select(Merge).where(Merge.batch_id == target.id, Merge.decision_id.is_(None), Merge.reversed_by_id.is_(None))
                              .order_by(Merge.id.desc())):
            loser = db.get(ProductVariant, rec.from_id)
            record(b, "product_variant", loser, "merged_into", None, effect="release", reason=f"undo of batch {target.uid}: the tail fold",
                   detail={"undo": True, "fold": rec.id})
        rows = list(db.scalars(select(Decision).where(Decision.batch_id == target.id, Decision.effect == "set", Decision.caused_by_id.is_(None))
                               .order_by(Decision.id.desc())))
        for x in rows:
            now = _effective_row(db, x)
            if now is None or now.id != x.id:
                newer = db.get(DecisionBatch, now.batch_id) if now is not None else None
                skipped.append({"decision": str(x.uid), "field": x.field, "superseded_by_batch": str(newer.uid) if newer else None})
                continue
            undo(b, x.id, reason, skipped=skipped)
    return b.row


def verify(db: Session) -> dict[str, Any]:
    """Read-only drift: every effective `set` row against its column (alias-followed for a
    reference); the count and up to fifty examples. Nothing prevents a bypass; this detects."""
    from app.services import attributes
    from app.services.product_lines import resolve_alias

    drift: list[dict] = []
    checked = 0
    for entity_type, model in MODELS.items():
        rows = effective(db, entity_type)
        if not rows:
            continue
        for (entity_id, field), dec in rows.items():
            entity = db.get(model, entity_id)
            if entity is None:
                drift.append({"decision": str(dec.uid), "field": field, "why": "entity gone"})
                continue
            checked += 1
            actual = _column_value(db, entity_type, entity, field, attributes, resolve_alias, dec)
            expected = dec.value_ref_id if field in ("product_line", "alias_of", "merged_into", "pinned_to") else dec.value
            if field == "split":
                expected = (dec.value or {}).get("brand_slug")
            if field == "decision":
                expected = dec.value.get("decision") if isinstance(dec.value, dict) else dec.value
            if field == "alias_of" and actual is not None and expected is not None and actual != expected:
                # alias-followed: the decided target may itself have become an alias since
                rows_by_id = {r.id: r for r in db.scalars(select(model))}
                actual, expected = (resolve_alias(rows_by_id, actual) or entity).id, (resolve_alias(rows_by_id, expected) or entity).id
            if _differs(actual, expected):
                drift.append({"decision": str(dec.uid), "entity": f"{entity_type}:{entity_id}", "field": field, "column": actual, "decided": expected})
    return {"checked": checked, "drift": len(drift), "examples": drift[:50]}


def _column_value(db, entity_type, entity, field, attributes, resolve_alias, dec=None):
    if field == "name":
        return entity.name
    if field == "product_line":
        return entity.product_line_id
    if field == "alias_of":
        return entity.alias_of_id
    if field == "split":
        return _split_state(db, dec)
    if field == "merged_into":
        return entity.merged_into_id
    if field in ("hidden", "indexed"):
        return getattr(entity, field)
    if field == "pinned_to":
        return entity.pinned_variant_id
    if field == "ignored":
        return entity.ignored_at is not None
    if field == "decision":
        return entity.decision
    if field == "meaning":
        return entity.canonical
    if field in ("display", "kind"):
        return getattr(entity, field)
    if field.startswith("attribute:"):
        value = attributes.get(entity, field[len("attribute:"):])
        if field == "attribute:quantity" and value is not None:
            return {"value": value.value, "unit": value.unit, "form": value.form, "pack_count": value.pack_count,
                    "pack_unit_value": value.pack_unit_value, "set_contents": value.set_contents}
        return value
    return None


def _split_state(db: Session, dec: Decision | None) -> str | None:
    """A split has no column of its own; what it asserts is that the variants it moved sit on the
    brand row it named. So the "column" is the slug of the row they are on, when they agree on
    one; `mixed` when a later act moved some of them elsewhere, which is the drift worth seeing."""
    from app.models import Brand

    moved = [m[0] for m in ((dec.detail or {}).get("moved") or [])] if dec is not None else []
    if not moved:
        return (dec.value or {}).get("brand_slug") if dec is not None else None
    slugs = {row for row, in db.execute(select(Brand.slug).join(ProductVariant, ProductVariant.brand_id == Brand.id)
                                        .where(ProductVariant.id.in_(moved)).distinct())}
    if not slugs:
        return (dec.value or {}).get("brand_slug") if dec is not None else None
    return slugs.pop() if len(slugs) == 1 else "mixed"


def _differs(actual, expected) -> bool:
    if isinstance(expected, dict) and isinstance(actual, dict):
        return any(_differs(actual.get(k), v) for k, v in expected.items() if v is not None)
    if isinstance(expected, (int, float)) and isinstance(actual, (int, float)):
        return float(actual) != float(expected)
    if isinstance(expected, str) and isinstance(actual, str):
        return actual.strip().lower() != expected.strip().lower()
    return actual != expected
