"""Project inception — what a project was decided to inherit (milestone 297).

A project's inheritance is a decision, not a default. The record lives on
``projects.inception``::

    {
      "decided_at": "<iso>", "decided_by": <user id> | null,
      "via": "mcp" | "ui" | "legacy",
      "choices": {
        "exclude_always_on_rulebooks": [rulebook ids],
        "subscribe_rulebooks": [rulebook ids],
        "design_system_id": <id> | null,
        "seed_systems": bool
      }
    }

NULL = undecided → enter_project asks. ``legacy`` is the migration's stamp on
projects that existed before the step did (inherit-all / no design system /
no seed), so the ask fires only for projects created after this shipped.

The shape and its validator are pure; ``decide`` composes the existing
services — always-on exclusions, subscriptions, set_project_design_system,
the standard Systems seed — checks every target BEFORE touching anything,
applies the effects (each idempotent), and writes the record LAST, so a
half-applied decision is re-runnable rather than recorded as done.
``current_defaults`` is what the enter_project ask shows: what binds today
if nobody decides.
"""
from __future__ import annotations

from datetime import datetime, timezone

from sqlalchemy import select

from scribe.models import async_session
from scribe.models.project import Project
from scribe.models.rulebook import Rulebook

INCEPTION_VIAS = ("mcp", "ui", "legacy")
CHOICE_KEYS = ("exclude_always_on_rulebooks", "subscribe_rulebooks", "design_system_id", "seed_systems")


def _is_id_list(value) -> bool:
    return isinstance(value, list) and all(
        isinstance(v, int) and not isinstance(v, bool) and v > 0 for v in value
    )


def validate_inception(choices) -> str | None:
    """The structural error an inception ``choices`` object would earn, or
    None. Pure and checked BEFORE any effect is applied: a decision either
    applies whole or errors whole (the StrictArgs lesson, #2709).

    Accepts the four keys, each optional: two id lists (positive ints, no
    duplicates between exclude and subscribe), ``design_system_id`` an int
    or None, ``seed_systems`` a bool. Unknown keys are an error — a typo
    must not become a silently ignored choice."""
    if not isinstance(choices, dict):
        return "choices must be an object"
    unknown = sorted(set(choices) - set(CHOICE_KEYS))
    if unknown:
        return f"unknown inception choice(s): {', '.join(unknown)} (one of: {', '.join(CHOICE_KEYS)})"
    excl = choices.get("exclude_always_on_rulebooks") or []
    subs = choices.get("subscribe_rulebooks") or []
    if not _is_id_list(excl):
        return "exclude_always_on_rulebooks must be a list of rulebook ids"
    if not _is_id_list(subs):
        return "subscribe_rulebooks must be a list of rulebook ids"
    both = sorted(set(excl) & set(subs))
    if both:
        return f"rulebook(s) {both} cannot be both excluded and subscribed"
    ds = choices.get("design_system_id")
    if ds is not None and (isinstance(ds, bool) or not isinstance(ds, int) or ds <= 0):
        return "design_system_id must be a positive id or null"
    seed = choices.get("seed_systems", False)
    if not isinstance(seed, bool):
        return "seed_systems must be true or false"
    return None


def normalize_choices(choices: dict | None) -> dict:
    """The four keys, always present, in canonical form — what gets stored
    and what the UI/agent reads back. Call after validate_inception."""
    choices = choices or {}
    return {
        "exclude_always_on_rulebooks": sorted(set(choices.get("exclude_always_on_rulebooks") or [])),
        "subscribe_rulebooks": sorted(set(choices.get("subscribe_rulebooks") or [])),
        "design_system_id": choices.get("design_system_id"),
        "seed_systems": bool(choices.get("seed_systems", False)),
    }


def is_decided(project) -> bool:
    """A project is decided once its inception record exists (any via)."""
    return bool(getattr(project, "inception", None))


async def current_defaults(user_id: int, project_id: int) -> dict:
    """What the project inherits if nobody decides — the ask's payload.

    {always_on_rulebooks: [{id,title}], other_rulebooks: [{id,title}],
     excluded_always_on: [...], subscribed_rulebooks: [...],
     design_system_id, design_systems: [{id,title}], systems: <count>}.
    Instance-agnostic: an install with no rulebooks / design systems shows
    empty lists, and the ask says so rather than inventing a default.
    """
    from scribe.services import design_systems as design_systems_svc
    from scribe.services import projects as projects_svc
    from scribe.services import rulebooks as rulebooks_svc
    from scribe.services import systems as systems_svc

    project = await projects_svc.get_project(user_id, project_id)
    if project is None:
        raise ValueError(f"project {project_id} not found")
    async with async_session() as session:
        rows = (
            await session.execute(
                select(Rulebook.id, Rulebook.title, Rulebook.always_on)
                .where(Rulebook.owner_user_id == user_id, Rulebook.deleted_at.is_(None))
                .order_by(Rulebook.title)
            )
        ).all()
    applicable = await rulebooks_svc.get_applicable_rules(project_id, user_id, limit=1)
    designs = await design_systems_svc.list_design_systems(user_id)
    systems = await systems_svc.list_systems(user_id, project_id, include_archived=True)
    return {
        "always_on_rulebooks": [{"id": i, "title": t} for i, t, on in rows if on],
        "other_rulebooks": [{"id": i, "title": t} for i, t, on in rows if not on],
        "excluded_always_on": applicable.get("excluded_always_on", []),
        "subscribed_rulebooks": applicable.get("subscribed_rulebooks", []),
        "design_system_id": project.design_system_id,
        "design_systems": [{"id": d.id, "title": d.title} for d in designs],
        "systems": len(systems),
    }


async def _check_targets(user_id: int, choices: dict) -> None:
    """Every id a decision names must be the caller's (or readable) BEFORE any
    effect lands — a decision applies whole or errors whole."""
    from scribe.services import access

    wanted = set(choices["exclude_always_on_rulebooks"]) | set(choices["subscribe_rulebooks"])
    if wanted:
        async with async_session() as session:
            rows = (
                await session.execute(
                    select(Rulebook.id, Rulebook.always_on).where(
                        Rulebook.id.in_(wanted),
                        Rulebook.owner_user_id == user_id,
                        Rulebook.deleted_at.is_(None),
                    )
                )
            ).all()
        found = {rid: on for rid, on in rows}
        missing = sorted(wanted - set(found))
        if missing:
            raise ValueError(f"rulebook(s) {missing} not found (or not yours)")
        not_always = sorted(r for r in choices["exclude_always_on_rulebooks"] if not found[r])
        if not_always:
            raise ValueError(
                f"rulebook(s) {not_always} are not always-on — only always-on rulebooks "
                "can be excluded; a subscribed rulebook is simply not subscribed"
            )
    ds = choices["design_system_id"]
    if ds is not None and not await access.can_read_design_system(user_id, ds):
        raise ValueError(f"design system {ds} not found (or not readable)")


async def decide(
    user_id: int,
    project_id: int,
    *,
    choices: dict | None,
    via: str,
) -> dict:
    """Record a project's inception decision and apply it (milestone 297).

    Owner-only. Validates the choices (pure) and every target (owned /
    readable) first; then, each idempotent: exclude the named always-on
    rulebooks, subscribe the named rulebooks, point the project at the design
    system (None = explicitly none), seed the standard Systems if asked and
    the project has none; then write ``projects.inception`` LAST. Re-deciding
    is additive for exclusions/subscriptions (nothing is silently dropped —
    include/unsubscribe are explicit calls), replaces the design system, and
    re-seeds nothing a project already has.

    Returns {"inception": <record>, "effects": {excluded, subscribed,
    design_system_id, systems_seeded}}.
    """
    from scribe.services import design_systems as design_systems_svc
    from scribe.services import projects as projects_svc
    from scribe.services import rulebooks as rulebooks_svc
    from scribe.services import systems as systems_svc

    if via not in INCEPTION_VIAS or via == "legacy":
        raise ValueError("via must be 'mcp' or 'ui' ('legacy' is the migration's stamp)")
    error = validate_inception(choices or {})
    if error:
        raise ValueError(error)
    choices = normalize_choices(choices)
    project = await projects_svc.get_project(user_id, project_id)   # owner-scoped
    if project is None:
        raise ValueError(f"project {project_id} not found (or not yours)")
    await _check_targets(user_id, choices)

    for rb in choices["exclude_always_on_rulebooks"]:
        await rulebooks_svc.exclude_always_on_rulebook_for_project(project_id, rb, user_id)
    for rb in choices["subscribe_rulebooks"]:
        await rulebooks_svc.subscribe_project(project_id, rb, user_id)
    if not await design_systems_svc.set_project_design_system(
        user_id, project_id, choices["design_system_id"]
    ):
        raise ValueError("could not set the design system (no write on the project?)")
    seeded = (
        await systems_svc.seed_standard_systems(user_id, project_id)
        if choices["seed_systems"] else []
    )

    record = {
        "decided_at": datetime.now(timezone.utc).isoformat(),
        "decided_by": user_id,
        "via": via,
        "choices": choices,
    }
    async with async_session() as session:
        row = await session.get(Project, project_id)
        row.inception = record
        row.updated_at = datetime.now(timezone.utc)
        await session.commit()
    return {
        "inception": record,
        "effects": {
            "excluded": choices["exclude_always_on_rulebooks"],
            "subscribed": choices["subscribe_rulebooks"],
            "design_system_id": choices["design_system_id"],
            "systems_seeded": [sy.name for sy in seeded],
        },
    }


async def inception_ask(user_id: int, project_id: int) -> dict:
    """The enter_project ask for an undecided project (milestone 297) — the
    sibling of the systems-bootstrap ask (#2683): the project's OWN current
    defaults, what to ask the operator, and the exact call that answers it.
    Fail-open: a hint must never break the call it rides on."""
    try:
        defaults = await current_defaults(user_id, project_id)
    except Exception:
        return {}
    always = ", ".join(f"{r['title']} (#{r['id']})" for r in defaults["always_on_rulebooks"]) or "none"
    others = ", ".join(f"{r['title']} (#{r['id']})" for r in defaults["other_rulebooks"]) or "none"
    designs = ", ".join(f"{d['title']} (#{d['id']})" for d in defaults["design_systems"]) or "none"
    return {
        "defaults": defaults,
        "ask": (
            "This project has no inception decision: nobody has said what it "
            f"inherits. Today, by default: always-on rulebooks binding it — {always}; "
            f"rulebooks it could subscribe to — {others}; design system — "
            f"{'#' + str(defaults['design_system_id']) if defaults['design_system_id'] else 'none'} "
            f"(available: {designs}); Systems — {defaults['systems']}. Ask the operator, "
            "once: which always-on rulebooks to EXCLUDE here (default: none), which "
            "rulebooks to subscribe, which design system (or none), and whether to seed "
            "the standard starter Systems — then record the answers. This ask repeats on "
            "every enter_project until a decision is recorded."
        ),
        "call": (
            f"decide_project_inception(project_id={project_id}, "
            "exclude_always_on_rulebooks=[...], subscribe_rulebooks=[...], "
            "design_system_id=<id | -1 for none>, seed_systems=<true|false>)"
        ),
    }

