"""services/clients — the PlusROI Clients + Contacts write surface
(phase-3 slice 1, docs/design/plusroi-forms.md §1).

A **client** is an org party the tenant does business with, carried by
three rows this service keeps in step (all pre-existing tables — no
schema change in this slice, ADR #012/#013/#016):

  * the **org party** (+ its 1:1 ``organizations`` detail) — the shared
    identity (name, legal_name, address, phone). Created ONLY through
    ``services.entities.parties.create_org_party`` (the 1:1 subtype choke
    point) — never ``Party(...)`` directly.
  * a **``client_profiles``** row for THIS tenant — the per-tenant
    commercial defaults (currency, terms, invoice_timing, credit_limit,
    default_commission_pct + commission_party, archived). PK
    (tenant, client_party): the same org can be a client of several
    tenants with different terms, so the profile is the tenant-scoped
    anchor and the authoritative "who are MY clients" list.
  * a **``direct_client`` engagement** (operator = the tenant party,
    client = the org) — the entity-graph edge the resolver/imports read.

**Contacts** are person parties linked to a client org by a
``party_affiliations`` row with role ``cc_recipient`` (email lives on the
contact person, per the spec) — reusable across clients, listed on their
own page.

**Reuse-existing-org (dedup).** Create matches an existing ACTIVE org
party by lower(name) first (the ``services/registry`` pattern): a hit
REUSES that party (the shared-identity model — one "Copernic" across
tenants) and just adds this tenant's profile + edge; only a miss mints a
new party. A party that is ALREADY this tenant's client (a profile row
exists) is a 409, not a silent duplicate.

Every mutation is **owner-gated** (``can_transfer``), refused on
**sheet-owned** books (409 ``SHEET_OWNED_READ_ONLY``), **tenant-scoped**,
and **audited** — the shared ``services.plusroi_gates`` gate.
"""

from __future__ import annotations

import re
import uuid
from datetime import date
from decimal import Decimal, InvalidOperation

from sqlalchemy import func, select
from sqlalchemy.orm import Session, aliased

from app.models import (
    ClientProfile,
    Engagement,
    Organization,
    Party,
    PartyAffiliation,
    Tenant,
)
from app.services.audit import write_audit
from app.services.entities.parties import create_org_party, create_person_party
from app.services.entities.resolve import _allocate_slug
from app.services.errors import ServiceError
from app.services.plusroi_gates import (
    require_owner,
    require_tenant,
    require_writable_books,
)

#: The affiliation role a contact is created under. ``cc_recipient`` = a
#: person cc'd on the client's invoices/comms (docs/features/people-
#: organizations.md). ``contact`` is accepted when LISTING (a legacy /
#: hand-made affiliation) but never created here.
CONTACT_CREATE_ROLE = "cc_recipient"
CONTACT_ROLES = ("cc_recipient", "contact")

#: The currencies a client profile may be quoted in (07 #21 all-CAD
#: default; PlusROI also invoices USD). Kept small + explicit so a typo
#: can't land an un-settleable currency in the books.
ALLOWED_CURRENCIES = ("CAD", "USD", "EUR", "GBP")

_EMAIL_RE = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$")


# --------------------------------------------------------------- parsing


def _clean_text(raw) -> str | None:
    if raw is None:
        return None
    trimmed = str(raw).strip()
    return trimmed or None


def _require_name(raw) -> str:
    name = (raw or "").strip()
    if not name:
        raise ServiceError(422, "NAME_REQUIRED", "A name is required.")
    return name


def _clean_email(raw, *, required: bool) -> str | None:
    email = (raw or "").strip().lower()
    if not email:
        if required:
            raise ServiceError(422, "BAD_EMAIL", "A valid email is required.")
        return None
    if not _EMAIL_RE.match(email):
        raise ServiceError(
            422, "BAD_EMAIL", "A valid email is required.", f"Got {raw!r}."
        )
    return email


def _clean_currency(raw, default: str) -> str:
    code = (raw or "").strip().upper() or default
    if code not in ALLOWED_CURRENCIES:
        raise ServiceError(
            422,
            "BAD_CURRENCY",
            f"Currency must be one of {', '.join(ALLOWED_CURRENCIES)}.",
            f"Got {raw!r}.",
        )
    return code


def _parse_money(raw, label: str) -> Decimal | None:
    """A 2dp money field: blank/None -> None (clear); else a finite,
    non-negative number rounded to cents."""
    if raw is None or str(raw).strip() == "":
        return None
    try:
        value = Decimal(re.sub(r"[$,\s]", "", str(raw)))
    except (InvalidOperation, ValueError) as exc:
        raise ServiceError(
            422, "BAD_AMOUNT", f"{label} must be a number, or empty to clear."
        ) from exc
    if not value.is_finite() or value < 0:
        raise ServiceError(
            422, "BAD_AMOUNT", f"{label} must be a non-negative number, or empty."
        )
    return value.quantize(Decimal("0.01"))


def _parse_pct(raw) -> Decimal | None:
    """A commission percentage: blank/None -> None; else a finite number
    (any sign) to 4dp — matches the ``numeric(7,4)`` column."""
    if raw is None or str(raw).strip() == "":
        return None
    try:
        value = Decimal(re.sub(r"[%,\s]", "", str(raw)))
    except (InvalidOperation, ValueError) as exc:
        raise ServiceError(
            422,
            "BAD_COMMISSION",
            "Commission % must be a number, or empty to clear.",
        ) from exc
    if not value.is_finite():
        raise ServiceError(
            422,
            "BAD_COMMISSION",
            "Commission % must be a number, or empty to clear.",
        )
    return value.quantize(Decimal("0.0001"))


def _validate_party(session: Session, raw) -> uuid.UUID | None:
    """Resolve an optional party-id reference (the commission earner)."""
    if raw in (None, ""):
        return None
    try:
        party_id = uuid.UUID(str(raw))
    except ValueError as exc:
        raise ServiceError(
            422, "BAD_PARTY", "commission_party_id must be a UUID."
        ) from exc
    if session.scalar(select(Party.id).where(Party.id == party_id)) is None:
        raise ServiceError(404, "PARTY_NOT_FOUND", "Selected commission party not found.")
    return party_id


# --------------------------------------------------------------- helpers


def _tenant_party_id(session: Session, tenant_id: uuid.UUID) -> uuid.UUID:
    party_id = session.scalar(select(Tenant.party_id).where(Tenant.id == tenant_id))
    if party_id is None:
        raise ServiceError(400, "NO_TENANT", "Tenant has no party.")
    return party_id


def _tenant_currency(session: Session, tenant_id: uuid.UUID) -> str:
    settings = session.scalar(select(Tenant.settings).where(Tenant.id == tenant_id))
    if isinstance(settings, dict):
        code = settings.get("default_currency")
        if isinstance(code, str) and code.strip():
            return code.strip().upper()
    return "CAD"


def _active_org_by_lower_name(session: Session, name_lower: str) -> Party | None:
    """The registry dedup query (services/registry._active_org_by_lower_name):
    the oldest ACTIVE org party whose lower(name) matches, or None."""
    return session.scalars(
        select(Party)
        .where(
            Party.kind == "org",
            Party.is_active.is_(True),
            func.lower(Party.name) == name_lower,
        )
        .order_by(Party.created_at)
        .limit(1)
    ).first()


def _org_detail(session: Session, party_id: uuid.UUID) -> Organization | None:
    return session.get(Organization, party_id)


def _get_profile(
    session: Session, tenant_id: uuid.UUID, party_id: uuid.UUID
) -> ClientProfile | None:
    return session.get(ClientProfile, (tenant_id, party_id))


def _num(value) -> str | None:
    return str(value) if value is not None else None


def _party_name(session: Session, party_id: uuid.UUID | None) -> str | None:
    if party_id is None:
        return None
    return session.scalar(select(Party.name).where(Party.id == party_id))


def _client_row(
    profile: ClientProfile,
    party: Party,
    org: Organization | None,
    commission_name: str | None,
    contact_count: int,
) -> dict:
    return {
        "party_id": str(party.id),
        "name": party.name,
        "official_name": org.legal_name if org is not None else None,
        "address": party.address,
        "phone": party.phone,
        "currency": profile.currency,
        "terms": profile.terms,
        "invoice_timing": profile.invoice_timing,
        "credit_limit": _num(profile.credit_limit),
        "default_commission_pct": _num(profile.default_commission_pct),
        "commission_party": (
            {"id": str(profile.commission_party_id), "name": commission_name}
            if profile.commission_party_id is not None
            else None
        ),
        "archived": bool(profile.archived),
        "contact_count": contact_count,
    }


def _contact_dto(aff: PartyAffiliation, person: Party) -> dict:
    return {
        "affiliation_id": str(aff.id),
        "party_id": str(person.id),
        "name": person.name,
        "email": person.email,
        "role": aff.role,
    }


def _profile_image(profile: ClientProfile, party: Party, org: Organization | None) -> dict:
    """A JSON-serializable image of the client's mutable state — the audit
    before/after payload (name/legal_name/address/phone + every profile
    column)."""
    return {
        "name": party.name,
        "legal_name": org.legal_name if org is not None else None,
        "address": party.address,
        "phone": party.phone,
        "currency": profile.currency,
        "terms": profile.terms,
        "invoice_timing": profile.invoice_timing,
        "credit_limit": _num(profile.credit_limit),
        "default_commission_pct": _num(profile.default_commission_pct),
        "commission_party_id": (
            str(profile.commission_party_id)
            if profile.commission_party_id is not None
            else None
        ),
        "archived": bool(profile.archived),
    }


def _contact_counts(
    session: Session, party_ids: list[uuid.UUID]
) -> dict[uuid.UUID, int]:
    if not party_ids:
        return {}
    rows = session.execute(
        select(
            PartyAffiliation.org_party_id, func.count(PartyAffiliation.id)
        )
        .where(
            PartyAffiliation.org_party_id.in_(party_ids),
            PartyAffiliation.role.in_(CONTACT_ROLES),
            PartyAffiliation.ended_on.is_(None),
        )
        .group_by(PartyAffiliation.org_party_id)
    ).all()
    return {pid: int(count) for pid, count in rows}


# --------------------------------------------------------------- reads


def list_clients(session: Session, actor) -> dict:
    """This tenant's clients: one row per ``client_profiles`` row (the
    tenant-scoped anchor), joined to the org party + detail, with a live
    contact count. Owner-only."""
    require_owner(actor, "Only an owner can view clients.")
    tenant_id = require_tenant(actor)

    rows = session.execute(
        select(ClientProfile, Party, Organization)
        .join(Party, Party.id == ClientProfile.client_party_id)
        .join(Organization, Organization.party_id == Party.id, isouter=True)
        .where(ClientProfile.tenant_id == tenant_id)
        .order_by(func.lower(Party.name))
    ).all()

    party_ids = [party.id for _, party, _ in rows]
    counts = _contact_counts(session, party_ids)
    commission_ids = {
        p.commission_party_id for p, _, _ in rows if p.commission_party_id is not None
    }
    commission_names = (
        dict(
            session.execute(
                select(Party.id, Party.name).where(Party.id.in_(commission_ids))
            ).all()
        )
        if commission_ids
        else {}
    )

    return {
        "rows": [
            _client_row(
                profile,
                party,
                org,
                commission_names.get(profile.commission_party_id),
                counts.get(party.id, 0),
            )
            for profile, party, org in rows
        ]
    }


def get_client(session: Session, actor, party_id: uuid.UUID) -> dict:
    """One client (profile + org identity + its contacts). A party that is
    not a client of THIS tenant is a clean 404 (tenant isolation)."""
    require_owner(actor, "Only an owner can view clients.")
    tenant_id = require_tenant(actor)

    profile = _get_profile(session, tenant_id, party_id)
    party = session.get(Party, party_id)
    if profile is None or party is None:
        raise ServiceError(404, "CLIENT_NOT_FOUND", "Client not found.")
    org = _org_detail(session, party_id)

    aff_rows = session.execute(
        select(PartyAffiliation, Party)
        .join(Party, Party.id == PartyAffiliation.person_party_id)
        .where(
            PartyAffiliation.org_party_id == party_id,
            PartyAffiliation.role.in_(CONTACT_ROLES),
            PartyAffiliation.ended_on.is_(None),
        )
        .order_by(func.lower(Party.name))
    ).all()
    contacts = [_contact_dto(aff, person) for aff, person in aff_rows]

    return {
        "client": _client_row(
            profile,
            party,
            org,
            _party_name(session, profile.commission_party_id),
            len(contacts),
        ),
        "contacts": contacts,
    }


def list_contacts(session: Session, actor) -> dict:
    """This tenant's contacts: people with an active cc_recipient/contact
    affiliation to one of this tenant's clients, grouped per person (a
    person cc'd on two clients is ONE row carrying both links)."""
    require_owner(actor, "Only an owner can view contacts.")
    tenant_id = require_tenant(actor)

    person = aliased(Party)
    org = aliased(Party)
    client_ids = select(ClientProfile.client_party_id).where(
        ClientProfile.tenant_id == tenant_id
    )
    rows = session.execute(
        select(
            PartyAffiliation.id,
            PartyAffiliation.role,
            person.id,
            person.name,
            person.email,
            org.id,
            org.name,
        )
        .join(person, person.id == PartyAffiliation.person_party_id)
        .join(org, org.id == PartyAffiliation.org_party_id)
        .where(
            PartyAffiliation.role.in_(CONTACT_ROLES),
            PartyAffiliation.ended_on.is_(None),
            PartyAffiliation.org_party_id.in_(client_ids),
        )
        .order_by(func.lower(person.name), func.lower(org.name))
    ).all()

    by_person: dict[uuid.UUID, dict] = {}
    for aff_id, role, person_id, person_name, person_email, org_id, org_name in rows:
        entry = by_person.setdefault(
            person_id,
            {
                "party_id": str(person_id),
                "name": person_name,
                "email": person_email,
                "clients": [],
            },
        )
        entry["clients"].append(
            {
                "affiliation_id": str(aff_id),
                "client_party_id": str(org_id),
                "name": org_name,
                "role": role,
            }
        )
    return {"rows": list(by_person.values())}


# --------------------------------------------------------------- writes


def _upsert_direct_client_edge(
    session: Session,
    tenant_id: uuid.UUID,
    operator_party_id: uuid.UUID,
    client_party_id: uuid.UUID,
) -> bool:
    """Ensure the tenant->client ``direct_client`` engagement exists
    (idempotent — a reused org already a client keeps its single edge).
    Returns True when a new edge was created."""
    existing = session.scalar(
        select(Engagement.id).where(
            Engagement.tenant_id == tenant_id,
            Engagement.type == "direct_client",
            Engagement.party_a_id == operator_party_id,
            Engagement.party_b_id == client_party_id,
            Engagement.ended_on.is_(None),
        )
    )
    if existing is not None:
        return False
    session.add(
        Engagement(
            tenant_id=tenant_id,
            type="direct_client",
            party_a_id=operator_party_id,
            party_b_id=client_party_id,
            role_a="operator",
            role_b="client",
        )
    )
    session.flush()
    return True


def create_client(session: Session, actor, payload: dict) -> dict:
    """Create (or attach) a client: the org party (reused by name if it
    already exists) + this tenant's ``client_profiles`` row + the
    ``direct_client`` edge + any inline contacts. Owner-only, writable-books,
    tenant-scoped, audited."""
    require_owner(actor, "Only an owner can create clients.")
    require_writable_books(session, actor)
    tenant_id = require_tenant(actor)
    operator_party_id = _tenant_party_id(session, tenant_id)

    name = _require_name(payload.get("name"))
    official_name = _clean_text(payload.get("official_name"))
    address = _clean_text(payload.get("address"))
    phone = _clean_text(payload.get("phone"))
    currency = _clean_currency(payload.get("currency"), _tenant_currency(session, tenant_id))
    terms = _clean_text(payload.get("terms"))
    invoice_timing = _clean_text(payload.get("invoice_timing"))
    credit_limit = _parse_money(payload.get("credit_limit"), "Credit limit")
    commission_pct = _parse_pct(payload.get("default_commission_pct"))
    commission_party_id = _validate_party(session, payload.get("commission_party_id"))
    archived = bool(payload.get("archived", False))

    # Validate inline contacts up-front so a bad row rejects the whole
    # create (no half-made client with a dropped contact).
    raw_contacts = payload.get("contacts") or []
    contacts: list[tuple[str, str | None]] = []
    for row in raw_contacts:
        c_name = (row.get("name") or "").strip() if isinstance(row, dict) else ""
        c_email = row.get("email") if isinstance(row, dict) else None
        if not c_name and not (c_email or "").strip():
            continue  # a blank repeater row is ignored, not an error
        contacts.append((_require_name(c_name), _clean_email(c_email, required=False)))

    # Reuse-existing-org dedup (registry pattern): an ACTIVE org party with
    # this name is REUSED (shared identity) — only a miss mints a new party.
    party = _active_org_by_lower_name(session, name.lower())
    reused = party is not None
    if party is None:
        party = create_org_party(
            session,
            name=name,
            slug=_allocate_slug(session, name),
            address=address,
            phone=phone,
        )
    else:
        if _get_profile(session, tenant_id, party.id) is not None:
            raise ServiceError(
                409,
                "CLIENT_EXISTS",
                f'"{party.name}" is already a client of these books.',
                "Open the existing client to edit it, or archive it first.",
            )
        # Non-destructive enrichment on reuse: fill blanks only, never
        # clobber another tenant's stored identity for a shared party.
        if address and not party.address:
            party.address = address
        if phone and not party.phone:
            party.phone = phone

    org = _org_detail(session, party.id)
    if org is None:  # defensive: the choke point always makes one
        org = Organization(party_id=party.id)
        session.add(org)
        session.flush()
    if official_name and not org.legal_name:
        org.legal_name = official_name
    if org.currency is None:
        org.currency = currency

    profile = ClientProfile(
        tenant_id=tenant_id,
        client_party_id=party.id,
        currency=currency,
        terms=terms,
        invoice_timing=invoice_timing,
        credit_limit=credit_limit,
        default_commission_pct=commission_pct,
        commission_party_id=commission_party_id,
        archived=archived,
    )
    session.add(profile)
    session.flush()

    _upsert_direct_client_edge(session, tenant_id, operator_party_id, party.id)

    created_contacts = [
        _add_contact_row(session, party.id, c_name, c_email)
        for c_name, c_email in contacts
    ]

    write_audit(
        session,
        actor,
        action="clients.create",
        entity_type="client",
        entity_id=party.id,
        after={
            "reused_party": reused,
            "profile": _profile_image(profile, party, org),
            "contacts": [c["party_id"] for c in created_contacts],
        },
    )
    session.commit()

    return {
        "ok": True,
        "reused_party": reused,
        "row": _client_row(
            profile,
            party,
            org,
            _party_name(session, commission_party_id),
            len(created_contacts),
        ),
        "contacts": created_contacts,
    }


def update_client(session: Session, actor, party_id: uuid.UUID, patch: dict) -> dict:
    """PATCH a client: only the SET keys change. Org-identity keys (name,
    official_name, address, phone) update the SHARED party/org; profile
    keys update THIS tenant's ``client_profiles`` row. Owner-only,
    writable-books, tenant-scoped, audited."""
    require_owner(actor, "Only an owner can edit clients.")
    require_writable_books(session, actor)
    tenant_id = require_tenant(actor)

    profile = _get_profile(session, tenant_id, party_id)
    party = session.get(Party, party_id)
    if profile is None or party is None:
        raise ServiceError(404, "CLIENT_NOT_FOUND", "Client not found.")
    org = _org_detail(session, party_id)
    if org is None:
        org = Organization(party_id=party_id)
        session.add(org)
        session.flush()

    before = _profile_image(profile, party, org)

    if "name" in patch:
        party.name = _require_name(patch.get("name"))
    if "official_name" in patch:
        org.legal_name = _clean_text(patch.get("official_name"))
    if "address" in patch:
        party.address = _clean_text(patch.get("address"))
    if "phone" in patch:
        party.phone = _clean_text(patch.get("phone"))
    if "currency" in patch:
        profile.currency = _clean_currency(
            patch.get("currency"), _tenant_currency(session, tenant_id)
        )
    if "terms" in patch:
        profile.terms = _clean_text(patch.get("terms"))
    if "invoice_timing" in patch:
        profile.invoice_timing = _clean_text(patch.get("invoice_timing"))
    if "credit_limit" in patch:
        profile.credit_limit = _parse_money(patch.get("credit_limit"), "Credit limit")
    if "default_commission_pct" in patch:
        profile.default_commission_pct = _parse_pct(patch.get("default_commission_pct"))
    if "commission_party_id" in patch:
        profile.commission_party_id = _validate_party(
            session, patch.get("commission_party_id")
        )
    if "archived" in patch:
        profile.archived = bool(patch.get("archived"))

    after = _profile_image(profile, party, org)
    write_audit(
        session,
        actor,
        action="clients.update",
        entity_type="client",
        entity_id=party.id,
        before=before,
        after=after,
    )
    session.commit()

    contact_count = _contact_counts(session, [party.id]).get(party.id, 0)
    return {
        "ok": True,
        "changed": before != after,
        "row": _client_row(
            profile,
            party,
            org,
            _party_name(session, profile.commission_party_id),
            contact_count,
        ),
    }


# --------------------------------------------------------------- contacts


def _add_contact_row(
    session: Session, client_party_id: uuid.UUID, name: str, email: str | None
) -> dict:
    """Create a person party (via the choke point) + a cc_recipient
    affiliation to the client org. No commit (the caller owns the txn)."""
    person = create_person_party(
        session, name=name, slug=_allocate_slug(session, name), email=email
    )
    aff = PartyAffiliation(
        person_party_id=person.id,
        org_party_id=client_party_id,
        role=CONTACT_CREATE_ROLE,
        started_on=date.today(),
    )
    session.add(aff)
    session.flush()
    return _contact_dto(aff, person)


def add_contact(session: Session, actor, party_id: uuid.UUID, payload: dict) -> dict:
    """Add a contact (a person cc'd on the client) to one of this tenant's
    clients. Owner-only, writable-books, tenant-scoped, audited."""
    require_owner(actor, "Only an owner can add contacts.")
    require_writable_books(session, actor)
    tenant_id = require_tenant(actor)

    if _get_profile(session, tenant_id, party_id) is None:
        raise ServiceError(404, "CLIENT_NOT_FOUND", "Client not found.")

    name = _require_name(payload.get("name"))
    email = _clean_email(payload.get("email"), required=True)
    contact = _add_contact_row(session, party_id, name, email)

    write_audit(
        session,
        actor,
        action="clients.add_contact",
        entity_type="client",
        entity_id=party_id,
        after={"contact": contact},
    )
    session.commit()
    return {"ok": True, "contact": contact}


def remove_contact(session: Session, actor, affiliation_id: uuid.UUID) -> dict:
    """END a contact's link to a client (append-only: stamp ``ended_on``,
    never hard-delete — parties/affiliations are history). Scoped: the
    affiliation's org must be a client of the acting tenant. Owner-only,
    writable-books, audited."""
    require_owner(actor, "Only an owner can remove contacts.")
    require_writable_books(session, actor)
    tenant_id = require_tenant(actor)

    aff = session.get(PartyAffiliation, affiliation_id)
    if (
        aff is None
        or aff.ended_on is not None
        or _get_profile(session, tenant_id, aff.org_party_id) is None
    ):
        raise ServiceError(404, "CONTACT_NOT_FOUND", "Contact link not found.")

    before = {
        "affiliation_id": str(aff.id),
        "person_party_id": str(aff.person_party_id),
        "org_party_id": str(aff.org_party_id),
        "role": aff.role,
    }
    aff.ended_on = date.today()
    write_audit(
        session,
        actor,
        action="clients.remove_contact",
        entity_type="client",
        entity_id=aff.org_party_id,
        before=before,
    )
    session.commit()
    return {"ok": True, "ended": str(affiliation_id)}
