"""T-ENTITY suite: rename (FK display — no propagation layer), move
(collision -> merge guidance), first-class merge at all three levels
incl. the recursive same-name collision case (T-ENTITY-013 — the
PlusROI/Palms incident), invoiced-row snapshot safety (judgment #9),
delete-empty guards, permission gates, and true aggregate counts."""

import pytest
from sqlalchemy import select

from app.models import CcExpenseBatch, CcExpenseLine, Engagement, Invoice, Party, Project
from app.services.entities.manage import (
    delete_client,
    delete_operator,
    delete_project,
    manage_payload,
    merge_client,
    merge_operator,
    merge_project,
    move_project,
    rename_client,
    rename_operator,
    rename_project,
)
from app.services.errors import ServiceError
from tests.entities_manage.conftest import add_client, add_entry, add_project


def err(fn, *args, **kwargs) -> ServiceError:
    with pytest.raises(ServiceError) as exc_info:
        fn(*args, **kwargs)
    return exc_info.value


# ------------------------------------------------------------- payload


def test_t_entity_015_manage_counts_are_true_aggregates(g, rian):
    payload = manage_payload(g.session, rian)
    site = next(p for p in payload["projects"] if p["name"] == "Site Rebuild")
    assert site["entries"] == 2  # adi row + locked row
    assert site["operator"] == "PlusROI" and site["client"] == "Brentwood"
    plusroi = next(o for o in payload["operators"] if o["name"] == "PlusROI")
    assert plusroi["clients"] == 1 and plusroi["entries"] == 2
    assert payload["can_manage"] is True


def test_manage_income_owner_only(g, rian, gary):
    g.projects.site.income = 1200
    g.projects.site.income_currency = "CAD"
    g.session.commit()
    owner = manage_payload(g.session, rian)
    site = next(p for p in owner["projects"] if p["name"] == "Site Rebuild")
    assert site["income"] in ("1200", "1200.00")  # Decimal repr differs per driver
    worker = manage_payload(g.session, gary)
    site_w = next(p for p in worker["projects"] if p["name"] == "Site Rebuild")
    assert "income" not in site_w
    assert worker["can_manage"] is False


# ------------------------------------------------------------- rename


def test_t_entity_002_rename_operator_fk_display(g, rian):
    from app.services.reporting.entries import list_entries
    from app.services.reporting.filters import EntryFilters

    result = rename_operator(g.session, rian, g.parties.plusroi.id, "PlusROI Inc")
    assert result["changed"] is True
    page = list_entries(g.session, rian, EntryFilters(q="adi row"))
    assert page.rows[0]["operator"] == "PlusROI Inc"  # FK reach, no rewrite


def test_t_entity_001_rename_collisions_and_no_change(g, rian):
    # same name -> no-change info
    result = rename_operator(g.session, rian, g.parties.plusroi.id, "PlusROI")
    assert result["changed"] is False
    assert result["info"] == "No change — name was already that."
    # case-insensitive sibling collision
    error = err(rename_operator, g.session, rian, g.parties.plusroi.id, "bowden works")
    assert error.summary == 'Another operator already has the name "bowden works".'
    # client collision under the same operator
    other = add_client(g, g.parties.plusroi, "Second Client", "second-client")
    error = err(
        rename_client, g.session, rian, g.parties.plusroi.id, other.id, "BRENTWOOD"
    )
    assert error.summary == (
        'Another client under this operator already has the name "BRENTWOOD".'
    )
    # project collision under the same client
    extra = add_project(g, g.parties.plusroi, g.parties.brentwood, "Other Project")
    error = err(rename_project, g.session, rian, extra.id, "site rebuild")
    assert error.summary == (
        'Another project under this client already has the name "site rebuild".'
    )


def test_t_entity_004_worker_role_refused_manager_ok(g, adi, gary):
    error = err(rename_operator, g.session, gary, g.parties.plusroi.id, "X")
    assert error.status == 403
    assert error.summary == "You do not have permission to rename entities."
    result = rename_operator(g.session, adi, g.parties.plusroi.id, "PlusROI Digital")
    assert result["changed"] is True


# ------------------------------------------------------------- move


def test_t_entity_005_move_collision_gets_merge_guidance(g, rian):
    add_project(g, g.parties.bw, g.parties.internal, "Site Rebuild")
    error = err(
        move_project, g.session, rian, g.projects.site.id,
        g.parties.bw.id, g.parties.internal.id,
    )
    assert error.code == "MERGE_INSTEAD"
    assert "Merge the two projects instead" in error.summary


def test_t_entity_006_move_cross_operator(g, rian):
    from app.services.reporting.entries import list_entries
    from app.services.reporting.filters import EntryFilters

    g.projects.site.income = 500
    g.projects.site.income_currency = "CAD"
    g.session.commit()
    result = move_project(
        g.session, rian, g.projects.site.id, g.parties.bw.id, g.parties.internal.id
    )
    assert result["changed"] is True
    assert g.projects.site.operator_party_id == g.parties.bw.id
    assert g.projects.site.client_party_id == g.parties.internal.id
    assert str(g.projects.site.income) in ("500", "500.00")  # keeps income
    # entries follow the FK — display refreshes (incl. the invoiced row)
    page = list_entries(g.session, rian, EntryFilters(q="locked row"))
    assert page.rows[0]["operator"] == "Bowden Works"
    assert page.rows[0]["client"] == "Internal"
    # invoices are NOT touched by a move
    assert g.invoice.to_party_id is None


# ------------------------------------------------------------- merge: project


def test_merge_project_repoints_children_and_deletes_husk(g, rian):
    from app.models import RateOverride

    source = g.projects.site
    target = g.projects.admin
    batch = CcExpenseBatch(tenant_id=g.tenant.id, name="June card", currency="CAD")
    g.session.add(batch)
    g.session.flush()
    cc = CcExpenseLine(batch_id=batch.id, line_index=0, raw_line="x", project_id=source.id)
    g.session.add(cc)
    g.session.commit()

    result = merge_project(g.session, rian, source.id, target.id)
    assert result["entries"] == 2 and result["cc_lines"] == 1
    assert g.session.get(Project, source.id) is None  # husk deleted
    assert cc.batch_id == batch.id and cc.project_id == target.id
    # the source's overrides are DROPPED, not merged (parity)
    assert (
        g.session.scalar(select(RateOverride.id).where(RateOverride.project_id == target.id))
        is None
    )


def test_merge_project_invoiced_rows_snapshot_safe(g, rian):
    """Judgment #9: the invoice line keeps its attach-time snapshot;
    the entry re-points and stays locked."""
    locked = g.entries["locked"]
    merge_project(g.session, rian, g.projects.site.id, g.projects.admin.id)
    assert locked.project_id == g.projects.admin.id
    assert locked.invoice_line_id == g.line.id  # still locked
    assert g.line.snapshot_client_name == "Brentwood"  # snapshot untouched
    assert g.line.snapshot_project_name == "Site Rebuild"


def test_t_entity_011_self_merge_refused(g, rian):
    error = err(merge_project, g.session, rian, g.projects.site.id, g.projects.site.id)
    assert error.summary == "Source and target are the same project — can't merge into self."


# ------------------------------------------------------------- merge: client


def test_t_entity_013_palms_incident_recursive_client_merge(g, rian):
    """Two clients with same-named projects merge WITHOUT a unique-
    constraint error: colliding projects merge recursively, the rest
    re-parent, the husk retires (fixes legacy B47)."""
    palms_pr = add_client(g, g.parties.plusroi, "The Palms", "palms-pr")
    palms_bw = add_client(g, g.parties.bw, "The Palms", "palms-bw")
    landing_pr = add_project(g, g.parties.plusroi, palms_pr, "Landing Page")
    landing_bw = add_project(g, g.parties.bw, palms_bw, "Landing Page")
    unique_pr = add_project(g, g.parties.plusroi, palms_pr, "SEO Audit")
    e1 = add_entry(g, landing_pr, "palms landing work")
    e2 = add_entry(g, unique_pr, "palms seo work")
    invoice = Invoice(tenant_id=g.tenant.id, name="Palms INV", status="open",
                      currency="CAD", to_party_id=palms_pr.id)
    g.session.add(invoice)
    g.session.commit()

    result = merge_client(
        g.session, rian,
        g.parties.plusroi.id, palms_pr.id, g.parties.bw.id, palms_bw.id,
    )
    assert result["projects_merged"] == 1
    assert result["projects_moved"] == 1
    # colliding project merged into the BW-side row; unique one re-parented
    assert g.session.get(Project, landing_pr.id) is None
    assert e1.project_id == landing_bw.id
    assert unique_pr.client_party_id == palms_bw.id
    assert unique_pr.operator_party_id == g.parties.bw.id
    assert e2.project_id == unique_pr.id
    # the source edge ended; the husk party retired; invoices re-pointed
    open_edges = g.session.scalars(
        select(Engagement).where(
            Engagement.type == "direct_client",
            Engagement.party_b_id == palms_pr.id,
            Engagement.ended_on.is_(None),
        )
    ).all()
    assert open_edges == []
    assert result["retired_party"] is True
    assert g.session.get(Party, palms_pr.id).is_active is False
    assert invoice.to_party_id == palms_bw.id  # T-ENTITY-009


def test_merge_client_same_edge_refused(g, rian):
    error = err(
        merge_client, g.session, rian,
        g.parties.plusroi.id, g.parties.brentwood.id,
        g.parties.plusroi.id, g.parties.brentwood.id,
    )
    assert error.summary == "Source and target are the same client — can't merge into self."


# ------------------------------------------------------------- merge: operator


def test_t_entity_010_merge_operator(g, rian):
    """PlusROI merges into Bowden Works: Brentwood re-parents (its
    projects keep their client), invoices re-point, husk retires."""
    invoice = Invoice(tenant_id=g.tenant.id, name="From PlusROI", status="open",
                      currency="CAD", from_party_id=g.parties.plusroi.id)
    g.session.add(invoice)
    g.session.commit()

    result = merge_operator(g.session, rian, g.parties.plusroi.id, g.parties.bw.id)
    assert result["clients_moved"] == 1
    assert g.projects.site.operator_party_id == g.parties.bw.id
    assert g.projects.site.client_party_id == g.parties.brentwood.id  # client kept
    # Brentwood now hangs under BW via an active edge
    edge = g.session.scalar(
        select(Engagement.id).where(
            Engagement.type == "direct_client",
            Engagement.party_a_id == g.parties.bw.id,
            Engagement.party_b_id == g.parties.brentwood.id,
            Engagement.ended_on.is_(None),
        )
    )
    assert edge is not None
    assert result["retired_party"] is True
    assert invoice.from_party_id == g.parties.bw.id
    assert g.session.get(Party, g.parties.plusroi.id).is_active is False


def test_merge_operator_with_same_named_clients_recurses(g, rian):
    """The full recursion: source operator's client shares a name with
    a target-side client (different party) — client merge fires, and
    same-named projects inside THAT merge too."""
    dup_internal = add_client(g, g.parties.plusroi, "Internal", "internal-pr")
    admin_pr = add_project(g, g.parties.plusroi, dup_internal, "Admin")
    e = add_entry(g, admin_pr, "dup internal admin work")
    g.session.commit()

    result = merge_operator(g.session, rian, g.parties.plusroi.id, g.parties.bw.id)
    assert result["clients_merged"] == 1  # Internal(pr) -> Internal(bw)
    assert result["clients_moved"] == 1  # Brentwood re-parented
    assert g.session.get(Project, admin_pr.id) is None  # merged into BW Admin
    assert e.project_id == g.projects.admin.id


def test_merge_operator_tenant_party_guard(g, rian):
    error = err(merge_operator, g.session, rian, g.parties.bw.id, g.parties.plusroi.id)
    assert error.code == "TENANT_PARTY"


# ------------------------------------------------------------- delete


def test_t_entity_012_delete_empty_and_guards(g, rian):
    empty = add_project(g, g.parties.plusroi, g.parties.brentwood, "Empty Project")
    g.session.commit()
    result = delete_project(g.session, rian, empty.id)
    assert result["info"] == 'Deleted empty project "Empty Project".'

    error = err(delete_project, g.session, rian, g.projects.site.id)
    assert error.code == "HAS_CHILDREN"
    assert "2 time entries" in error.summary

    # a project with 0 entries but >0 CC lines still requires a merge
    cc_only = add_project(g, g.parties.plusroi, g.parties.brentwood, "CC Only")
    batch = CcExpenseBatch(tenant_id=g.tenant.id, name="card", currency="CAD")
    g.session.add(batch)
    g.session.flush()
    g.session.add(
        CcExpenseLine(batch_id=batch.id, line_index=0, raw_line="x", project_id=cc_only.id)
    )
    g.session.commit()
    error = err(delete_project, g.session, rian, cc_only.id)
    assert error.code == "HAS_CHILDREN"
    assert "1 CC expense line" in error.summary


def test_delete_client_and_operator_guards(g, rian):
    error = err(delete_client, g.session, rian, g.parties.plusroi.id, g.parties.brentwood.id)
    assert error.code == "HAS_CHILDREN"
    error = err(delete_operator, g.session, rian, g.parties.plusroi.id)
    assert error.code == "HAS_CHILDREN"

    empty_client = add_client(g, g.parties.plusroi, "Ghost Client", "ghost-client")
    g.session.commit()
    result = delete_client(g.session, rian, g.parties.plusroi.id, empty_client.id)
    assert result["info"] == 'Deleted empty client "Ghost Client".'
    assert g.session.get(Party, empty_client.id).is_active is False


def test_t_entity_014_merge_is_transactional(g, rian, monkeypatch):
    """A failure mid-merge leaves NO partial state (single
    transaction — replaces the legacy sequential PostgREST calls)."""
    from app.services.entities import manage as manage_mod

    original = manage_mod._retire_party_if_unused

    def boom(*args, **kwargs):
        raise RuntimeError("simulated mid-merge failure")

    monkeypatch.setattr(manage_mod, "_retire_party_if_unused", boom)
    palms_pr = add_client(g, g.parties.plusroi, "The Palms", "palms-pr2")
    landing = add_project(g, g.parties.plusroi, palms_pr, "Landing Page")
    e = add_entry(g, landing, "tx test work")
    g.session.commit()
    landing_id, entry_id = landing.id, e.id

    with pytest.raises(RuntimeError):
        merge_client(
            g.session, rian,
            g.parties.plusroi.id, palms_pr.id, g.parties.plusroi.id, g.parties.brentwood.id,
        )
    g.session.rollback()
    monkeypatch.setattr(manage_mod, "_retire_party_if_unused", original)
    # nothing moved: the project still exists under the source client
    survivor = g.session.get(Project, landing_id)
    assert survivor is not None and survivor.client_party_id == palms_pr.id
    refreshed = g.session.execute(
        select(Project.id).where(Project.id == landing_id)
    ).first()
    assert refreshed is not None
    assert g.session.get(type(e), entry_id).project_id == landing_id
