"""INDL-03 AC-09 — AI record extraction.

Covers the two halves that carry the risk:

- Upload validation (SAC-06): size cap, magic-byte MIME sniffing, malformed
  PDFs. All of it must reject before a single byte reaches the AI provider.
- Output validation (SAC-12): the model's output is untrusted. Bad individual
  values are dropped, not propagated and not fatal.

The Azure client is always mocked — these tests never make a network call.
"""
from datetime import date, timedelta
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4

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

from src.apps.ai.services.extraction_service import _validate_extraction
from src.apps.auth.models.user import User
from src.apps.tenants.models.account import Account
from src.core.security import build_token_payload, create_access_token, hash_password

URL = "/api/v1/ai/extract-record"

# Smallest valid PNG (magic bytes are what libmagic keys on).
PNG_BYTES = (
    b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
    b"\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00\x01"
    b"\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82"
)


async def _make_account(db: AsyncSession, *, plan: str = "professional") -> Account:
    uid = uuid4().hex[:8]
    acc = Account(
        organization_name=f"Extract Cemetery {uid}",
        subdomain=f"extract-{uid}",
        contact_email=f"admin-{uid}@extract.test",
        plan=plan,
        status="active",
    )
    db.add(acc)
    await db.flush()
    return acc


async def _make_token(db: AsyncSession, account: Account) -> str:
    user = User(
        tenant_id=account.id,
        email=f"staff-{uuid4().hex[:6]}@extract.test",
        password_hash=hash_password("Test1234!"),
        first_name="Staff",
        last_name="User",
        role="staff",
        status="active",
    )
    db.add(user)
    await db.flush()
    return create_access_token(build_token_payload(user, account))


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


def _mock_completion(arguments: str) -> MagicMock:
    """Build a chat-completion response carrying one forced tool call."""
    tool_call = MagicMock()
    tool_call.function.arguments = arguments
    message = MagicMock()
    message.tool_calls = [tool_call]
    choice = MagicMock()
    choice.message = message
    completion = MagicMock()
    completion.choices = [choice]
    return completion


# ─────────────────────────────────────────────────────────────────────────────
# Output validation (SAC-12) — pure, no HTTP
# ─────────────────────────────────────────────────────────────────────────────

@pytest.mark.asyncio
async def test_valid_fields_are_coerced():
    record, dropped, _ = _validate_extraction(
        {
            "first_name": "Patricia",
            "last_name": "O'Brien",
            "date_of_birth": "1940-03-02",
            "attendees": "42",
            "interment_time": "14:30",
        }
    )
    assert record.first_name == "Patricia"
    assert record.date_of_birth == date(1940, 3, 2)
    assert record.attendees == 42
    assert dropped == []


@pytest.mark.asyncio
async def test_invalid_field_is_dropped_without_losing_the_rest():
    record, dropped, _ = _validate_extraction(
        {
            "first_name": "Patricia",
            "date_of_birth": "not-a-date",
            "maiden_name": "D" * 250,  # over the 100-char cap
        }
    )
    assert record.first_name == "Patricia"
    assert record.date_of_birth is None
    assert record.maiden_name is None
    assert set(dropped) == {"date_of_birth", "maiden_name"}


@pytest.mark.asyncio
async def test_unknown_field_from_the_model_is_dropped():
    record, dropped, _ = _validate_extraction(
        {"first_name": "Patricia", "ssn": "123-45-6789"}
    )
    assert dropped == ["ssn"]
    assert not hasattr(record, "ssn")


@pytest.mark.asyncio
async def test_future_date_of_death_is_dropped_with_a_warning():
    future = (date.today() + timedelta(days=30)).isoformat()
    record, dropped, warnings = _validate_extraction({"date_of_death": future})
    assert record.date_of_death is None
    assert "date_of_death" in dropped
    assert any("future" in w for w in warnings)


@pytest.mark.asyncio
async def test_death_before_birth_drops_both_dates():
    record, dropped, warnings = _validate_extraction(
        {"date_of_birth": "1990-01-01", "date_of_death": "1950-01-01"}
    )
    assert record.date_of_birth is None
    assert record.date_of_death is None
    assert {"date_of_birth", "date_of_death"} <= set(dropped)
    assert warnings


@pytest.mark.asyncio
async def test_one_bad_contact_does_not_lose_the_others():
    record, dropped, _ = _validate_extraction(
        {
            "contacts": [
                {"first_name": "John", "last_name": "O'Brien", "relationship": "Son"},
                {"relationship": "Neighbour"},  # no name — unusable
                {"first_name": "Mary", "last_name": "Doe", "unexpected": "x"},
            ]
        }
    )
    assert [c.first_name for c in record.contacts] == ["John", "Mary"]
    assert "contacts[1]" in dropped


# ─────────────────────────────────────────────────────────────────────────────
# Upload validation (SAC-06) — over HTTP
# ─────────────────────────────────────────────────────────────────────────────

@pytest.mark.asyncio
async def test_oversize_file_is_rejected_with_413(client: AsyncClient, db_session: AsyncSession):
    acc = await _make_account(db_session)
    token = await _make_token(db_session, acc)

    oversize = PNG_BYTES + b"\x00" * (11 * 1024 * 1024)
    resp = await client.post(
        URL,
        headers=_headers(token, acc),
        files={"file": ("big.png", oversize, "image/png")},
    )
    assert resp.status_code == 413


@pytest.mark.asyncio
async def test_disallowed_file_type_is_rejected_with_415(
    client: AsyncClient, db_session: AsyncSession
):
    acc = await _make_account(db_session)
    token = await _make_token(db_session, acc)

    # Declares image/png but the bytes are plain text — the sniff must win.
    resp = await client.post(
        URL,
        headers=_headers(token, acc),
        files={"file": ("evil.png", b"#!/bin/sh\necho pwned\n", "image/png")},
    )
    assert resp.status_code == 415


@pytest.mark.asyncio
async def test_empty_file_is_rejected(client: AsyncClient, db_session: AsyncSession):
    acc = await _make_account(db_session)
    token = await _make_token(db_session, acc)

    resp = await client.post(
        URL, headers=_headers(token, acc), files={"file": ("empty.png", b"", "image/png")}
    )
    assert resp.status_code == 400


# ─────────────────────────────────────────────────────────────────────────────
# Provider behaviour
# ─────────────────────────────────────────────────────────────────────────────

@pytest.mark.asyncio
async def test_happy_path_returns_extracted_fields(
    client: AsyncClient, db_session: AsyncSession
):
    acc = await _make_account(db_session)
    token = await _make_token(db_session, acc)

    mock_client = MagicMock()
    mock_client.chat.completions.create = AsyncMock(
        return_value=_mock_completion(
            '{"first_name": "Patricia", "last_name": "O\'Brien", '
            '"date_of_death": "2019-03-04"}'
        )
    )

    with patch("src.core.config.settings.AZURE_OPENAI_API_KEY", "test-key"), \
         patch("src.core.config.settings.AZURE_OPENAI_ENDPOINT", "https://test.invalid"), \
         patch("openai.AsyncAzureOpenAI", return_value=mock_client):
        resp = await client.post(
            URL,
            headers=_headers(token, acc),
            files={"file": ("ledger.png", PNG_BYTES, "image/png")},
        )

    assert resp.status_code == 200
    body = resp.json()
    assert body["success"] is True
    assert body["data"]["record"]["first_name"] == "Patricia"
    assert body["data"]["record"]["date_of_death"] == "2019-03-04"


@pytest.mark.asyncio
async def test_provider_error_returns_502_without_leaking_detail(
    client: AsyncClient, db_session: AsyncSession
):
    """API10 — the provider's own error text must never reach the client."""
    import openai

    acc = await _make_account(db_session)
    token = await _make_token(db_session, acc)

    secret = "azure-internal-trace-with-api-key-abcdef123456"
    mock_client = MagicMock()
    mock_client.chat.completions.create = AsyncMock(
        side_effect=openai.APIStatusError(
            secret, response=MagicMock(status_code=500), body=None
        )
    )

    with patch("src.core.config.settings.AZURE_OPENAI_API_KEY", "test-key"), \
         patch("src.core.config.settings.AZURE_OPENAI_ENDPOINT", "https://test.invalid"), \
         patch("openai.AsyncAzureOpenAI", return_value=mock_client):
        resp = await client.post(
            URL,
            headers=_headers(token, acc),
            files={"file": ("ledger.png", PNG_BYTES, "image/png")},
        )

    assert resp.status_code == 502
    assert secret not in resp.text


@pytest.mark.asyncio
async def test_unconfigured_provider_returns_503(
    client: AsyncClient, db_session: AsyncSession
):
    acc = await _make_account(db_session)
    token = await _make_token(db_session, acc)

    with patch("src.core.config.settings.AZURE_OPENAI_API_KEY", ""):
        resp = await client.post(
            URL,
            headers=_headers(token, acc),
            files={"file": ("ledger.png", PNG_BYTES, "image/png")},
        )

    assert resp.status_code == 503
