"""
Backend tests for INDL-56 — Sales: Stage-Conditional Action Buttons.

Covers:
  - POST /sales/contracts/{id}/resend (AC-09)
  - POST/GET /sales/contracts/{id}/signed-document (AC-15..AC-18)
"""
from __future__ import annotations

import uuid
from unittest.mock import AsyncMock, MagicMock, patch

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

from src.apps.sales.models.contract import Contract

CONTRACTS_URL = "/api/v1/sales/contracts"

PDF_MAGIC = b"%PDF-1.4\n%mock pdf content for tests\n"


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


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


async def _sign_contract(client: AsyncClient, token: str, tenant_id, contract_id: str) -> None:
    fake_sig = (
        "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk"
        "+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
    )
    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 _issue_contract(client: AsyncClient, token: str, tenant_id, contract_id: str):
    return await client.patch(
        f"{CONTRACTS_URL}/{contract_id}/issue",
        headers=_headers(token, tenant_id),
    )


async def _make_staff_token(db_session: AsyncSession, test_account):
    from src.apps.auth.models.user import User
    from src.core.security import hash_password, create_access_token, build_token_payload

    staff_user = User(
        tenant_id=test_account.id,
        email=f"staff_{uuid.uuid4().hex[:8]}@example.ca",
        password_hash=hash_password("Password1!"),
        first_name="Staff",
        last_name="User",
        role="staff",
        status="active",
    )
    db_session.add(staff_user)
    await db_session.flush()
    return create_access_token(build_token_payload(staff_user, test_account))


async def _make_other_tenant(db_session: AsyncSession):
    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

    other_account = Account(
        organization_name="Other Cemetery",
        subdomain=f"other{uuid.uuid4().hex[:8]}",
        contact_email="admin@othercemetery.com",
        plan="starter",
        status="active",
    )
    db_session.add(other_account)
    await db_session.flush()

    other_admin = User(
        tenant_id=other_account.id,
        email=f"admin_{uuid.uuid4().hex[:8]}@othercemetery.com",
        password_hash=hash_password("TestPassword123"),
        first_name="Admin",
        last_name="User",
        role="administrator",
        status="active",
    )
    db_session.add(other_admin)
    await db_session.flush()
    token = create_access_token(build_token_payload(other_admin, other_account))
    return other_account, token


# ── Resend ────────────────────────────────────────────────────────────────────


@pytest.mark.asyncio
async def test_resend_unissued_contract_returns_409(
    client: AsyncClient, admin_token: str, test_account
):
    contract = await _create_contract(client, admin_token, test_account.id)
    await _sign_contract(client, admin_token, test_account.id, contract["id"])
    resp = await client.post(
        f"{CONTRACTS_URL}/{contract['id']}/resend",
        headers=_headers(admin_token, test_account.id),
    )
    assert resp.status_code == 409, resp.text


@pytest.mark.asyncio
async def test_resend_issued_contract_succeeds_no_new_invoice_or_token(
    client: AsyncClient, admin_token: str, test_account, db_session: AsyncSession
):
    contract = await _create_contract(client, admin_token, test_account.id)
    await _sign_contract(client, admin_token, test_account.id, contract["id"])
    issue_resp = await _issue_contract(client, admin_token, test_account.id, contract["id"])
    assert issue_resp.status_code == 200

    result = await db_session.execute(select(Contract).where(Contract.id == contract["id"]))
    before = result.scalar_one()
    token_before = before.payment_token
    payment_link_before = before.payment_link_url

    resp = await client.post(
        f"{CONTRACTS_URL}/{contract['id']}/resend",
        headers=_headers(admin_token, test_account.id),
    )
    assert resp.status_code == 200, resp.text
    data = resp.json()["data"]
    assert data["payment_link_url"] == payment_link_before
    assert data["invoices_created"] == []
    assert data["opportunity_stage_updated_to"] is None

    result = await db_session.execute(select(Contract).where(Contract.id == contract["id"]))
    after = result.scalar_one()
    assert after.payment_token == token_before
    assert after.payment_link_url == payment_link_before


@pytest.mark.asyncio
async def test_resend_requires_manager_role(
    client: AsyncClient, admin_token: str, test_account, db_session: AsyncSession
):
    contract = await _create_contract(client, admin_token, test_account.id)
    await _sign_contract(client, admin_token, test_account.id, contract["id"])
    await _issue_contract(client, admin_token, test_account.id, contract["id"])

    staff_token = await _make_staff_token(db_session, test_account)
    resp = await client.post(
        f"{CONTRACTS_URL}/{contract['id']}/resend",
        headers=_headers(staff_token, test_account.id),
    )
    assert resp.status_code == 403


@pytest.mark.asyncio
async def test_resend_cross_tenant_returns_404_not_leaking_existence(
    client: AsyncClient, admin_token: str, test_account, db_session: AsyncSession
):
    contract = await _create_contract(client, admin_token, test_account.id)
    await _sign_contract(client, admin_token, test_account.id, contract["id"])
    await _issue_contract(client, admin_token, test_account.id, contract["id"])

    other_account, other_token = await _make_other_tenant(db_session)
    resp = await client.post(
        f"{CONTRACTS_URL}/{contract['id']}/resend",
        headers=_headers(other_token, other_account.id),
    )
    assert resp.status_code == 404

    fake_id = str(uuid.uuid4())
    resp_fake = await client.post(
        f"{CONTRACTS_URL}/{fake_id}/resend",
        headers=_headers(other_token, other_account.id),
    )
    assert resp_fake.status_code == 404
    assert resp_fake.json()["message"] == resp.json()["message"]


# ── Signed document upload/get ───────────────────────────────────────────────


def _s3_client_patch():
    """Patch the module-level S3 client factory so no real AWS calls happen."""
    mock_s3 = MagicMock()
    mock_s3.put_object = MagicMock(return_value={})
    mock_s3.delete_object = MagicMock(return_value={})
    mock_s3.generate_presigned_url = MagicMock(
        return_value="https://s3.example.com/presigned-signed-doc"
    )
    return patch(
        "src.apps.sales.services.contract_document_service._make_s3_client",
        return_value=mock_s3,
    ), mock_s3


@pytest.mark.asyncio
async def test_upload_unsigned_contract_returns_409(
    client: AsyncClient, admin_token: str, test_account
):
    contract = await _create_contract(client, admin_token, test_account.id)
    patcher, _ = _s3_client_patch()
    with patcher:
        resp = await client.post(
            f"{CONTRACTS_URL}/{contract['id']}/signed-document",
            headers=_headers(admin_token, test_account.id),
            files={"file": ("signed.pdf", PDF_MAGIC, "application/pdf")},
        )
    assert resp.status_code == 409, resp.text


@pytest.mark.asyncio
async def test_upload_non_pdf_returns_422(
    client: AsyncClient, admin_token: str, test_account
):
    contract = await _create_contract(client, admin_token, test_account.id)
    await _sign_contract(client, admin_token, test_account.id, contract["id"])

    patcher, _ = _s3_client_patch()
    with patcher:
        resp = await client.post(
            f"{CONTRACTS_URL}/{contract['id']}/signed-document",
            headers=_headers(admin_token, test_account.id),
            files={"file": ("notes.docx", b"not a pdf", "application/vnd.msword")},
        )
    assert resp.status_code == 422, resp.text


@pytest.mark.asyncio
async def test_upload_oversized_file_returns_422(
    client: AsyncClient, admin_token: str, test_account
):
    contract = await _create_contract(client, admin_token, test_account.id)
    await _sign_contract(client, admin_token, test_account.id, contract["id"])

    oversized = PDF_MAGIC + (b"0" * (20 * 1024 * 1024 + 1))
    patcher, _ = _s3_client_patch()
    with patcher:
        resp = await client.post(
            f"{CONTRACTS_URL}/{contract['id']}/signed-document",
            headers=_headers(admin_token, test_account.id),
            files={"file": ("signed.pdf", oversized, "application/pdf")},
        )
    assert resp.status_code == 422, resp.text


@pytest.mark.asyncio
async def test_upload_signed_contract_succeeds(
    client: AsyncClient, admin_token: str, test_account, db_session: AsyncSession
):
    contract = await _create_contract(client, admin_token, test_account.id)
    await _sign_contract(client, admin_token, test_account.id, contract["id"])

    patcher, mock_s3 = _s3_client_patch()
    with patcher:
        resp = await client.post(
            f"{CONTRACTS_URL}/{contract['id']}/signed-document",
            headers=_headers(admin_token, test_account.id),
            files={"file": ("signed.pdf", PDF_MAGIC, "application/pdf")},
        )
    assert resp.status_code == 200, resp.text
    data = resp.json()["data"]
    assert data["signed_document_available"] is True
    mock_s3.put_object.assert_called_once()
    mock_s3.delete_object.assert_not_called()

    result = await db_session.execute(select(Contract).where(Contract.id == contract["id"]))
    db_contract = result.scalar_one()
    assert db_contract.signed_document_s3_key is not None
    assert db_contract.signed_document_uploaded_at is not None
    assert db_contract.signed_document_uploaded_by is not None


@pytest.mark.asyncio
async def test_upload_requires_manager_role(
    client: AsyncClient, admin_token: str, test_account, db_session: AsyncSession
):
    contract = await _create_contract(client, admin_token, test_account.id)
    await _sign_contract(client, admin_token, test_account.id, contract["id"])

    staff_token = await _make_staff_token(db_session, test_account)
    patcher, _ = _s3_client_patch()
    with patcher:
        resp = await client.post(
            f"{CONTRACTS_URL}/{contract['id']}/signed-document",
            headers=_headers(staff_token, test_account.id),
            files={"file": ("signed.pdf", PDF_MAGIC, "application/pdf")},
        )
    assert resp.status_code == 403


@pytest.mark.asyncio
async def test_get_signed_document_url_404_when_none_uploaded(
    client: AsyncClient, admin_token: str, test_account
):
    contract = await _create_contract(client, admin_token, test_account.id)
    await _sign_contract(client, admin_token, test_account.id, contract["id"])

    resp = await client.get(
        f"{CONTRACTS_URL}/{contract['id']}/signed-document",
        headers=_headers(admin_token, test_account.id),
    )
    assert resp.status_code == 404


@pytest.mark.asyncio
async def test_get_signed_document_url_after_upload_staff_allowed(
    client: AsyncClient, admin_token: str, test_account, db_session: AsyncSession
):
    contract = await _create_contract(client, admin_token, test_account.id)
    await _sign_contract(client, admin_token, test_account.id, contract["id"])

    patcher, _ = _s3_client_patch()
    with patcher:
        upload_resp = await client.post(
            f"{CONTRACTS_URL}/{contract['id']}/signed-document",
            headers=_headers(admin_token, test_account.id),
            files={"file": ("signed.pdf", PDF_MAGIC, "application/pdf")},
        )
    assert upload_resp.status_code == 200

    staff_token = await _make_staff_token(db_session, test_account)
    patcher2, mock_s3_2 = _s3_client_patch()
    with patcher2:
        resp = await client.get(
            f"{CONTRACTS_URL}/{contract['id']}/signed-document",
            headers=_headers(staff_token, test_account.id),
        )
    assert resp.status_code == 200, resp.text
    assert resp.json()["data"]["url"] == "https://s3.example.com/presigned-signed-doc"
    mock_s3_2.generate_presigned_url.assert_called_once()


@pytest.mark.asyncio
async def test_replace_uploads_new_object_before_deleting_old(
    client: AsyncClient, admin_token: str, test_account, db_session: AsyncSession
):
    """Regression test — the delete-before-upload ordering bug found in security
    review: uploading a replacement must put_object the NEW key and flush the DB
    row BEFORE delete_object is called on the OLD key, so a failed replacement
    never leaves the contract pointing at an already-deleted object."""
    contract = await _create_contract(client, admin_token, test_account.id)
    await _sign_contract(client, admin_token, test_account.id, contract["id"])

    call_order: list[str] = []
    mock_s3 = MagicMock()
    mock_s3.put_object = MagicMock(side_effect=lambda **kw: call_order.append("put"))
    mock_s3.delete_object = MagicMock(side_effect=lambda **kw: call_order.append("delete"))
    mock_s3.generate_presigned_url = MagicMock(return_value="https://s3.example.com/x")

    with patch(
        "src.apps.sales.services.contract_document_service._make_s3_client",
        return_value=mock_s3,
    ):
        first = await client.post(
            f"{CONTRACTS_URL}/{contract['id']}/signed-document",
            headers=_headers(admin_token, test_account.id),
            files={"file": ("signed.pdf", PDF_MAGIC, "application/pdf")},
        )
        assert first.status_code == 200, first.text
        assert call_order == ["put"]

        second = await client.post(
            f"{CONTRACTS_URL}/{contract['id']}/signed-document",
            headers=_headers(admin_token, test_account.id),
            files={"file": ("signed-v2.pdf", PDF_MAGIC, "application/pdf")},
        )
        assert second.status_code == 200, second.text

    # The second (replacement) upload must put the NEW object before deleting
    # the OLD one — i.e. "put" appears again before the "delete" that follows it.
    assert call_order == ["put", "put", "delete"], call_order

    result = await db_session.execute(select(Contract).where(Contract.id == contract["id"]))
    db_contract = result.scalar_one()
    assert db_contract.signed_document_s3_key is not None


# ── Service-layer unit test: replace ordering, mocked DB + S3 ───────────────


@pytest.mark.asyncio
async def test_service_upload_signed_document_put_before_delete_unit():
    """Pure service-layer unit test (mocked db + s3) mirroring tests/test_documents.py's
    convention — asserts put_object precedes delete_object at the mock-call level."""
    from src.apps.sales.services.contract_document_service import ContractDocumentService

    tenant_id = str(uuid.uuid4())
    contract_id = uuid.uuid4()
    user_id = uuid.uuid4()

    contract = MagicMock()
    contract.id = contract_id
    contract.tenant_id = tenant_id
    contract.signed_at = MagicMock()  # non-None => signed
    contract.signed_document_s3_key = "tenant/x/contracts/y/signed/old-key.pdf"

    db = MagicMock()
    db.flush = AsyncMock()
    result_mock = MagicMock()
    result_mock.scalar_one_or_none.return_value = contract
    db.execute = AsyncMock(return_value=result_mock)

    current_user = MagicMock()
    current_user.id = user_id

    call_order: list[str] = []
    mock_s3 = MagicMock()
    mock_s3.put_object = MagicMock(side_effect=lambda **kw: call_order.append("put"))
    mock_s3.delete_object = MagicMock(side_effect=lambda **kw: call_order.append("delete"))

    with patch(
        "src.apps.sales.services.contract_document_service._make_s3_client",
        return_value=mock_s3,
    ):
        await ContractDocumentService.upload_signed_document(
            db=db,
            tenant_id=tenant_id,
            contract_id=contract_id,
            current_user=current_user,
            file_content=PDF_MAGIC,
            filename="signed.pdf",
            mime_type="application/pdf",
        )

    assert call_order == ["put", "delete"]
    assert contract.signed_document_uploaded_by == user_id
