"""Fixtures for the Hub v0 cross-tenant read surface.

A small two-tenant ORM graph (Bowden Works + PlusROI) with shared
entities (Copernic is a client of BOTH tenants — the cross-tenant case),
plus service-level fixtures (a self-contained SQLite session + SimpleName-
space actors) and HTTP-level fixtures (a seeded engine patched into
``app.db`` + signed-session TestClients) so the same graph proves both the
counts and the super-admin gate.
"""

from __future__ import annotations

from datetime import date, datetime
from types import SimpleNamespace

import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool

from app.main import create_app
from app.models import (
    Base,
    ClientProfile,
    Engagement,
    Invoice,
    InvoiceLine,
    Organization,
    Party,
    PartyAffiliation,
    Project,
    Tenant,
    TenantVendor,
    TimeEntry,
    User,
)
from app.services.auth import session as auth_session

HUB_ENDPOINTS = ("projects", "organizations", "people", "tenants", "accounts")


def _party(s: Session, kind: str, name: str, slug: str, **fields) -> Party:
    p = Party(kind=kind, name=name, slug=slug, **fields)
    s.add(p)
    s.flush()
    return p


def seed_graph(s: Session) -> SimpleNamespace:
    """The reference graph. Counts asserted by the tests are derived from
    exactly these rows."""
    # --- tenants ---------------------------------------------------------
    bw_party = _party(s, "org", "Bowden Works", "bowden-works")
    bw = Tenant(party_id=bw_party.id, settings={})  # no books_owner => None
    s.add(bw)
    plus_party = _party(s, "org", "PlusROI", "plusroi")
    plus = Tenant(
        party_id=plus_party.id, settings={"books_owner": "app", "parallel_run": False}
    )
    s.add(plus)
    s.flush()

    # --- client orgs (non-tenant) ---------------------------------------
    # Copernic carries the full set of party + org-detail fields — proves
    # the hub surfaces them. Acme deliberately gets NO `organizations` row
    # (an invariant violation the LEFT JOIN must survive, nulls not a crash).
    copernic = _party(
        s,
        "org",
        "Copernic",
        "copernic",
        email="hello@copernic.com",
        phone="555-0100",
        address="1 Copernic Way",
        notes="client since 2024",
    )  # client of BOTH tenants
    s.add(
        Organization(
            party_id=copernic.id,
            legal_name="Copernic Inc.",
            currency="CAD",
            drive_folder_url="https://drive.example/copernic",
            mosiah_folder="/mosiah/copernic",
        )
    )
    s.flush()
    acme = _party(s, "org", "Acme", "acme")

    # --- people ----------------------------------------------------------
    rian_p = _party(s, "person", "Rian Owner", "rian-owner")
    adi_p = _party(s, "person", "Adi Worker", "adi-worker")
    dani_p = _party(s, "person", "Danielle", "danielle")
    heather_p = _party(s, "person", "Heather", "heather")  # no account

    # --- users (accounts) ------------------------------------------------
    def user(username, person, is_super=False):
        s.add(
            User(
                idauth_username=username,
                email=f"{username}@x.com",
                is_super_admin=is_super,
                person_party_id=person.id,
            )
        )

    user("rian", rian_p, is_super=True)
    user("adi", adi_p)
    user("dani", dani_p)
    s.flush()

    # --- engagements -----------------------------------------------------
    def eng(tenant, etype, a, b, role_a, role_b, ended=None):
        e = Engagement(
            tenant_id=tenant.id,
            type=etype,
            party_a_id=a.id,
            party_b_id=b.id,
            role_a=role_a,
            role_b=role_b,
            ended_on=ended,
        )
        s.add(e)
        s.flush()
        return e

    eng(bw, "official_partnership", bw_party, rian_p, "operator", "owner")
    eng(bw, "subcontract", bw_party, adi_p, "operator", "worker")
    # party_b is an ORG here — must NOT count as a "person member" of BW.
    eng(bw, "direct_client", bw_party, copernic, "operator", "client")
    eng(plus, "subcontract", plus_party, dani_p, "operator", "worker")
    # ENDED — Heather is not a worker and not a member of anything.
    eng(bw, "subcontract", bw_party, heather_p, "operator", "worker", ended=date(2026, 1, 1))

    # --- projects --------------------------------------------------------
    def proj(tenant, operator, client, name, **fields):
        p = Project(
            tenant_id=tenant.id,
            operator_party_id=operator.id,
            client_party_id=client.id,
            name=name,
            **fields,
        )
        s.add(p)
        s.flush()
        return p

    website = proj(bw, bw_party, copernic, "Website", notes="the redesign")
    app_proj = proj(bw, bw_party, acme, "App")
    growth = proj(plus, plus_party, copernic, "Growth")

    # --- invoice + line (for a project's invoice_count) ------------------
    inv = Invoice(tenant_id=bw.id, name="INV-1")
    s.add(inv)
    s.flush()
    line = InvoiceLine(invoice_id=inv.id, kind="time")
    s.add(line)
    s.flush()

    # --- time entries ----------------------------------------------------
    def te(tenant, project, worker, line_id=None):
        t = TimeEntry(
            tenant_id=tenant.id,
            project_id=project.id,
            worker_party_id=worker.id,
            source="manual",
            start_at=datetime(2026, 6, 1, 9, 0),
            invoice_line_id=line_id,
        )
        s.add(t)
        s.flush()
        return t

    te(bw, website, adi_p, line.id)  # invoiced
    te(bw, website, rian_p)
    te(bw, website, adi_p)  # duplicate worker on the same project
    te(plus, growth, dani_p)

    # --- affiliation -----------------------------------------------------
    s.add(
        PartyAffiliation(
            person_party_id=heather_p.id,
            org_party_id=copernic.id,
            role="cc_recipient",
            started_on=date(2026, 1, 1),
        )
    )

    # --- client_profiles: Copernic is a client of BOTH tenants, with
    # DIFFERENT terms each — the hub's expand-a-row drill-down exists to
    # surface exactly this (one org, N tenant-scoped commercial rows). ---
    s.add(
        ClientProfile(
            tenant_id=bw.id,
            client_party_id=copernic.id,
            currency="CAD",
            terms="net30",
            default_commission_pct=None,
            credit_limit=1000,
            contacts="ops@copernic.com | billing@copernic.com",
        )
    )
    s.add(
        ClientProfile(
            tenant_id=plus.id,
            client_party_id=copernic.id,
            currency="USD",
            terms="net15",
            default_commission_pct=10,
            commission_party_id=rian_p.id,
            credit_limit=5000,
            contacts="hello@copernic.com",
            archived=False,
        )
    )

    # --- tenant_vendors: Acme is a listed vendor of Bowden Works ----------
    s.add(
        TenantVendor(
            tenant_id=bw.id,
            vendor_party_id=acme.id,
            default_category="software",
            default_paid_by_party_id=bw_party.id,
        )
    )
    s.flush()

    return SimpleNamespace(
        bw=bw,
        plus=plus,
        copernic=copernic,
        acme=acme,
        rian_p=rian_p,
        adi_p=adi_p,
        dani_p=dani_p,
        heather_p=heather_p,
        website=website,
        app=app_proj,
        growth=growth,
    )


# --------------------------------------------------------------- actors


def super_admin():
    return SimpleNamespace(is_super_admin=True, is_viewing_as=False)


def non_super():
    return SimpleNamespace(is_super_admin=False, is_viewing_as=False)


def viewing_super():
    # A super admin currently viewing-as someone: is_super_admin is the
    # TARGET's flag; here even a super target is blocked because is_viewing_as.
    return SimpleNamespace(is_super_admin=True, is_viewing_as=True)


# --------------------------------------------------------------- service


@pytest.fixture()
def graph():
    """(session, ns) over a fresh in-memory graph for service-level tests."""
    engine = create_engine(
        "sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool
    )
    Base.metadata.create_all(engine)
    with Session(engine) as session:
        ns = seed_graph(session)
        session.commit()
        yield session, ns
    engine.dispose()


# --------------------------------------------------------------- HTTP


@pytest.fixture()
def hub_http(monkeypatch):
    """The seeded graph behind the real app, with a factory that mints
    signed-session TestClients (optionally under view-as)."""
    from app import db as app_db
    from app.services.authz.view_as import VIEW_AS_COOKIE

    engine = create_engine(
        "sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool
    )
    Base.metadata.create_all(engine)
    # expire_on_commit=False: the seeding session closes below (HTTP requests
    # use their own session via app_db.get_engine), but tests still read
    # already-loaded ids off `ns` objects afterward — without this, that
    # access would try to refresh from the closed session and raise
    # DetachedInstanceError.
    with Session(engine, expire_on_commit=False) as session:
        ns = seed_graph(session)
        session.commit()
    monkeypatch.setattr(app_db, "get_engine", lambda: engine)

    clients: list[TestClient] = []

    def make(username: str, is_super: bool, view_as: str | None = None) -> TestClient:
        client = TestClient(create_app(), base_url="https://testserver")
        client.__enter__()
        clients.append(client)
        token = auth_session.mint_session(
            username=username, email=f"{username}@x.com", is_super_admin=is_super
        )
        client.cookies.set(auth_session.SESSION_COOKIE, token)
        if view_as is not None:
            client.cookies.set(VIEW_AS_COOKIE, view_as)
        return client

    yield SimpleNamespace(make=make, ns=ns)
    for client in clients:
        client.__exit__(None, None, None)
    engine.dispose()
