"""Resolution computation for a staged import (imports.md §H, rebuild
edition): which operator/client/project names in the parsed entries are
already known (case-insensitive, scoped by parent, chained — B42/B43),
which need a human decision, and the incomplete buckets (B44).

The wire contract is structured JSON keyed on the raw parsed names —
the legacy pipe-delimited composite keys (wart #3) do not port. The
frontend computes live candidate scoping (effectiveClientId — B51)
from the catalog this module returns.
"""

from __future__ import annotations

import uuid
from dataclasses import dataclass, field

from sqlalchemy.orm import Session

from app.models import PendingImport
from app.services.entities import EntityGraph, load_entity_graph


def bucket_signature(operator: str | None, client: str | None, project: str | None) -> str:
    """The legacy bucket signature `${op??''}|${cl??''}|${pr??''}` —
    kept as the bucket's opaque identity string (it appears verbatim in
    the B56 create-incomplete error message)."""
    return f"{operator or ''}|{client or ''}|{project or ''}"


@dataclass
class Resolution:
    graph: EntityGraph
    known_operators: list[dict] = field(default_factory=list)
    unknown_operators: list[dict] = field(default_factory=list)
    known_clients: list[dict] = field(default_factory=list)
    unknown_clients: list[dict] = field(default_factory=list)
    known_projects: list[dict] = field(default_factory=list)
    unknown_projects: list[dict] = field(default_factory=list)
    buckets: list[dict] = field(default_factory=list)
    missing_rows: int = 0

    @property
    def all_known(self) -> bool:
        return (
            not self.unknown_operators
            and not self.unknown_clients
            and not self.unknown_projects
            and not self.buckets
        )


def _distinct_names(entries: list[dict]) -> tuple[list[str], list[tuple], list[tuple]]:
    """B43: operators from rows with a truthy operator; (op, client)
    pairs where both truthy; triples where ALL THREE truthy. First-seen
    order, then sorted for display."""
    ops: dict[str, None] = {}
    pairs: dict[tuple[str, str], None] = {}
    triples: dict[tuple[str, str, str], None] = {}
    for e in entries:
        op = e.get("operator") or ""
        cl = e.get("client") or ""
        pr = e.get("project") or ""
        if op:
            ops.setdefault(op)
        if op and cl:
            pairs.setdefault((op, cl))
        if op and cl and pr:
            triples.setdefault((op, cl, pr))
    return (
        sorted(ops),
        sorted(pairs),
        sorted(triples),
    )


def _buckets(entries: list[dict]) -> tuple[list[dict], int]:
    """B44: rows where ANY of operator/client/project is blank, grouped
    by signature, sorted by count DESCENDING."""
    groups: dict[str, dict] = {}
    missing_rows = 0
    for e in entries:
        op = e.get("operator") or None
        cl = e.get("client") or None
        pr = e.get("project") or None
        if op and cl and pr:
            continue
        missing_rows += 1
        key = bucket_signature(op, cl, pr)
        bucket = groups.get(key)
        if bucket is None:
            missing = [
                label
                for label, value in (("operator", op), ("client", cl), ("project", pr))
                if not value
            ]
            groups[key] = {
                "key": key,
                "operator": op,
                "client": cl,
                "project": pr,
                "missing": missing,
                "count": 1,
            }
        else:
            bucket["count"] += 1
    ordered = sorted(groups.values(), key=lambda b: (-b["count"], b["key"]))
    return ordered, missing_rows


def compute_resolution(session: Session, tenant_id: uuid.UUID, entries: list[dict]) -> Resolution:
    """Known-ness is chained (B42): a client can only be known under a
    known operator; a project only under a known (operator, client)."""
    graph = load_entity_graph(session, tenant_id)
    res = Resolution(graph=graph)

    op_names, pairs, triples = _distinct_names(entries)

    op_ids: dict[str, uuid.UUID | None] = {}
    for name in op_names:
        found = graph.find_operator(name)
        op_ids[name] = found
        if found is not None:
            res.known_operators.append(
                {"name": name, "id": str(found), "canonical_name": graph.operators[found]}
            )
        else:
            res.unknown_operators.append({"name": name})

    client_ids: dict[tuple[str, str], uuid.UUID | None] = {}
    for op_name, cl_name in pairs:
        op_id = op_ids.get(op_name)
        found = graph.find_client(op_id, cl_name) if op_id is not None else None
        client_ids[(op_name, cl_name)] = found
        if found is not None:
            res.known_clients.append(
                {
                    "operator": op_name,
                    "name": cl_name,
                    "id": str(found),
                    "canonical_name": graph.client_names.get(found, cl_name),
                }
            )
        else:
            res.unknown_clients.append(
                {
                    "operator": op_name,
                    "name": cl_name,
                    "operator_id": str(op_id) if op_id is not None else None,
                }
            )

    for op_name, cl_name, pr_name in triples:
        cl_id = client_ids.get((op_name, cl_name))
        found = graph.find_project(cl_id, pr_name) if cl_id is not None else None
        if found is not None:
            res.known_projects.append(
                {
                    "operator": op_name,
                    "client": cl_name,
                    "name": pr_name,
                    "id": str(found),
                    "canonical_name": graph.project_chain[found][0],
                }
            )
        else:
            res.unknown_projects.append(
                {
                    "operator": op_name,
                    "client": cl_name,
                    "name": pr_name,
                    "client_id": str(cl_id) if cl_id is not None else None,
                }
            )

    res.buckets, res.missing_rows = _buckets(entries)
    return res


def catalog_payload(graph: EntityGraph) -> dict:
    """Everything the resolver UI needs for map candidates + datalists
    (B48/B50/B51): operators, clients (with their operator edge), and
    projects labeled `{client} : {project}` sorted by label (B48)."""
    operators = sorted(
        ({"id": str(pid), "name": name} for pid, name in graph.operators.items()),
        key=lambda o: o["name"].lower(),
    )
    clients = []
    for op_id, by_name in graph.clients_of.items():
        for client_id in by_name.values():
            clients.append(
                {
                    "id": str(client_id),
                    "name": graph.client_names.get(client_id, ""),
                    "operator_id": str(op_id),
                }
            )
    clients.sort(key=lambda c: (c["name"].lower(), c["operator_id"]))
    projects = []
    for project_id, (name, op_id, client_id) in graph.project_chain.items():
        client_name = graph.client_names.get(client_id, "")
        projects.append(
            {
                "id": str(project_id),
                "name": name,
                "operator_id": str(op_id),
                "client_id": str(client_id),
                "label": f"{client_name} : {name}",
            }
        )
    projects.sort(key=lambda p: p["label"].lower())
    return {"operators": operators, "clients": clients, "projects": projects}


def resolution_payload(session: Session, actor, pending: PendingImport) -> dict:
    """The GET /api/import/pending/{id} response body."""
    from app.services.imports.staging import can_commit, uploader_username

    entries = pending.parsed_entries or []
    res = compute_resolution(session, pending.tenant_id, entries)
    return {
        "pending": {
            "id": str(pending.id),
            "batch_name": pending.batch_name,
            "filename": pending.filename,
            "notes": pending.notes,
            "source": pending.source,
            "entry_count": len(entries),
            "total_rows": pending.total_rows,
            "uploaded_at": pending.uploaded_at.isoformat() if pending.uploaded_at else None,
            "uploaded_by": uploader_username(session, pending),
            "parse_errors": pending.parse_errors or [],
            "parse_warnings": pending.parse_warnings or [],
        },
        "can_commit": can_commit(actor, pending),
        "operators": {"known": res.known_operators, "unknown": res.unknown_operators},
        "clients": {"known": res.known_clients, "unknown": res.unknown_clients},
        "projects": {"known": res.known_projects, "unknown": res.unknown_projects},
        "buckets": res.buckets,
        "summary": {
            "operators_known": len(res.known_operators),
            "operators_unknown": len(res.unknown_operators),
            "clients_known": len(res.known_clients),
            "clients_unknown": len(res.unknown_clients),
            "projects_known": len(res.known_projects),
            "projects_unknown": len(res.unknown_projects),
            "missing_rows": res.missing_rows,
            "bucket_count": len(res.buckets),
            "all_known": res.all_known,
        },
        "catalog": catalog_payload(res.graph),
    }
