"""
Records <-> Sales <-> Cemetery Map auto-sync (RecordSalesSyncService).

A plot's linked Record must track the plot's own status as it moves through
the sales pipeline: On Hold -> Pre-need, Reserved -> Reserved,
Interred/Occupied -> Interred, Available -> no record (unlinked/downgraded,
never deleted). Only a real sale (proposal sent, contract signed/paid) may
fabricate the placeholder record; a bare manual plot-status edit never does.
"""
from __future__ import annotations

from decimal import Decimal

import pytest
from httpx import AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from src.apps.plots.models.plot import Plot
from src.apps.records.models.record import Record
from src.apps.records.services.record_sales_sync_service import RecordSalesSyncService
from src.apps.sections.models.section import Section

PROPOSALS_URL = "/api/v1/sales/proposals"
CONTRACTS_URL = "/api/v1/sales/contracts"
INVOICES_URL = "/api/v1/billing/invoices"
OPPORTUNITIES_URL = "/api/v1/sales/opportunities"
PLOTS_URL = "/api/v1/plots"

FAKE_SIG = (
    "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk"
    "+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
)


def _headers(token: str, tenant_id) -> dict:
    return {"Authorization": f"Bearer {token}", "X-Tenant-ID": str(tenant_id)}


async def _make_plot(db: AsyncSession, account, ref="A-1", status="vacant") -> Plot:
    section = Section(tenant_id=account.id, code=ref[0], name=f"Section {ref[0]}")
    db.add(section)
    await db.flush()
    plot = Plot(tenant_id=account.id, plot_ref=ref, section_id=section.id, status=status)
    db.add(plot)
    await db.flush()
    return plot


async def _plot(db: AsyncSession, plot_id) -> Plot:
    result = await db.execute(select(Plot).where(Plot.id == plot_id))
    return result.scalar_one()


async def _record_for_plot(db: AsyncSession, plot_id) -> Record | None:
    result = await db.execute(
        select(Record).where(Record.plot_id == plot_id, Record.deleted_at.is_(None))
    )
    return result.scalar_one_or_none()


async def _create_opportunity(client, token, tenant_id, family_name="Smith Family") -> dict:
    resp = await client.post(
        OPPORTUNITIES_URL,
        json={"family_name": family_name},
        headers=_headers(token, tenant_id),
    )
    assert resp.status_code == 201, resp.text
    return resp.json()["data"]


async def _create_and_send_proposal(
    client, token, tenant_id, plot_id, opportunity_id=None
) -> dict:
    payload = {
        "to_email": "family@example.ca",
        "subject": "Proposal",
        "cover_note": "Please review.",
        "plot_id": str(plot_id),
        "line_items": [
            {"description": "Burial plot", "quantity": 1, "unit_price": "2500.00"},
        ],
    }
    if opportunity_id:
        payload["opportunity_id"] = str(opportunity_id)
    resp = await client.post(PROPOSALS_URL, json=payload, headers=_headers(token, tenant_id))
    assert resp.status_code == 201, resp.text
    proposal = resp.json()["data"]
    send_resp = await client.post(
        f"{PROPOSALS_URL}/{proposal['id']}/send", headers=_headers(token, tenant_id)
    )
    assert send_resp.status_code == 200, send_resp.text
    return proposal


async def _create_contract(
    client, token, tenant_id, plot_id, *, unit_price="2000.00", opportunity_id=None
) -> dict:
    payload = {
        "plot_id": str(plot_id),
        "contract_type": "pre_need",
        "purchaser_name": "Mary McLeod",
        "purchaser_email": "buyer@example.ca",
        "payment_plan_type": "full",
        "line_items": [
            {"description": "Burial plot", "quantity": 1, "unit_price": unit_price},
        ],
    }
    if opportunity_id:
        payload["opportunity_id"] = str(opportunity_id)
    resp = await client.post(CONTRACTS_URL, json=payload, headers=_headers(token, tenant_id))
    assert resp.status_code == 201, resp.text
    return resp.json()["data"]


async def _issue_contract(client, token, tenant_id, contract_id) -> dict:
    resp = await client.patch(
        f"{CONTRACTS_URL}/{contract_id}/issue", headers=_headers(token, tenant_id)
    )
    assert resp.status_code == 200, resp.text
    return resp.json()["data"]


async def _sign_contract(client, token, tenant_id, contract_id) -> None:
    resp = await client.post(
        f"{CONTRACTS_URL}/{contract_id}/sign",
        json={"purchaser_signature_b64": FAKE_SIG},
        headers=_headers(token, tenant_id),
    )
    assert resp.status_code == 200, resp.text


async def _pay_invoice(client, token, tenant_id, invoice_id, amount) -> None:
    resp = await client.post(
        f"{INVOICES_URL}/{invoice_id}/payment",
        json={"amount": str(amount), "payment_date": "2026-08-25", "payment_method": "Cash"},
        headers=_headers(token, tenant_id),
    )
    assert resp.status_code == 201, resp.text


# ── Proposal sent -> Pre-need placeholder record created ────────────────────

@pytest.mark.asyncio
async def test_proposal_sent_creates_pre_need_record_with_family_name(
    client: AsyncClient, admin_token: str, test_account, db_session: AsyncSession
):
    plot = await _make_plot(db_session, test_account)
    opp = await _create_opportunity(client, admin_token, test_account.id, family_name="Nguyen Family")
    await _create_and_send_proposal(client, admin_token, test_account.id, plot.id, opp["id"])

    record = await _record_for_plot(db_session, plot.id)
    assert record is not None
    assert record.status == "pre_need"
    assert record.first_name == "Reserved"
    assert record.last_name == "Nguyen Family"
    assert record.auto_generated is True


@pytest.mark.asyncio
async def test_proposal_sent_without_family_name_falls_back_to_unknown(
    client: AsyncClient, admin_token: str, test_account, db_session: AsyncSession
):
    plot = await _make_plot(db_session, test_account)
    # No linked opportunity at all — send() has nothing to hint a name from.
    await _create_and_send_proposal(client, admin_token, test_account.id, plot.id)

    record = await _record_for_plot(db_session, plot.id)
    assert record is not None
    assert record.status == "pre_need"
    assert record.last_name == "Unknown"


# ── Idempotency — repeated syncs never duplicate or overwrite ───────────────

@pytest.mark.asyncio
async def test_sync_for_plot_is_idempotent(db_session: AsyncSession, test_account):
    plot = await _make_plot(db_session, test_account, status="hold")

    await RecordSalesSyncService.sync_for_plot(
        db_session, test_account.id, plot, allow_create=True, family_name_hint="Original"
    )
    await db_session.flush()
    first = await _record_for_plot(db_session, plot.id)
    assert first is not None
    first_id = first.id

    # Re-running with a different hint must not create a second record or
    # touch the name of the one that already exists.
    await RecordSalesSyncService.sync_for_plot(
        db_session, test_account.id, plot, allow_create=True, family_name_hint="Different"
    )
    await db_session.flush()

    result = await db_session.execute(
        select(Record).where(Record.plot_id == plot.id, Record.deleted_at.is_(None))
    )
    records = result.scalars().all()
    assert len(records) == 1
    assert records[0].id == first_id
    assert records[0].last_name == "Original"


# ── Payment thresholds drive both plot and record status ────────────────────

@pytest.mark.asyncio
async def test_50_percent_payment_promotes_record_to_reserved(
    client: AsyncClient, admin_token: str, test_account, db_session: AsyncSession
):
    plot = await _make_plot(db_session, test_account)
    opp = await _create_opportunity(client, admin_token, test_account.id)
    await _create_and_send_proposal(client, admin_token, test_account.id, plot.id, opp["id"])
    contract = await _create_contract(
        client, admin_token, test_account.id, plot.id, unit_price="2000.00", opportunity_id=opp["id"]
    )
    issued = await _issue_contract(client, admin_token, test_account.id, contract["id"])
    await _sign_contract(client, admin_token, test_account.id, contract["id"])

    invoice = issued["invoices_created"][0]
    half = (Decimal(str(invoice["amount"])) / 2).quantize(Decimal("0.01"))
    await _pay_invoice(client, admin_token, test_account.id, invoice["id"], half)

    assert (await _plot(db_session, plot.id)).status == "reserved"
    record = await _record_for_plot(db_session, plot.id)
    assert record is not None
    assert record.status == "reserved"


@pytest.mark.asyncio
async def test_full_payment_promotes_record_to_interred(
    client: AsyncClient, admin_token: str, test_account, db_session: AsyncSession
):
    plot = await _make_plot(db_session, test_account)
    opp = await _create_opportunity(client, admin_token, test_account.id)
    await _create_and_send_proposal(client, admin_token, test_account.id, plot.id, opp["id"])
    contract = await _create_contract(
        client, admin_token, test_account.id, plot.id, unit_price="1000.00", opportunity_id=opp["id"]
    )
    issued = await _issue_contract(client, admin_token, test_account.id, contract["id"])
    await _sign_contract(client, admin_token, test_account.id, contract["id"])

    invoice = issued["invoices_created"][0]
    await _pay_invoice(client, admin_token, test_account.id, invoice["id"], invoice["amount"])

    assert (await _plot(db_session, plot.id)).status == "occupied"
    record = await _record_for_plot(db_session, plot.id)
    assert record is not None
    assert record.status == "interred"


# ── Marking an opportunity Lost always releases the plot + record ──────────
# (per the product decision: unlike the pre-existing "only from hold" plot
# guard, losing a deal now releases the plot/record regardless of how much
# was paid — see StageService._release_plot_and_record.)

@pytest.mark.asyncio
async def test_mark_lost_from_hold_releases_plot_and_downgrades_record(
    client: AsyncClient, admin_token: str, test_account, db_session: AsyncSession
):
    plot = await _make_plot(db_session, test_account)
    opp = await _create_opportunity(client, admin_token, test_account.id)
    await _create_and_send_proposal(client, admin_token, test_account.id, plot.id, opp["id"])

    resp = await client.post(
        f"{OPPORTUNITIES_URL}/{opp['id']}/mark-lost",
        json={"loss_reason": "other"},
        headers=_headers(admin_token, test_account.id),
    )
    assert resp.status_code == 200, resp.text

    assert (await _plot(db_session, plot.id)).status == "vacant"
    record = await _record_for_plot(db_session, plot.id)
    assert record is None  # unlinked — plot_id cleared, not deleted
    # Confirm it was unlinked+downgraded, not soft-deleted.
    all_records = (
        await db_session.execute(select(Record).where(Record.tenant_id == test_account.id))
    ).scalars().all()
    assert len(all_records) == 1
    assert all_records[0].plot_id is None
    assert all_records[0].status == "draft"
    assert all_records[0].deleted_at is None


@pytest.mark.asyncio
async def test_mark_lost_after_reserved_still_releases_plot_and_record(
    client: AsyncClient, admin_token: str, test_account, db_session: AsyncSession
):
    """The newly-relaxed rule: previously mark_lost only released a plot
    still at 'hold'; now it releases even once Reserved (payment received)."""
    plot = await _make_plot(db_session, test_account)
    opp = await _create_opportunity(client, admin_token, test_account.id)
    await _create_and_send_proposal(client, admin_token, test_account.id, plot.id, opp["id"])
    contract = await _create_contract(
        client, admin_token, test_account.id, plot.id, unit_price="2000.00", opportunity_id=opp["id"]
    )
    issued = await _issue_contract(client, admin_token, test_account.id, contract["id"])
    await _sign_contract(client, admin_token, test_account.id, contract["id"])
    invoice = issued["invoices_created"][0]
    half = (Decimal(str(invoice["amount"])) / 2).quantize(Decimal("0.01"))
    await _pay_invoice(client, admin_token, test_account.id, invoice["id"], half)
    assert (await _plot(db_session, plot.id)).status == "reserved"

    resp = await client.post(
        f"{OPPORTUNITIES_URL}/{opp['id']}/mark-lost",
        json={"loss_reason": "other"},
        headers=_headers(admin_token, test_account.id),
    )
    assert resp.status_code == 200, resp.text

    assert (await _plot(db_session, plot.id)).status == "vacant"
    assert await _record_for_plot(db_session, plot.id) is None


@pytest.mark.asyncio
async def test_mark_lost_via_dedicated_stage_endpoint_also_releases(
    client: AsyncClient, admin_token: str, test_account, db_session: AsyncSession
):
    """PATCH /opportunities/{id}/stage (StageService.transition_stage) is a
    second entry point that can reach 'lost' — it must release the plot the
    same way the dedicated mark-lost endpoint does."""
    plot = await _make_plot(db_session, test_account)
    opp = await _create_opportunity(client, admin_token, test_account.id)
    await _create_and_send_proposal(client, admin_token, test_account.id, plot.id, opp["id"])

    resp = await client.patch(
        f"{OPPORTUNITIES_URL}/{opp['id']}/stage",
        json={"stage": "lost"},
        headers=_headers(admin_token, test_account.id),
    )
    assert resp.status_code == 200, resp.text

    assert (await _plot(db_session, plot.id)).status == "vacant"
    assert await _record_for_plot(db_session, plot.id) is None


@pytest.mark.asyncio
async def test_mark_lost_via_generic_update_endpoint_also_releases(
    client: AsyncClient, admin_token: str, test_account, db_session: AsyncSession
):
    """PATCH /opportunities/{id} (OpportunityService.update) is a third entry
    point that can reach 'lost' — same release must fire from there too."""
    plot = await _make_plot(db_session, test_account)
    opp = await _create_opportunity(client, admin_token, test_account.id)
    await _create_and_send_proposal(client, admin_token, test_account.id, plot.id, opp["id"])

    resp = await client.patch(
        f"{OPPORTUNITIES_URL}/{opp['id']}",
        json={"stage": "lost"},
        headers=_headers(admin_token, test_account.id),
    )
    assert resp.status_code == 200, resp.text

    assert (await _plot(db_session, plot.id)).status == "vacant"
    assert await _record_for_plot(db_session, plot.id) is None


@pytest.mark.asyncio
async def test_mark_lost_attempted_after_occupied_is_rejected_and_untouched(
    client: AsyncClient, admin_token: str, test_account, db_session: AsyncSession
):
    """fully_paid/occupied is a terminal stage not in LOST_ELIGIBLE_STAGES —
    confirms the occupied-precedence guard is unreachable via the normal API
    surface (the plot/record are protected at the stage-validation layer)."""
    plot = await _make_plot(db_session, test_account)
    opp = await _create_opportunity(client, admin_token, test_account.id)
    await _create_and_send_proposal(client, admin_token, test_account.id, plot.id, opp["id"])
    contract = await _create_contract(
        client, admin_token, test_account.id, plot.id, unit_price="1000.00", opportunity_id=opp["id"]
    )
    issued = await _issue_contract(client, admin_token, test_account.id, contract["id"])
    await _sign_contract(client, admin_token, test_account.id, contract["id"])
    invoice = issued["invoices_created"][0]
    await _pay_invoice(client, admin_token, test_account.id, invoice["id"], invoice["amount"])
    assert (await _plot(db_session, plot.id)).status == "occupied"

    resp = await client.post(
        f"{OPPORTUNITIES_URL}/{opp['id']}/mark-lost",
        json={"loss_reason": "other"},
        headers=_headers(admin_token, test_account.id),
    )
    assert resp.status_code == 422, resp.text

    assert (await _plot(db_session, plot.id)).status == "occupied"
    record = await _record_for_plot(db_session, plot.id)
    assert record is not None
    assert record.status == "interred"


# ── Manual plot-status edits (Cemetery Map) never fabricate a placeholder ──

@pytest.mark.asyncio
async def test_manual_plot_status_change_with_no_sale_creates_no_record(
    client: AsyncClient, admin_token: str, test_account, db_session: AsyncSession
):
    plot = await _make_plot(db_session, test_account)

    resp = await client.patch(
        f"{PLOTS_URL}/{plot.id}",
        json={"status": "reserved"},
        headers=_headers(admin_token, test_account.id),
    )
    assert resp.status_code == 200, resp.text

    assert (await _plot(db_session, plot.id)).status == "reserved"
    assert await _record_for_plot(db_session, plot.id) is None


@pytest.mark.asyncio
async def test_manual_flip_to_occupied_with_no_record_warns_and_creates_nothing(
    client: AsyncClient, admin_token: str, test_account, db_session: AsyncSession
):
    plot = await _make_plot(db_session, test_account)

    resp = await client.patch(
        f"{PLOTS_URL}/{plot.id}",
        json={"status": "occupied"},
        headers=_headers(admin_token, test_account.id),
    )
    assert resp.status_code == 200, resp.text
    warnings = resp.json()["data"].get("warnings") or []
    assert any("no linked record" in w for w in warnings)

    assert await _record_for_plot(db_session, plot.id) is None


@pytest.mark.asyncio
async def test_revert_to_default_unlinks_and_downgrades_linked_record(
    client: AsyncClient, admin_token: str, test_account, db_session: AsyncSession
):
    plot = await _make_plot(db_session, test_account)
    opp = await _create_opportunity(client, admin_token, test_account.id)
    await _create_and_send_proposal(client, admin_token, test_account.id, plot.id, opp["id"])
    assert await _record_for_plot(db_session, plot.id) is not None

    resp = await client.patch(
        f"{PLOTS_URL}/{plot.id}/revert",
        headers=_headers(admin_token, test_account.id),
    )
    assert resp.status_code == 200, resp.text

    assert (await _plot(db_session, plot.id)).status == "vacant"
    assert await _record_for_plot(db_session, plot.id) is None


# ── Map effective status derives from the linked record's own status ───────
# (regression test for the pre-existing bug where ANY linked record forced
# a plot to render as 'occupied' on the map, which this feature would
# otherwise immediately expose for every Pre-need/Reserved plot.)

@pytest.mark.asyncio
async def test_map_shows_hold_not_occupied_for_a_pre_need_linked_plot(
    client: AsyncClient, admin_token: str, test_account, db_session: AsyncSession
):
    plot = await _make_plot(db_session, test_account)
    opp = await _create_opportunity(client, admin_token, test_account.id)
    await _create_and_send_proposal(client, admin_token, test_account.id, plot.id, opp["id"])

    resp = await client.get(f"{PLOTS_URL}/map", headers=_headers(admin_token, test_account.id))
    assert resp.status_code == 200, resp.text
    features = resp.json()["data"]["plots"]["features"]
    match = next(f for f in features if f["properties"]["id"] == str(plot.id))
    assert match["properties"]["status"] == "hold"
