"""The `featured` command group (Stream AW2): the admin's pin, and the evidence a rail is measured by.

    python -m app.cli featured pin   line:<slug or id> variant:<id> ... --by <user> [--reason ...]
    python -m app.cli featured unpin line:<slug or id> variant:<id> ... --by <user> [--reason ...]
    python -m app.cli featured list
    python -m app.cli featured evidence [--pages home /products /alcohol /alcohol/whisky /brands/<slug> /airports/<iata> ...]
                                       [--first 8] [--verbose] [--json]

`pin` and `unpin` write one ledger batch through `services/featured_pins.set_featured` (one
`decisions` row per target on its `featured` field, the column following through the
applier), commit once, and print the batch uid with its `decisions undo-batch` line, one line
per refusal, and each target's comparison state (`comparison: 10 airports, $14.20 / 18%`, or
`comparison: none: shows only in the full list`, because a pin orders and never admits, D6).
An unknown target shape exits 1 before any database call; every target refused exits 2.
`list` prints every pin, newest first, with `inert` (it leads nothing: a hidden line, a merged
variant) and `drift` (the column and the ledger disagree) beside the ones that need a look.

`evidence` is read-only: one session, one snapshot per statement, never a batch, nothing
written. For each page it reads the records the page's pool holds (`catalog_queries.
featured_records`, the same columns before and after v3) and the order the page shows
(`featured.order` over those records, the very function every featured sort runs; the
per-family picker for an airport), then prints `featured.evidence` over the first N: pool,
pictures by tier beside the pool's picture rate, awards, pins, which axis each saving leads on,
medians against the pool's p75, brands, categories, exclusives, held-back two-shop spreads, the
gates, the hygiene counts, and the milliseconds the query and the ordering took. The default
pages are `home` and the three largest eligible pools among the categories `category_counts`
lists, never hard-coded, so the report follows the catalogue. `--verbose` adds one line per
slot; `--json` prints the same figures as one object per page for a later side-by-side.

The grammar is checked before any database call: an unknown page shape or a `--first` under 1
exits 1 with the reason, so a typo never opens a session.
"""

from __future__ import annotations

import argparse
import dataclasses
import json
import time
from typing import Callable

from sqlalchemy.orm import Session

from app.services import airport_featured, catalog_queries, featured, featured_pins
from app.services.urls import category_from_slug, category_page_path, family_from_slug, family_slug

#: A page spec: `home`, `/products`, `/<family>`, `/<family>/<category>`, `/brands/<slug>`,
#: `/airports/<iata or airport slug>`. `parse_page` turns a spec into `(kind, argument)`.
PAGE_KINDS = ("home", "products", "family", "category", "brand", "airport")
#: How many of the largest category pools join `home` when no page is named.
DEFAULT_POOLS = 3


class PageError(ValueError):
    """A page spec the grammar does not admit; raised before any database call."""


def parse_page(spec: str) -> tuple[str, str | None]:
    """`(kind, argument)` for a page spec, or `PageError`. Pure."""
    s = spec.strip()
    if s == "home":
        return "home", None
    if s == "/products":
        return "products", None
    parts = [p for p in s.split("/") if p]
    if s.startswith("/") and len(parts) == 1 and family_from_slug(parts[0]):
        return "family", family_from_slug(parts[0])
    if s.startswith("/") and len(parts) == 2:
        if parts[0] == "brands":
            return "brand", parts[1]
        if parts[0] == "airports":
            return "airport", parts[1]
        family = family_from_slug(parts[0])
        category = category_from_slug(parts[1])
        if family and category:
            return "category", category
    raise PageError(
        f"unknown page {spec!r}: expected home, /products, /<family>, /<family>/<category>, "
        "/brands/<slug> or /airports/<iata>"
    )


def _timed(fn: Callable[[], object]) -> tuple[object, float]:
    started = time.perf_counter()
    out = fn()
    return out, (time.perf_counter() - started) * 1000.0


def _airport_iata(db: Session, argument: str) -> str | None:
    """`lhr` or `heathrow-lhr-london`: the one part that names a visible airport."""
    for part in argument.split("-"):
        if len(part) == 3 and catalog_queries.airport_shops(db, part):
            return part.upper()
    return None


Loaded = tuple[list[featured.SavingRecord], list[int], float, float, int]


def load_page(db: Session, spec: str, first: int) -> dict[str, Loaded]:
    """`{label: (records, order, query_ms, order_ms, first)}` for one page spec: one entry, or
    one per family for an airport (measured at the row's own length, `PER_FAMILY`, when that is
    shorter), or none when the page does not exist (a brand or airport we do not hold). The
    order is the page's own today, so the figures describe it as it is."""
    kind, argument = parse_page(spec)
    if kind == "home":
        records, q_ms = _timed(lambda: catalog_queries.featured_records(db, multi_only=True))
        ids, o_ms = _timed(lambda: featured.pick_featured(records, total=max(first, featured.FIRST)))
        return {"home": (records, ids, q_ms, o_ms, first)}
    if kind == "products":
        records, q_ms = _timed(lambda: catalog_queries.featured_records(db, multi_only=False))
        ids, o_ms = _timed(lambda: featured.order(records, featured.FIRST))
        return {"/products": (records, ids, q_ms, o_ms, first)}
    if kind == "family":
        records, q_ms = _timed(lambda: catalog_queries.featured_records(db, family=argument, multi_only=True))
        ids, o_ms = _timed(lambda: featured.order(records, featured.FIRST))
        return {f"/{family_slug(argument)}": (records, ids, q_ms, o_ms, first)}
    if kind == "category":
        records, q_ms = _timed(lambda: catalog_queries.featured_records(db, category=argument, multi_only=True))
        ids, o_ms = _timed(lambda: featured.order(records, featured.FIRST))
        return {category_page_path(_family_of(argument), argument): (records, ids, q_ms, o_ms, first)}
    if kind == "brand":
        brand = catalog_queries.brand_by_slug(db, argument)
        if brand is None:
            return {}
        ids = catalog_queries._brand_ids(db, brand)
        records, q_ms = _timed(lambda: catalog_queries.featured_records(db, brand_ids=ids, multi_only=False))
        order, o_ms = _timed(lambda: featured.order(records, featured.FIRST))
        return {f"/brands/{argument}": (records, order, q_ms, o_ms, first)}
    iata = _airport_iata(db, argument)
    if iata is None:
        return {}
    shop_ids = [loc.id for loc in catalog_queries.airport_shops(db, iata)]
    rows, q_ms = _timed(lambda: catalog_queries._airport_rows(db, shop_ids))
    candidates = airport_featured.candidates(rows)
    base, q2_ms = _timed(
        lambda: catalog_queries.featured_records(db, only_ids=[c.variant_id for c in candidates] or [0], multi_only=True)
    )
    by_id = {r.variant_id: r for r in base}
    row_length = min(first, airport_featured.PER_FAMILY)
    tiers = airport_featured.tiers_of(db, rows)
    picked, o_ms = _timed(
        lambda: airport_featured.pick_by_family(
            rows, airport_featured.awarded_among(db, [c.variant_id for c in candidates]), per_family=row_length, tiers=tiers,
        )
    )
    out = {}
    for key, _label, ids in picked:
        family_records = []
        for c in candidates:
            if airport_featured.family_of(c.category) != key or c.variant_id not in by_id:
                continue
            # This airport's saving is against the dearest of our airports, not the cheapest's.
            family_records.append(dataclasses.replace(
                by_id[c.variant_id], saving_usd=c.saving_usd, saving_pct=c.saving_pct, shop_count=c.shop_count,
            ))
        out[f"/airports/{iata.lower()}#{key}"] = (family_records, ids, q_ms + q2_ms, o_ms, row_length)
    return out


def _family_of(category: str) -> str:
    from app.services import taxonomy

    return taxonomy.vertical_of(category) or ""


def default_pages(db: Session, home_records: list[featured.SavingRecord] | None = None) -> list[str]:
    """`home` plus the three largest eligible pools among the categories `category_counts`
    lists that have a page: what the report measures when nothing is named."""
    counts = catalog_queries.category_counts(db)
    records = home_records if home_records is not None else catalog_queries.featured_records(db, multi_only=True)
    eligible_by_category: dict[str, int] = {}
    for r in records:
        if r.category and featured.eligible(r):
            eligible_by_category[r.category] = eligible_by_category.get(r.category, 0) + 1
    pools = []
    for c in counts:
        family = c.family or _family_of(c.category)
        if not family or family_slug(family) is None or not category_from_slug(_slug_of(c.category)):
            continue
        pools.append((eligible_by_category.get(c.category, 0), c.category, family))
    pools.sort(key=lambda t: (-t[0], t[1]))
    return ["home"] + [category_page_path(family, category) for _n, category, family in pools[:DEFAULT_POOLS]]


def _slug_of(category: str) -> str | None:
    from app.services.urls import category_slug

    return category_slug(category)


def gather(db: Session, pages: list[str] | None, first: int) -> dict[str, featured.Evidence]:
    """The evidence per page label, the pages in the order asked."""
    out: dict[str, featured.Evidence] = {}
    if not pages:
        loaded = load_page(db, "home", first)
        pages = [p for p in default_pages(db, loaded["home"][0]) if p != "home"]
        out.update(measure(loaded))
    for spec in pages:
        out.update(measure(load_page(db, spec, first)))
    return out


def measure(loaded: dict[str, Loaded]) -> dict[str, featured.Evidence]:
    """`featured.evidence` per loaded page, the timings stamped on."""
    out = {}
    for label, (records, order, q_ms, o_ms, first) in loaded.items():
        out[label] = dataclasses.replace(
            featured.evidence(records, order, first), query_ms=round(q_ms, 1), order_ms=round(o_ms, 1)
        )
    return out


# --- printing -----------------------------------------------------------------------------------

def _fmt(value) -> str:
    if value is None:
        return "-"
    if isinstance(value, bool):
        return "yes" if value else "no"
    if isinstance(value, float):
        return f"{value:.2f}"
    return str(value)


def rows_of(pages: dict[str, featured.Evidence]) -> list[tuple[str, list[str]]]:
    """The table: one row per figure, one column per page."""
    ev = list(pages.values())
    rows: list[tuple[str, list[str]]] = [
        ("first", [_fmt(e.first) for e in ev]),
        ("shown", [_fmt(e.shown) for e in ev]),
        ("records", [_fmt(e.records) for e in ev]),
        ("pool (eligible)", [_fmt(e.pool) for e in ev]),
        ("pool pictured / rate", [f"{e.pool_pictured} / {e.pool_picture_rate:.0%}" for e in ev]),
        ("pool bottles", [_fmt(e.pool_bottles) for e in ev]),
        ("pool awarded / better half", [f"{e.pool_awarded} / {_fmt(e.pool_better_half_winner)}" for e in ev]),
        ("pool brands", [_fmt(e.pool_brands) for e in ev]),
        ("pool p75 $ / %", [f"{_fmt(e.pool_p75_usd)} / {_pct(e.pool_p75_pct)}" for e in ev]),
        ("pictured", [_fmt(e.pictured) for e in ev]),
        ("bottles", [_fmt(e.bottles) for e in ev]),
        ("tiers 0..6", [" ".join(str(t) for t in e.tiers) for e in ev]),
        ("awarded", [_fmt(e.awarded) for e in ev]),
        ("pinned", [_fmt(e.pinned) for e in ev]),
        ("%led / $led / both", [f"{e.pct_led} / {e.usd_led} / {e.both_led}" for e in ev]),
        ("median $ / %", [f"{_fmt(e.median_usd)} / {_pct(e.median_pct)}" for e in ev]),
        ("brands", [_fmt(e.brands) for e in ev]),
        ("categories / max per", [f"{e.categories} / {e.max_per_category}" for e in ev]),
        ("exclusives", [_fmt(e.exclusives) for e in ev]),
        ("champions in first 4", [f"{e.champions_in_first_mix} of {len(e.champions)}" for e in ev]),
        ("held back (2-shop)", [_fmt(e.held_back) for e in ev]),
        ("gates", [_counts(e.gates) for e in ev]),
        ("ineligible (all records)", [_counts(e.ineligible) for e in ev]),
        ("hygiene", [_counts(e.hygiene) for e in ev]),
        ("targets met", [f"{e.targets_met} of {len(e.targets)}" for e in ev]),
        ("query ms / order ms", [f"{_fmt(e.query_ms)} / {_fmt(e.order_ms)}" for e in ev]),
    ]
    return rows


def _pct(value: float | None) -> str:
    return "-" if value is None else f"{value:.0%}"


def _counts(counts: dict[str, int]) -> str:
    named = [f"{k} {v}" for k, v in counts.items() if v]
    return ", ".join(named) if named else "0"


def render(pages: dict[str, featured.Evidence], verbose: bool = False) -> str:
    labels = list(pages)
    rows = rows_of(pages)
    width = max(len(name) for name, _ in rows)
    widths = [max(len(label), *(len(cells[i]) for _, cells in rows)) for i, label in enumerate(labels)]
    lines = [" " * width + "  " + "  ".join(label.ljust(w) for label, w in zip(labels, widths))]
    for name, cells in rows:
        lines.append(name.ljust(width) + "  " + "  ".join(cell.ljust(w) for cell, w in zip(cells, widths)))
    for label, e in pages.items():
        missed = [t for t in e.targets if not t.met]
        if missed:
            lines.append(f"{label}: missed " + "; ".join(f"{t.name} {_fmt(t.value)} (want {t.target})" for t in missed))
    if verbose:
        for label, e in pages.items():
            lines.append("")
            lines.append(f"{label}:")
            for s in e.slots:
                flags = " ".join(f for f, on in (("award", s.awarded), ("pin", s.pinned), ("excl", s.is_exclusive)) if on)
                gate = f" [{', '.join(s.gates)}]" if s.gates else ""
                lines.append(
                    f"  {s.position:>2}. #{s.variant_id} ${s.saving_usd:.2f} / {s.saving_pct:.0%} tier {s.tier}"
                    f" {s.lead or '-'} {flags} {s.category or '-'} {s.name[:60]}{gate}"
                )
    return "\n".join(lines)


def to_json(pages: dict[str, featured.Evidence]) -> str:
    return json.dumps({label: dataclasses.asdict(e) for label, e in pages.items()}, indent=1, default=str)


# --- the command --------------------------------------------------------------------------------

def cmd_featured_evidence(args: argparse.Namespace) -> int:
    if args.first < 1:
        print(f"featured evidence: --first must be at least 1, not {args.first}")
        return 1
    try:
        for spec in args.pages or []:
            parse_page(spec)
    except PageError as exc:
        print(f"featured evidence: {exc}")
        return 1
    from app.db import SessionLocal

    with SessionLocal() as db:
        pages = gather(db, args.pages, args.first)
    if not pages:
        print("featured evidence: no page answered (an unknown brand or airport?)")
        return 2
    print(to_json(pages) if args.json else render(pages, verbose=args.verbose))
    return 0


def _decide(args: argparse.Namespace, featured_: bool) -> int:
    verb = "pin" if featured_ else "unpin"
    try:
        targets = [featured_pins.parse_target(t) for t in args.targets]
    except ValueError as exc:
        print(f"featured {verb}: {exc}")
        return 1
    from app.db import SessionLocal

    with SessionLocal() as db:
        try:
            result = featured_pins.set_featured(db, targets, featured_, args.by, batch_kind="cli", reason=args.reason)
        except featured_pins.writer.Refused as exc:
            print(f"featured {verb}: {exc.code}: {exc.summary}")
            return 2
        db.commit()
        print(f"featured {verb}: {result['message']}"
              + (f"; undo with `decisions undo-batch {result['batch_uid']}`" if result["batch_uid"] else ""))
        for t in result["targets"]:
            row = featured_pins.find(db, t["kind"], t["id"])
            state = featured_pins.comparison_state(db, t["kind"], row) if row is not None else ""
            print(f"  {verb}ned {t['target']}: {t['path'] or '(no page)'} ({'changed' if t['changed'] else 'already so'}); {state}")
    for r in result["refusals"]:
        print(f"  refused {r['target']}: {r['error_code']}: {r['summary']}")
    return 2 if result["refusals"] and not result["counts"]["changed"] else 0


def cmd_featured_pin(args: argparse.Namespace) -> int:
    return _decide(args, True)


def cmd_featured_unpin(args: argparse.Namespace) -> int:
    return _decide(args, False)


def cmd_featured_list(args: argparse.Namespace) -> int:
    from app.db import SessionLocal

    with SessionLocal() as db:
        pins = featured_pins.current(db)
        if not pins:
            print("featured list: no pins")
            return 0
        for p in pins:
            flags = " ".join(f for f, on in (("inert", p["inert"]), ("drift", p["drift"])) if on)
            when = p["decided_at"].strftime("%Y-%m-%d %H:%M") if p["decided_at"] else "no decision"
            row = featured_pins.find(db, p["kind"], p["id"])
            state = featured_pins.comparison_state(db, p["kind"], row) if row is not None else ""
            print(f"{p['kind']}:{p['id']}  {p['name'][:50]:<50}  {p['path'] or '(no page)':<48}  {when}  {p['by'] or '-':<12}  {flags:<11} {state}")
    return 0


def register(sub: argparse._SubParsersAction) -> None:
    group = sub.add_parser("featured", help="featured selection v3: the admin's pin (pin, unpin, list) and the evidence a rail is measured by")
    fsub = group.add_subparsers(dest="featured_command", required=True)

    for verb, fn, help_ in (("pin", cmd_featured_pin, "pin product lines or product variants: they lead every featured list they are a comparison in"),
                            ("unpin", cmd_featured_unpin, "release pins (a ledger decision; undo with decisions undo-batch)")):
        cmd = fsub.add_parser(verb, help=help_)
        cmd.add_argument("targets", nargs="+", help="line:<slug or id> or variant:<id>; an old slug or a merged id forwards")
        cmd.add_argument("--by", required=True, help="the account username deciding")
        cmd.add_argument("--reason", default=None)
        cmd.set_defaults(func=fn)

    ls = fsub.add_parser("list", help="every pin, newest first, marked inert (leads nothing) or drift (column and ledger disagree)")
    ls.set_defaults(func=cmd_featured_list)

    evidence = fsub.add_parser("evidence", help="measure each page's first N against its pool; read-only, nothing written")
    evidence.add_argument("--pages", nargs="*", default=None,
                          help="home, /products, /<family>, /<family>/<category>, /brands/<slug>, /airports/<iata>; "
                               "default home plus the three largest category pools")
    evidence.add_argument("--first", type=int, default=featured.FIRST, help=f"how many slots to measure (default {featured.FIRST})")
    evidence.add_argument("--verbose", action="store_true", help="one line per slot")
    evidence.add_argument("--json", action="store_true", help="the same figures as JSON, one object per page")
    evidence.set_defaults(func=cmd_featured_evidence)
