"""
Tests for PATCH /api/v1/sales/opportunities/{id}/stage (INDL-26).
Covers T-01 through T-07 and T-13/14/16/17 from the PRD test matrix.
"""
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from src.apps.sales.models.opportunity import Opportunity
from src.apps.sales.models.contract import Contract
from src.apps.sales.models.proposal import Proposal
from src.apps.plots.models.plot import Plot
from src.apps.sections.models.section import Section


OPPS_URL = "/api/v1/sales/opportunities"
CONTRACTS_URL = "/api/v1/sales/contracts"
PROPOSALS_URL = "/api/v1/sales/proposals"


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


async def _create_opportunity(client, token, tenant_id, stage="inquiry"):
    """Helper: create an opportunity and return its id."""
    resp = await client.post(
        OPPS_URL,
        json={
            "family_name": "Test Family",
            "care_type": "pre_need",
            "contact_email": "test@example.com",
            "estimated_value": 4800,
        },
        headers=_tenant_headers(token, tenant_id),
    )
    assert resp.status_code == 201
    opp_id = resp.json()["data"]["id"]

    if stage != "inquiry":
        # Advance through stages as needed
        stage_path = [
            "inquiry", "site_visit", "proposal_sent", "proposal_accepted",
            "contract_sent", "contract_signed", "partial_payment_received", "fully_paid",
        ]
        idx = stage_path.index(stage)
        for i in range(idx):
            next_stage = stage_path[i + 1]
            patch_resp = await client.patch(
                f"{OPPS_URL}/{opp_id}/stage",
                json={"stage": next_stage},
                headers=_tenant_headers(token, tenant_id),
            )
            assert patch_resp.status_code == 200, f"Failed advancing to {next_stage}: {patch_resp.json()}"

    return opp_id


# ── T-01: Valid transition inquiry → site_visit ───────────────────────────────

@pytest.mark.asyncio
async def test_stage_transition_inquiry_to_site_visit(
    client: AsyncClient, admin_token: str, test_account
):
    """T-01: PATCH /stage site_visit from inquiry → 200; stage updated."""
    opp_id = await _create_opportunity(client, admin_token, str(test_account.id))
    resp = await client.patch(
        f"{OPPS_URL}/{opp_id}/stage",
        json={"stage": "site_visit"},
        headers=_tenant_headers(admin_token, str(test_account.id)),
    )
    assert resp.status_code == 200
    assert resp.json()["data"]["stage"] == "site_visit"


# ── T-02: proposal_sent → proposal_accepted ───────────────────────────────────

@pytest.mark.asyncio
async def test_stage_transition_proposal_sent_to_accepted(
    client: AsyncClient, admin_token: str, test_account
):
    """T-02: PATCH /stage proposal_accepted from proposal_sent → 200."""
    opp_id = await _create_opportunity(client, admin_token, str(test_account.id), stage="proposal_sent")
    resp = await client.patch(
        f"{OPPS_URL}/{opp_id}/stage",
        json={"stage": "proposal_accepted"},
        headers=_tenant_headers(admin_token, str(test_account.id)),
    )
    assert resp.status_code == 200
    assert resp.json()["data"]["stage"] == "proposal_accepted"


# ── T-03: proposal_accepted → contract_sent ───────────────────────────────────

@pytest.mark.asyncio
async def test_stage_transition_proposal_accepted_to_contract_sent(
    client: AsyncClient, admin_token: str, test_account
):
    """T-03: PATCH /stage contract_sent from proposal_accepted → 200."""
    opp_id = await _create_opportunity(client, admin_token, str(test_account.id), stage="proposal_accepted")
    resp = await client.patch(
        f"{OPPS_URL}/{opp_id}/stage",
        json={"stage": "contract_sent"},
        headers=_tenant_headers(admin_token, str(test_account.id)),
    )
    assert resp.status_code == 200
    assert resp.json()["data"]["stage"] == "contract_sent"


# ── contract_signed may pay in full immediately, skipping partial payment ───

@pytest.mark.asyncio
async def test_stage_transition_contract_signed_to_fully_paid_direct(
    client: AsyncClient, admin_token: str, test_account
):
    """PATCH /stage fully_paid from contract_signed → 200; full payment on
    signature skips the partial_payment_received step entirely."""
    opp_id = await _create_opportunity(client, admin_token, str(test_account.id), stage="contract_signed")
    resp = await client.patch(
        f"{OPPS_URL}/{opp_id}/stage",
        json={"stage": "fully_paid"},
        headers=_tenant_headers(admin_token, str(test_account.id)),
    )
    assert resp.status_code == 200
    assert resp.json()["data"]["stage"] == "fully_paid"


# ── T-04: Invalid jump → 422 ─────────────────────────────────────────────────

@pytest.mark.asyncio
async def test_stage_transition_invalid_jump(
    client: AsyncClient, admin_token: str, test_account
):
    """T-04: PATCH with jump from inquiry to fully_paid → 422; error names allowed stages."""
    opp_id = await _create_opportunity(client, admin_token, str(test_account.id))
    resp = await client.patch(
        f"{OPPS_URL}/{opp_id}/stage",
        json={"stage": "fully_paid"},
        headers=_tenant_headers(admin_token, str(test_account.id)),
    )
    assert resp.status_code == 422
    detail = resp.json()["message"]
    assert "fully_paid" in detail


# ── T-05: Terminal stage fully_paid → 422 ────────────────────────────────────

@pytest.mark.asyncio
async def test_stage_transition_from_fully_paid_terminal(
    client: AsyncClient, admin_token: str, test_account
):
    """T-05: PATCH /stage on fully_paid opportunity → 422; terminal stage message."""
    opp_id = await _create_opportunity(client, admin_token, str(test_account.id), stage="fully_paid")
    resp = await client.patch(
        f"{OPPS_URL}/{opp_id}/stage",
        json={"stage": "contract_signed"},
        headers=_tenant_headers(admin_token, str(test_account.id)),
    )
    assert resp.status_code == 422
    assert "terminal" in resp.json()["message"].lower()


# ── T-06: Terminal stage lost → 422 ──────────────────────────────────────────

@pytest.mark.asyncio
async def test_stage_transition_from_lost_terminal(
    client: AsyncClient, admin_token: str, test_account
):
    """T-06: PATCH /stage on a lost opportunity → 422; terminal stage message."""
    opp_id = await _create_opportunity(client, admin_token, str(test_account.id))
    # Mark as lost first
    await client.post(
        f"{OPPS_URL}/{opp_id}/mark-lost",
        json={"loss_reason": "price"},
        headers=_tenant_headers(admin_token, str(test_account.id)),
    )
    resp = await client.patch(
        f"{OPPS_URL}/{opp_id}/stage",
        json={"stage": "site_visit"},
        headers=_tenant_headers(admin_token, str(test_account.id)),
    )
    assert resp.status_code == 422
    assert "terminal" in resp.json()["message"].lower()


# ── T-07: view_only → 403 ────────────────────────────────────────────────────

@pytest.mark.asyncio
async def test_stage_transition_view_only_forbidden(
    client: AsyncClient, db_session: AsyncSession, admin_token: str, test_account
):
    """T-07: PATCH /stage by view_only → 403."""
    from src.apps.auth.models.user import User
    from src.core.security import hash_password, create_access_token, build_token_payload

    opp_id = await _create_opportunity(client, admin_token, str(test_account.id))

    view_user = User(
        tenant_id=test_account.id,
        email="view.stage@testcemetery.com",
        password_hash=hash_password("Test1234"),
        first_name="View",
        last_name="Stage",
        role="view_only",
        status="active",
    )
    db_session.add(view_user)
    await db_session.flush()
    token = create_access_token(build_token_payload(view_user, test_account))

    resp = await client.patch(
        f"{OPPS_URL}/{opp_id}/stage",
        json={"stage": "site_visit"},
        headers=_tenant_headers(token, str(test_account.id)),
    )
    assert resp.status_code == 403


# ── T-13/14: GET with stage=lost filter ──────────────────────────────────────

@pytest.mark.asyncio
async def test_list_opportunities_stage_filter(
    client: AsyncClient, admin_token: str, test_account
):
    """T-13/14: GET /opportunities?stage=lost returns only lost opportunities."""
    opp_id = await _create_opportunity(client, admin_token, str(test_account.id))
    await client.post(
        f"{OPPS_URL}/{opp_id}/mark-lost",
        json={"loss_reason": "timing"},
        headers=_tenant_headers(admin_token, str(test_account.id)),
    )
    resp = await client.get(
        f"{OPPS_URL}?stage=lost",
        headers=_tenant_headers(admin_token, str(test_account.id)),
    )
    assert resp.status_code == 200
    items = resp.json()["data"]
    assert all(i["stage"] == "lost" for i in items)


# ── Lost blocked once contract is signed (dedicated /stage endpoint) ─────────

@pytest.mark.asyncio
async def test_mark_lost_blocked_after_contract_signed(
    client: AsyncClient, admin_token: str, test_account
):
    """Mark-lost from contract_signed → 422; deal is won, cannot be marked lost."""
    opp_id = await _create_opportunity(client, admin_token, str(test_account.id), stage="contract_signed")
    resp = await client.post(
        f"{OPPS_URL}/{opp_id}/mark-lost",
        json={"loss_reason": "price"},
        headers=_tenant_headers(admin_token, str(test_account.id)),
    )
    assert resp.status_code == 422
    assert "contract" in resp.json()["message"].lower()


@pytest.mark.asyncio
async def test_stage_transition_to_lost_blocked_after_contract_signed(
    client: AsyncClient, admin_token: str, test_account
):
    """PATCH /stage lost from contract_signed → 422; 'lost' no longer in allowed next stages."""
    opp_id = await _create_opportunity(client, admin_token, str(test_account.id), stage="contract_signed")
    resp = await client.patch(
        f"{OPPS_URL}/{opp_id}/stage",
        json={"stage": "lost"},
        headers=_tenant_headers(admin_token, str(test_account.id)),
    )
    assert resp.status_code == 422


@pytest.mark.asyncio
async def test_mark_lost_allowed_before_contract_signed(
    client: AsyncClient, admin_token: str, test_account
):
    """Mark-lost from contract_sent (before signing) → 200; still eligible."""
    opp_id = await _create_opportunity(client, admin_token, str(test_account.id), stage="contract_sent")
    resp = await client.post(
        f"{OPPS_URL}/{opp_id}/mark-lost",
        json={"loss_reason": "timing"},
        headers=_tenant_headers(admin_token, str(test_account.id)),
    )
    assert resp.status_code == 200
    assert resp.json()["data"]["stage"] == "lost"


# ── Generic PATCH /opportunities/{id} enforces the same stage rules ──────────

@pytest.mark.asyncio
async def test_generic_update_blocks_backward_stage_move(
    client: AsyncClient, admin_token: str, test_account
):
    """PATCH /opportunities/{id} with an earlier stage than current → 422 (forward-only)."""
    opp_id = await _create_opportunity(client, admin_token, str(test_account.id), stage="proposal_sent")
    resp = await client.patch(
        f"{OPPS_URL}/{opp_id}",
        json={"stage": "site_visit"},
        headers=_tenant_headers(admin_token, str(test_account.id)),
    )
    assert resp.status_code == 422
    assert "back" in resp.json()["message"].lower()


@pytest.mark.asyncio
async def test_generic_update_allows_forward_stage_move(
    client: AsyncClient, admin_token: str, test_account
):
    """PATCH /opportunities/{id} moving forward (even skipping stages) → 200.

    Target is contract_sent rather than proposal_accepted/contract_signed —
    those two now carry their own linked-entity guard (see the sync tests
    below) and would 422 here since this opportunity has no proposal/contract.
    """
    opp_id = await _create_opportunity(client, admin_token, str(test_account.id), stage="site_visit")
    resp = await client.patch(
        f"{OPPS_URL}/{opp_id}",
        json={"stage": "contract_sent"},
        headers=_tenant_headers(admin_token, str(test_account.id)),
    )
    assert resp.status_code == 200
    assert resp.json()["data"]["stage"] == "contract_sent"


@pytest.mark.asyncio
async def test_generic_update_blocks_lost_after_contract_signed(
    client: AsyncClient, admin_token: str, test_account
):
    """PATCH /opportunities/{id} with stage=lost from contract_signed → 422."""
    opp_id = await _create_opportunity(client, admin_token, str(test_account.id), stage="contract_signed")
    resp = await client.patch(
        f"{OPPS_URL}/{opp_id}",
        json={"stage": "lost"},
        headers=_tenant_headers(admin_token, str(test_account.id)),
    )
    assert resp.status_code == 422
    assert "contract" in resp.json()["message"].lower()


@pytest.mark.asyncio
async def test_generic_update_allows_lost_before_contract_signed(
    client: AsyncClient, admin_token: str, test_account
):
    """PATCH /opportunities/{id} with stage=lost before contract signature → 200."""
    opp_id = await _create_opportunity(client, admin_token, str(test_account.id), stage="proposal_sent")
    resp = await client.patch(
        f"{OPPS_URL}/{opp_id}",
        json={"stage": "lost"},
        headers=_tenant_headers(admin_token, str(test_account.id)),
    )
    assert resp.status_code == 200
    assert resp.json()["data"]["stage"] == "lost"


@pytest.mark.asyncio
async def test_generic_update_blocks_change_from_terminal_stage(
    client: AsyncClient, admin_token: str, test_account
):
    """PATCH /opportunities/{id} on a fully_paid (terminal) opportunity → 422."""
    opp_id = await _create_opportunity(client, admin_token, str(test_account.id), stage="fully_paid")
    resp = await client.patch(
        f"{OPPS_URL}/{opp_id}",
        json={"stage": "contract_signed"},
        headers=_tenant_headers(admin_token, str(test_account.id)),
    )
    assert resp.status_code == 422
    assert "terminal" in resp.json()["message"].lower()


# ── T-17: Cross-tenant isolation ─────────────────────────────────────────────

@pytest.mark.asyncio
async def test_stage_transition_cross_tenant_404(
    client: AsyncClient, db_session: AsyncSession, test_account
):
    """T-17: Staff from tenant A cannot transition opportunity belonging to tenant B → 404."""
    from src.apps.tenants.models.account import Account
    from src.apps.auth.models.user import User
    from src.core.security import hash_password, create_access_token, build_token_payload
    from src.apps.sales.models.opportunity import Opportunity

    # Create tenant B — professional plan: this test authenticates AS tenant B
    # to hit a `/sales/*` route (INDL-59 gates the whole router on `sales`).
    tenant_b = Account(
        organization_name="Cemetery B",
        subdomain="cemb",
        contact_email="admin@cemb.com",
        plan="professional",
        status="active",
    )
    db_session.add(tenant_b)
    await db_session.flush()

    # Create an opportunity for tenant B directly in DB
    opp_b = Opportunity(
        tenant_id=tenant_b.id,
        family_name="B Family",
        stage="inquiry",
    )
    db_session.add(opp_b)
    await db_session.flush()

    # Create admin for tenant A (test_account)
    admin_a = User(
        tenant_id=test_account.id,
        email="admin.a.stage@testcemetery.com",
        password_hash=hash_password("Test1234"),
        first_name="Admin",
        last_name="A",
        role="administrator",
        status="active",
    )
    db_session.add(admin_a)
    await db_session.flush()

    token_a = create_access_token(build_token_payload(admin_a, test_account))

    # Tenant A admin tries to transition tenant B's opportunity
    resp = await client.patch(
        f"{OPPS_URL}/{opp_b.id}/stage",
        json={"stage": "site_visit"},
        headers=_tenant_headers(token_a, str(test_account.id)),
    )
    assert resp.status_code == 404


# ── Pipeline ↔ Contracts / Proposal sync ─────────────────────────────────────
# PATCH /opportunities/{id} moving the Pipeline stage into 'contract_signed'
# or 'proposal_accepted' must keep the linked Contract/Proposal (and the
# plot a proposal carries) in sync — see OpportunityService.update().

async def _create_contract(client, token, tenant_id, opportunity_id):
    resp = await client.post(
        CONTRACTS_URL,
        json={
            "opportunity_id": opportunity_id,
            "purchaser_name": "Test Family",
            "purchaser_email": "test@example.com",
            "contract_type": "pre_need",
            "payment_plan_type": "full",
            "line_items": [{"description": "Service", "quantity": 1, "unit_price": 1000}],
        },
        headers=_tenant_headers(token, tenant_id),
    )
    assert resp.status_code == 201, resp.text
    return resp.json()["data"]["id"]


async def _issue_contract(client, token, tenant_id, contract_id):
    resp = await client.patch(
        f"{CONTRACTS_URL}/{contract_id}/issue",
        headers=_tenant_headers(token, tenant_id),
    )
    assert resp.status_code == 200, resp.text


async def _create_and_send_proposal(client, token, tenant_id, opportunity_id, plot_id=None):
    body = {
        "opportunity_id": opportunity_id,
        "to_email": "family@example.ca",
        "subject": "Proposal",
        "cover_note": "Please review the following proposal.",
        "valid_days": 30,
        "line_items": [{"description": "Service", "quantity": 1, "unit_price": "500.00"}],
    }
    if plot_id:
        body["plot_id"] = str(plot_id)
    resp = await client.post(PROPOSALS_URL, json=body, headers=_tenant_headers(token, tenant_id))
    assert resp.status_code == 201, resp.text
    proposal_id = resp.json()["data"]["id"]

    resp = await client.post(
        f"{PROPOSALS_URL}/{proposal_id}/send",
        headers=_tenant_headers(token, tenant_id),
    )
    assert resp.status_code == 200, resp.text
    return proposal_id


@pytest.mark.asyncio
async def test_generic_update_rejects_contract_signed_without_contract(
    client: AsyncClient, admin_token: str, test_account
):
    """PATCH stage=contract_signed with no linked contract → 422, stage unchanged."""
    opp_id = await _create_opportunity(client, admin_token, str(test_account.id), stage="proposal_accepted")
    resp = await client.patch(
        f"{OPPS_URL}/{opp_id}",
        json={"stage": "contract_signed"},
        headers=_tenant_headers(admin_token, str(test_account.id)),
    )
    assert resp.status_code == 422
    assert "no contract" in resp.json()["message"].lower()

    resp = await client.get(f"{OPPS_URL}/{opp_id}", headers=_tenant_headers(admin_token, str(test_account.id)))
    assert resp.json()["data"]["stage"] == "proposal_accepted"


@pytest.mark.asyncio
async def test_generic_update_signs_contract_on_stage_move(
    client: AsyncClient, db_session: AsyncSession, admin_token: str, test_account
):
    """PATCH stage=contract_signed with a linked (unsigned) contract → syncs Contract.status to 'signed'."""
    opp_id = await _create_opportunity(client, admin_token, str(test_account.id), stage="proposal_accepted")
    contract_id = await _create_contract(client, admin_token, str(test_account.id), opp_id)
    await _issue_contract(client, admin_token, str(test_account.id), contract_id)

    resp = await client.patch(
        f"{OPPS_URL}/{opp_id}",
        json={"stage": "contract_signed"},
        headers=_tenant_headers(admin_token, str(test_account.id)),
    )
    assert resp.status_code == 200
    assert resp.json()["data"]["stage"] == "contract_signed"

    result = await db_session.execute(select(Contract).where(Contract.id == contract_id))
    contract = result.scalar_one()
    assert contract.status == "signed"
    assert contract.signed_at is not None


@pytest.mark.asyncio
async def test_generic_update_rejects_proposal_accepted_without_proposal(
    client: AsyncClient, admin_token: str, test_account
):
    """PATCH stage=proposal_accepted with no linked proposal → 422, stage unchanged."""
    opp_id = await _create_opportunity(client, admin_token, str(test_account.id), stage="proposal_sent")
    resp = await client.patch(
        f"{OPPS_URL}/{opp_id}",
        json={"stage": "proposal_accepted"},
        headers=_tenant_headers(admin_token, str(test_account.id)),
    )
    assert resp.status_code == 422
    assert "no proposal" in resp.json()["message"].lower()

    resp = await client.get(f"{OPPS_URL}/{opp_id}", headers=_tenant_headers(admin_token, str(test_account.id)))
    assert resp.json()["data"]["stage"] == "proposal_sent"


@pytest.mark.asyncio
async def test_generic_update_accepts_proposal_and_copies_plot_on_stage_move(
    client: AsyncClient, db_session: AsyncSession, admin_token: str, test_account
):
    """PATCH stage=proposal_accepted with a sent proposal (carrying a plot) →
    syncs Proposal.status to 'accepted' AND copies the plot onto the opportunity,
    so the Contract Wizard can inherit it later."""
    section = Section(tenant_id=test_account.id, code="A", name="Section A")
    db_session.add(section)
    await db_session.flush()
    plot = Plot(tenant_id=test_account.id, plot_ref="A-1", section_id=section.id, status="vacant")
    db_session.add(plot)
    await db_session.flush()

    opp_id = await _create_opportunity(client, admin_token, str(test_account.id), stage="proposal_sent")
    proposal_id = await _create_and_send_proposal(
        client, admin_token, str(test_account.id), opp_id, plot_id=plot.id,
    )

    resp = await client.patch(
        f"{OPPS_URL}/{opp_id}",
        json={"stage": "proposal_accepted"},
        headers=_tenant_headers(admin_token, str(test_account.id)),
    )
    assert resp.status_code == 200
    assert resp.json()["data"]["stage"] == "proposal_accepted"

    prop_result = await db_session.execute(select(Proposal).where(Proposal.id == proposal_id))
    proposal = prop_result.scalar_one()
    assert proposal.status == "accepted"
    assert proposal.accepted_at is not None

    opp_result = await db_session.execute(select(Opportunity).where(Opportunity.id == opp_id))
    opp = opp_result.scalar_one()
    assert opp.plot_id == plot.id
