"""
Tests for Proposal Compose & Detail — INDL-27.
Covers T-01 through T-20 from the PRD test matrix.
"""
from datetime import datetime, timedelta, timezone

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

from src.apps.sales.models.proposal import Proposal
from src.apps.sales.models.opportunity import Opportunity

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


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


_SAMPLE_LINE_ITEMS = [
    {"description": "Burial plot — A-01", "quantity": 1, "unit_price": "2500.00"},
    {"description": "Opening & closing", "quantity": 1, "unit_price": "500.00"},
]


async def _create_proposal(client, token, tenant_id, *, extra=None):
    body = {
        "to_email": "family@example.ca",
        "subject": "Proposal for plot A-01",
        "cover_note": "Please review the following proposal.",
        "valid_days": 30,
        "line_items": _SAMPLE_LINE_ITEMS,
    }
    if extra:
        body.update(extra)
    resp = await client.post(
        PROPOSALS_URL,
        json=body,
        headers=_headers(token, tenant_id),
    )
    assert resp.status_code == 201, resp.text
    return resp.json()["data"]


async def _create_opportunity(client, token, tenant_id):
    resp = await client.post(
        OPPS_URL,
        json={"family_name": "Test Family", "care_type": "pre_need", "estimated_value": 4000},
        headers=_headers(token, tenant_id),
    )
    assert resp.status_code == 201, resp.text
    return resp.json()["data"]["id"]


# ── T-01: Create draft with required fields ──────────────────────────────────

@pytest.mark.asyncio
async def test_create_proposal_returns_draft(
    client: AsyncClient, admin_token: str, test_account
):
    """T-01: POST /proposals with valid payload → 201; status=draft, is_expired=false."""
    data = await _create_proposal(client, admin_token, test_account.id)
    assert data["status"] == "draft"
    assert data["is_expired"] is False
    assert data["quote_number"].startswith("P-")
    assert "line_items" in data
    assert len(data["line_items"]) == 2


# ── T-02: Create without line_items ──────────────────────────────────────────

@pytest.mark.asyncio
async def test_create_proposal_no_line_items(
    client: AsyncClient, admin_token: str, test_account
):
    """T-02: POST /proposals without line_items → 201; subtotal = 0."""
    resp = await client.post(
        PROPOSALS_URL,
        json={"to_email": "test@example.ca", "subject": "Empty proposal", "cover_note": "Test"},
        headers=_headers(admin_token, test_account.id),
    )
    assert resp.status_code == 201
    data = resp.json()["data"]
    assert float(data["subtotal"]) == 0.0
    assert data["status"] == "draft"


# ── T-03: Totals computed correctly (13% HST) ─────────────────────────────────

@pytest.mark.asyncio
async def test_create_proposal_computes_totals(
    client: AsyncClient, admin_token: str, test_account
):
    """T-03: Line item totals: subtotal=$3000, tax=$390, total=$3390."""
    data = await _create_proposal(client, admin_token, test_account.id)
    assert float(data["subtotal"]) == 3000.00
    assert float(data["tax_amount"]) == pytest.approx(390.00, abs=0.01)
    assert float(data["total_amount"]) == pytest.approx(3390.00, abs=0.01)
    assert float(data["tax_rate"]) == pytest.approx(0.13, rel=0.01)


# ── T-04: GET by ID includes line_items ──────────────────────────────────────

@pytest.mark.asyncio
async def test_get_proposal_by_id(
    client: AsyncClient, admin_token: str, test_account
):
    """T-04: GET /proposals/{id} → 200, returns full proposal with line_items."""
    created = await _create_proposal(client, admin_token, test_account.id)
    resp = await client.get(
        f"{PROPOSALS_URL}/{created['id']}",
        headers=_headers(admin_token, test_account.id),
    )
    assert resp.status_code == 200
    data = resp.json()["data"]
    assert data["id"] == created["id"]
    assert len(data["line_items"]) == 2
    assert data["is_expired"] is False
    assert "pdf_s3_key" not in data  # must never be exposed


# ── T-05: GET non-existent proposal → 404 ────────────────────────────────────

@pytest.mark.asyncio
async def test_get_nonexistent_proposal(
    client: AsyncClient, admin_token: str, test_account
):
    """T-05: GET /proposals/<invalid-uuid> → 404."""
    resp = await client.get(
        f"{PROPOSALS_URL}/00000000-0000-0000-0000-000000000099",
        headers=_headers(admin_token, test_account.id),
    )
    assert resp.status_code == 404


# ── T-06: Send draft → status=sent, sent_at set, expires_at set ──────────────

@pytest.mark.asyncio
async def test_send_proposal(
    client: AsyncClient, admin_token: str, test_account, db_session: AsyncSession
):
    """T-06: POST /proposals/{id}/send → 200; status=sent, expires_at=sent_at+valid_days."""
    data = await _create_proposal(client, admin_token, test_account.id)

    resp = await client.post(
        f"{PROPOSALS_URL}/{data['id']}/send",
        headers=_headers(admin_token, test_account.id),
    )
    assert resp.status_code == 200
    result = resp.json()["data"]
    assert result["status"] == "sent"
    assert result["sent_at"] is not None
    assert result["expires_at"] is not None

    # expires_at should be ~30 days after sent_at
    sent_ts = datetime.fromisoformat(result["sent_at"].replace("Z", "+00:00"))
    exp_ts = datetime.fromisoformat(result["expires_at"].replace("Z", "+00:00"))
    diff_days = (exp_ts - sent_ts).days
    assert 29 <= diff_days <= 30, f"Expected ~30 days, got {diff_days}"


# ── T-07: Send already-sent proposal → 409 ───────────────────────────────────

@pytest.mark.asyncio
async def test_send_already_sent_proposal_returns_conflict(
    client: AsyncClient, admin_token: str, test_account
):
    """T-07: Sending a proposal that is already sent → 409 Conflict."""
    data = await _create_proposal(client, admin_token, test_account.id)
    await client.post(
        f"{PROPOSALS_URL}/{data['id']}/send",
        headers=_headers(admin_token, test_account.id),
    )
    # Second send
    resp = await client.post(
        f"{PROPOSALS_URL}/{data['id']}/send",
        headers=_headers(admin_token, test_account.id),
    )
    assert resp.status_code == 409


# ── T-08: Accept sent proposal ────────────────────────────────────────────────

@pytest.mark.asyncio
async def test_accept_sent_proposal(
    client: AsyncClient, admin_token: str, test_account
):
    """T-08: POST /proposals/{id}/accept on sent proposal → 200, status=accepted."""
    data = await _create_proposal(client, admin_token, test_account.id)
    await client.post(
        f"{PROPOSALS_URL}/{data['id']}/send",
        headers=_headers(admin_token, test_account.id),
    )
    resp = await client.post(
        f"{PROPOSALS_URL}/{data['id']}/accept",
        headers=_headers(admin_token, test_account.id),
    )
    assert resp.status_code == 200
    result = resp.json()["data"]
    assert result["status"] == "accepted"
    assert result["accepted_at"] is not None


# ── T-09: Accept draft proposal → 422 ────────────────────────────────────────

@pytest.mark.asyncio
async def test_accept_draft_proposal_returns_422(
    client: AsyncClient, admin_token: str, test_account
):
    """T-09: Accepting a draft proposal → 422 Unprocessable."""
    data = await _create_proposal(client, admin_token, test_account.id)
    resp = await client.post(
        f"{PROPOSALS_URL}/{data['id']}/accept",
        headers=_headers(admin_token, test_account.id),
    )
    assert resp.status_code == 422


# ── T-10: Accept already-accepted proposal → 422 ─────────────────────────────

@pytest.mark.asyncio
async def test_accept_accepted_proposal_returns_422(
    client: AsyncClient, admin_token: str, test_account
):
    """T-10: Accepting an already-accepted proposal → 422."""
    data = await _create_proposal(client, admin_token, test_account.id)
    pid = data["id"]
    await client.post(f"{PROPOSALS_URL}/{pid}/send", headers=_headers(admin_token, test_account.id))
    await client.post(f"{PROPOSALS_URL}/{pid}/accept", headers=_headers(admin_token, test_account.id))

    resp = await client.post(
        f"{PROPOSALS_URL}/{pid}/accept",
        headers=_headers(admin_token, test_account.id),
    )
    assert resp.status_code == 422


# ── T-11: Decline sent proposal ──────────────────────────────────────────────

@pytest.mark.asyncio
async def test_decline_sent_proposal(
    client: AsyncClient, admin_token: str, test_account
):
    """T-11: PATCH /proposals/{id}/status {status: declined} on sent → 200, declined_at set."""
    data = await _create_proposal(client, admin_token, test_account.id)
    pid = data["id"]
    await client.post(f"{PROPOSALS_URL}/{pid}/send", headers=_headers(admin_token, test_account.id))

    resp = await client.patch(
        f"{PROPOSALS_URL}/{pid}/status",
        json={"status": "declined"},
        headers=_headers(admin_token, test_account.id),
    )
    assert resp.status_code == 200
    result = resp.json()["data"]
    assert result["status"] == "declined"
    assert result["declined_at"] is not None


# ── T-12: Decline draft → 422 ────────────────────────────────────────────────

@pytest.mark.asyncio
async def test_decline_draft_returns_422(
    client: AsyncClient, admin_token: str, test_account
):
    """T-12: Declining a draft proposal → 422."""
    data = await _create_proposal(client, admin_token, test_account.id)
    resp = await client.patch(
        f"{PROPOSALS_URL}/{data['id']}/status",
        json={"status": "declined"},
        headers=_headers(admin_token, test_account.id),
    )
    assert resp.status_code == 422


# ── T-13: Decline already-accepted proposal → 422 ────────────────────────────

@pytest.mark.asyncio
async def test_decline_accepted_returns_422(
    client: AsyncClient, admin_token: str, test_account
):
    """T-13: Declining an accepted proposal → 422."""
    data = await _create_proposal(client, admin_token, test_account.id)
    pid = data["id"]
    await client.post(f"{PROPOSALS_URL}/{pid}/send", headers=_headers(admin_token, test_account.id))
    await client.post(f"{PROPOSALS_URL}/{pid}/accept", headers=_headers(admin_token, test_account.id))

    resp = await client.patch(
        f"{PROPOSALS_URL}/{pid}/status",
        json={"status": "declined"},
        headers=_headers(admin_token, test_account.id),
    )
    assert resp.status_code == 422


# ── T-14: Resend sent proposal ───────────────────────────────────────────────

@pytest.mark.asyncio
async def test_resend_sent_proposal(
    client: AsyncClient, admin_token: str, test_account
):
    """T-14: POST /proposals/{id}/resend on sent (non-expired) → 200."""
    data = await _create_proposal(client, admin_token, test_account.id)
    pid = data["id"]
    await client.post(f"{PROPOSALS_URL}/{pid}/send", headers=_headers(admin_token, test_account.id))

    resp = await client.post(
        f"{PROPOSALS_URL}/{pid}/resend",
        headers=_headers(admin_token, test_account.id),
    )
    assert resp.status_code == 200
    assert resp.json()["data"]["status"] == "sent"


# ── T-15: Resend expired proposal → 422 ──────────────────────────────────────

@pytest.mark.asyncio
async def test_resend_expired_proposal_returns_422(
    client: AsyncClient, admin_token: str, test_account, db_session: AsyncSession
):
    """T-15: Resend an expired proposal (expires_at in the past) → 422."""
    data = await _create_proposal(client, admin_token, test_account.id)
    pid = data["id"]
    await client.post(f"{PROPOSALS_URL}/{pid}/send", headers=_headers(admin_token, test_account.id))

    # Force expires_at to be in the past by mutating the ORM object directly
    past = datetime.now(timezone.utc) - timedelta(days=5)
    result = await db_session.execute(select(Proposal).where(Proposal.id == pid))
    proposal_obj = result.scalar_one()
    proposal_obj.expires_at = past
    await db_session.flush()

    resp = await client.post(
        f"{PROPOSALS_URL}/{pid}/resend",
        headers=_headers(admin_token, test_account.id),
    )
    assert resp.status_code == 422


# ── T-16: Resend declined proposal → 422 ─────────────────────────────────────

@pytest.mark.asyncio
async def test_resend_declined_proposal_returns_422(
    client: AsyncClient, admin_token: str, test_account
):
    """T-16: Resending a declined proposal → 422."""
    data = await _create_proposal(client, admin_token, test_account.id)
    pid = data["id"]
    await client.post(f"{PROPOSALS_URL}/{pid}/send", headers=_headers(admin_token, test_account.id))
    await client.patch(
        f"{PROPOSALS_URL}/{pid}/status",
        json={"status": "declined"},
        headers=_headers(admin_token, test_account.id),
    )
    resp = await client.post(
        f"{PROPOSALS_URL}/{pid}/resend",
        headers=_headers(admin_token, test_account.id),
    )
    assert resp.status_code == 422


# ── T-17: PATCH /status with invalid status → 422 ────────────────────────────

@pytest.mark.asyncio
async def test_patch_status_invalid_value(
    client: AsyncClient, admin_token: str, test_account
):
    """T-17: PATCH /status with status='accepted' → 422 (only 'declined' allowed)."""
    data = await _create_proposal(client, admin_token, test_account.id)
    pid = data["id"]
    resp = await client.patch(
        f"{PROPOSALS_URL}/{pid}/status",
        json={"status": "accepted"},
        headers=_headers(admin_token, test_account.id),
    )
    assert resp.status_code == 422


# ── T-18: Send linked opportunity → stage advances to proposal_sent ───────────

@pytest.mark.asyncio
async def test_send_proposal_advances_opportunity_stage(
    client: AsyncClient, admin_token: str, test_account, db_session: AsyncSession
):
    """T-18: Sending a proposal linked to an opportunity → opp.stage = proposal_sent."""
    opp_id = await _create_opportunity(client, admin_token, test_account.id)
    data = await _create_proposal(
        client, admin_token, test_account.id,
        extra={"opportunity_id": opp_id},
    )
    await client.post(
        f"{PROPOSALS_URL}/{data['id']}/send",
        headers=_headers(admin_token, test_account.id),
    )

    result = await db_session.execute(select(Opportunity).where(Opportunity.id == opp_id))
    opp = result.scalar_one()
    assert opp.stage == "proposal_sent"


# ── T-19: Accept linked opportunity → stage advances to proposal_accepted ─────

@pytest.mark.asyncio
async def test_accept_proposal_advances_opportunity_stage(
    client: AsyncClient, admin_token: str, test_account, db_session: AsyncSession
):
    """T-19: Accepting a sent proposal → opp.stage = proposal_accepted (not contract_signed)."""
    opp_id = await _create_opportunity(client, admin_token, test_account.id)
    data = await _create_proposal(
        client, admin_token, test_account.id,
        extra={"opportunity_id": opp_id},
    )
    pid = data["id"]
    await client.post(f"{PROPOSALS_URL}/{pid}/send", headers=_headers(admin_token, test_account.id))
    await client.post(f"{PROPOSALS_URL}/{pid}/accept", headers=_headers(admin_token, test_account.id))

    result = await db_session.execute(select(Opportunity).where(Opportunity.id == opp_id))
    opp = result.scalar_one()
    assert opp.stage == "proposal_accepted"
    assert opp.stage != "contract_signed"


# ── T-20: Unauthenticated request → 401/403 ─────────────────────────────────

@pytest.mark.asyncio
async def test_unauthenticated_request_denied(
    client: AsyncClient, test_account
):
    """T-20: Requests without a Bearer token → 401 or 403."""
    # Use a real proposal endpoint with a dummy UUID — no auth header
    resp = await client.get(
        f"{PROPOSALS_URL}/00000000-0000-0000-0000-000000000001",
        headers={"X-Tenant-ID": str(test_account.id)},
    )
    assert resp.status_code in (401, 403)
