"""Resolve-or-create for the operator -> client -> project chain — THE
one implementation every write path uses (04-architecture; 02 §5
invariant 3: "entities are first-class, no free-text references").

Model mapping (02 §2): an *operator* is an org party; a *client* is an
org party connected to its operator by an active ``direct_client``
engagement in the tenant; a *project* is a projects row carrying both
party FKs. "Known-ness" is case-insensitive name matching scoped by
parent, exactly like the legacy resolver (imports.md B42) — but with
``lower() =`` comparisons, never ``ilike`` (the legacy wildcard wart,
edge case #4, does not port).

The tenant's own party is an operator ("one party wearing two hats" —
the migration merge rule); resolving a client whose name matches an
existing operator-set party reuses that party and just adds the edge,
mirroring the migration's client-into-operator merge.
"""

from __future__ import annotations

import re
import unicodedata
import uuid
from dataclasses import dataclass, field

from sqlalchemy import select
from sqlalchemy.orm import Session

from app.models import Engagement, Party, Project, Tenant
from app.services.entities.parties import create_org_party


def slugify(name: str) -> str:
    ascii_name = unicodedata.normalize("NFKD", name).encode("ascii", "ignore").decode()
    slug = re.sub(r"[^a-z0-9]+", "-", ascii_name.lower()).strip("-")
    return slug or "party"


def _allocate_slug(session: Session, name: str) -> str:
    base = slugify(name)
    slug, n = base, 1
    while session.scalar(select(Party.id).where(Party.slug == slug)) is not None:
        n += 1
        slug = f"{base}-{n}"
    return slug


@dataclass
class EntityGraph:
    """The tenant's entity chain, loaded once per request and mutated
    as resolve-or-create creates rows — so chains created early (e.g.
    incomplete-bucket creates, which run FIRST) are found as existing
    by later steps (imports.md B56)."""

    tenant_id: uuid.UUID
    tenant_party_id: uuid.UUID | None
    #: operator set: party id -> display name
    operators: dict[uuid.UUID, str] = field(default_factory=dict)
    #: lower(name) -> operator party id
    op_by_lower: dict[str, uuid.UUID] = field(default_factory=dict)
    #: operator party id -> { lower(client name) -> client party id }
    clients_of: dict[uuid.UUID, dict[str, uuid.UUID]] = field(default_factory=dict)
    #: client party id -> display name
    client_names: dict[uuid.UUID, str] = field(default_factory=dict)
    #: client party id -> { lower(project name) -> project id }
    projects_by_client: dict[uuid.UUID, dict[str, uuid.UUID]] = field(default_factory=dict)
    #: project id -> (name, operator_party_id, client_party_id)
    project_chain: dict[uuid.UUID, tuple[str, uuid.UUID, uuid.UUID]] = field(
        default_factory=dict
    )

    # --- lookups (case-insensitive, parent-scoped — B42) -----------------

    def find_operator(self, name: str) -> uuid.UUID | None:
        return self.op_by_lower.get(name.strip().lower())

    def find_client(self, operator_id: uuid.UUID, name: str) -> uuid.UUID | None:
        return self.clients_of.get(operator_id, {}).get(name.strip().lower())

    def find_project(self, client_id: uuid.UUID, name: str) -> uuid.UUID | None:
        return self.projects_by_client.get(client_id, {}).get(name.strip().lower())

    # --- graph mutation helpers ------------------------------------------

    def add_operator(self, party_id: uuid.UUID, name: str) -> None:
        self.operators[party_id] = name
        self.op_by_lower.setdefault(name.strip().lower(), party_id)

    def add_client_edge(self, operator_id: uuid.UUID, client_id: uuid.UUID, name: str) -> None:
        self.clients_of.setdefault(operator_id, {})[name.strip().lower()] = client_id
        self.client_names.setdefault(client_id, name)

    def add_project(
        self, project_id: uuid.UUID, name: str, operator_id: uuid.UUID, client_id: uuid.UUID
    ) -> None:
        self.projects_by_client.setdefault(client_id, {})[name.strip().lower()] = project_id
        self.project_chain[project_id] = (name, operator_id, client_id)


def load_entity_graph(session: Session, tenant_id: uuid.UUID) -> EntityGraph:
    """Operators = the tenant party ∪ direct_client operator sides ∪
    project operator parties; clients = active direct_client edges ∪
    project (operator, client) pairs; projects = the projects table."""
    tenant_party_id = session.scalar(select(Tenant.party_id).where(Tenant.id == tenant_id))
    graph = EntityGraph(tenant_id=tenant_id, tenant_party_id=tenant_party_id)

    names = dict(
        session.execute(select(Party.id, Party.name).where(Party.kind == "org")).all()
    )

    if tenant_party_id is not None and tenant_party_id in names:
        graph.add_operator(tenant_party_id, names[tenant_party_id])

    edges = session.execute(
        select(Engagement.party_a_id, Engagement.party_b_id)
        .where(
            Engagement.tenant_id == tenant_id,
            Engagement.type == "direct_client",
            Engagement.ended_on.is_(None),
        )
        .order_by(Engagement.created_at)
    ).all()
    for op_id, client_id in edges:
        if op_id in names:
            graph.add_operator(op_id, names[op_id])
        if client_id in names:
            graph.add_client_edge(op_id, client_id, names[client_id])

    projects = session.execute(
        select(Project.id, Project.name, Project.operator_party_id, Project.client_party_id)
        .where(Project.tenant_id == tenant_id)
        .order_by(Project.created_at)
    ).all()
    for project_id, name, op_id, client_id in projects:
        if op_id in names:
            graph.add_operator(op_id, names[op_id])
        if client_id in names:
            graph.add_client_edge(op_id, client_id, names[client_id])
        graph.add_project(project_id, name, op_id, client_id)

    return graph


def resolve_or_create_operator(
    session: Session, graph: EntityGraph, name: str
) -> uuid.UUID:
    """Case-insensitive match in the tenant's operator set wins; miss
    -> create an org party with the raw (trimmed) CSV name."""
    found = graph.find_operator(name)
    if found is not None:
        return found
    clean = name.strip()
    party = create_org_party(session, name=clean, slug=_allocate_slug(session, clean))
    graph.add_operator(party.id, clean)
    return party.id


def resolve_or_create_client(
    session: Session, graph: EntityGraph, operator_id: uuid.UUID, name: str
) -> uuid.UUID:
    """Match under the operator wins; a name matching an operator-set
    party reuses that party (two hats) and adds the edge; otherwise a
    new org party + direct_client engagement are created. Same-named
    clients under DIFFERENT operators stay separate parties (the
    migration rule)."""
    found = graph.find_client(operator_id, name)
    if found is not None:
        return found
    clean = name.strip()
    party_id = graph.find_operator(clean)
    if party_id is None:
        party = create_org_party(session, name=clean, slug=_allocate_slug(session, clean))
        party_id = party.id
    session.add(
        Engagement(
            tenant_id=graph.tenant_id,
            type="direct_client",
            party_a_id=operator_id,
            party_b_id=party_id,
            role_a="operator",
            role_b="client",
        )
    )
    session.flush()
    graph.add_client_edge(operator_id, party_id, clean)
    return party_id


def resolve_or_create_project(
    session: Session,
    graph: EntityGraph,
    operator_id: uuid.UUID,
    client_id: uuid.UUID,
    name: str,
) -> uuid.UUID:
    """Match under the client wins (project uniqueness is per (tenant,
    client, lower(name)) — judgment #33); miss -> create the projects
    row carrying both party FKs."""
    found = graph.find_project(client_id, name)
    if found is not None:
        return found
    clean = name.strip()
    project = Project(
        tenant_id=graph.tenant_id,
        operator_party_id=operator_id,
        client_party_id=client_id,
        name=clean,
        status="active",
    )
    session.add(project)
    session.flush()
    graph.add_project(project.id, clean, operator_id, client_id)
    return project.id
