"""
QR Code System — INDL-47 "headstone" QR type delta tests.

These tests cover ONLY what INDL-47 added on top of the INDL-11 baseline
(already covered by tests/test_qr_codes.py):

  1. Headstone generation happy path (plot -> record -> memorial resolves
     content_url to /memorial/{slug})
  2. Headstone generation — plot exists, no memorial -> 404
  3. Headstone generation — plot doesn't exist -> 404
  4. Tenant isolation on headstone generation
  5. POST /qr-codes/bulk-headstones role gating (staff 403 / administrator 202)
  6. Bulk-headstones rate limiting (1 per 24h per tenant) -> 429 + Retry-After
  7. Bulk-headstones only includes occupied plots with a memorial
  8. extra="forbid" mass-assignment rejection on GenerateQRRequest
  9. reference_id validation (path-traversal / control-char rejection,
     legitimate punctuation acceptance)
  10. Cross-tenant download 404 (not covered by the INDL-11 baseline file)
  11. QRCodeResponse never leaks svg_s3_key / pdf_s3_key
  12. AuditService.log is invoked for generate / regenerate-all / bulk-headstones
  13. DELETE /qr-codes/{id} (soft-delete via is_active=False): role gating
      (staff 403 / administrator 204), cross-tenant 404, deactivated rows
      excluded from GET /qr-codes and GET /download, audit logging
"""
from __future__ import annotations

import datetime as dt
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4

import pytest
import pytest_asyncio
from httpx import AsyncClient
from pydantic import ValidationError
from sqlalchemy import select

# ---------------------------------------------------------------------------
# Fixtures — mirrors the pattern established in tests/test_qr_codes.py and
# tests/apps/public/test_memorial_page.py. Kept local/self-contained rather
# than importing from test_qr_codes.py (matches this repo's convention of
# each test module owning its own fixtures).
# ---------------------------------------------------------------------------


@pytest_asyncio.fixture
async def hs_tenant(db_session):
    from src.apps.tenants.models.account import Account

    account = Account(
        organization_name="Headstone QR Test Cemetery",
        subdomain=f"hs-test-{uuid4().hex[:8]}",
        contact_email=f"hs-{uuid4().hex[:6]}@qrtest.com",
        plan="starter",
        status="active",
    )
    db_session.add(account)
    await db_session.flush()
    return account


@pytest_asyncio.fixture
async def hs_tenant_other(db_session):
    """A second, distinct tenant used for cross-tenant isolation checks."""
    from src.apps.tenants.models.account import Account

    account = Account(
        organization_name="Other Cemetery",
        subdomain=f"hs-other-{uuid4().hex[:8]}",
        contact_email=f"hs-other-{uuid4().hex[:6]}@qrtest.com",
        plan="starter",
        status="active",
    )
    db_session.add(account)
    await db_session.flush()
    return account


@pytest_asyncio.fixture
async def hs_staff_user(db_session, hs_tenant):
    from src.apps.auth.models.user import User
    from src.core.security import hash_password

    user = User(
        tenant_id=hs_tenant.id,
        email=f"staff-{uuid4().hex[:6]}@qrtest.com",
        password_hash=hash_password("TestPassword123!"),
        first_name="Staff",
        last_name="HS",
        role="staff",
        status="active",
    )
    db_session.add(user)
    await db_session.flush()
    return user


@pytest_asyncio.fixture
async def hs_admin_user(db_session, hs_tenant):
    from src.apps.auth.models.user import User
    from src.core.security import hash_password

    user = User(
        tenant_id=hs_tenant.id,
        email=f"admin-{uuid4().hex[:6]}@qrtest.com",
        password_hash=hash_password("TestPassword123!"),
        first_name="Admin",
        last_name="HS",
        role="administrator",
        status="active",
    )
    db_session.add(user)
    await db_session.flush()
    return user


@pytest_asyncio.fixture
async def hs_admin_user_other(db_session, hs_tenant_other):
    from src.apps.auth.models.user import User
    from src.core.security import hash_password

    user = User(
        tenant_id=hs_tenant_other.id,
        email=f"admin-{uuid4().hex[:6]}@other.com",
        password_hash=hash_password("TestPassword123!"),
        first_name="Admin",
        last_name="Other",
        role="administrator",
        status="active",
    )
    db_session.add(user)
    await db_session.flush()
    return user


@pytest_asyncio.fixture
async def hs_staff_headers(hs_staff_user, hs_tenant):
    from src.core.security import create_access_token, build_token_payload

    token = create_access_token(build_token_payload(hs_staff_user, hs_tenant))
    return {
        "Authorization": f"Bearer {token}",
        "X-Tenant-ID": str(hs_tenant.id),
    }


@pytest_asyncio.fixture
async def hs_admin_headers(hs_admin_user, hs_tenant):
    from src.core.security import create_access_token, build_token_payload

    token = create_access_token(build_token_payload(hs_admin_user, hs_tenant))
    return {
        "Authorization": f"Bearer {token}",
        "X-Tenant-ID": str(hs_tenant.id),
    }


@pytest_asyncio.fixture
async def hs_admin_headers_other(hs_admin_user_other, hs_tenant_other):
    from src.core.security import create_access_token, build_token_payload

    token = create_access_token(
        build_token_payload(hs_admin_user_other, hs_tenant_other)
    )
    return {
        "Authorization": f"Bearer {token}",
        "X-Tenant-ID": str(hs_tenant_other.id),
    }


def _mock_arq_pool():
    """Standard mocked arq.create_pool context manager patch target."""
    mock_arq = AsyncMock()
    mock_arq.enqueue_job = AsyncMock(return_value=MagicMock(job_id="job-id"))
    mock_arq.aclose = AsyncMock()
    return mock_arq


async def _make_plot(db, tenant_id, *, plot_ref, status="occupied"):
    from src.apps.plots.models.plot import Plot

    plot = Plot(tenant_id=tenant_id, plot_ref=plot_ref, status=status)
    db.add(plot)
    await db.flush()
    return plot


async def _make_record(db, tenant_id, *, plot_id, first="Jane", last="Doe"):
    from src.apps.records.models.record import Record

    rec = Record(
        tenant_id=tenant_id,
        plot_id=plot_id,
        first_name=first,
        last_name=last,
        date_of_birth=dt.date(1940, 1, 1),
        date_of_death=dt.date(2020, 1, 1),
    )
    db.add(rec)
    await db.flush()
    return rec


async def _make_memorial(db, tenant_id, *, record_id, slug):
    from src.apps.memorials.models.memorial import Memorial

    mem = Memorial(
        tenant_id=tenant_id,
        record_id=record_id,
        slug=slug,
        is_published=True,
    )
    db.add(mem)
    await db.flush()
    return mem


async def _make_plot_with_memorial(db, tenant_id, *, plot_ref, slug, status="occupied"):
    plot = await _make_plot(db, tenant_id, plot_ref=plot_ref, status=status)
    rec = await _make_record(db, tenant_id, plot_id=plot.id)
    mem = await _make_memorial(db, tenant_id, record_id=rec.id, slug=slug)
    return plot, rec, mem


# ---------------------------------------------------------------------------
# 1-4. Headstone generate_one — happy path, no memorial, no plot, tenant isolation
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_generate_headstone_resolves_memorial_slug_in_content_url(
    client: AsyncClient, db_session, hs_staff_headers, hs_staff_user, hs_tenant
):
    """A headstone QR for a plot with a linked record + memorial must resolve
    content_url to /memorial/{slug}, not /find?plot=... like the 'plot' type."""
    await _make_plot_with_memorial(
        db_session, hs_tenant.id, plot_ref="A-100", slug="jane-doe-a100"
    )

    with patch("arq.create_pool", new_callable=AsyncMock) as mock_pool:
        mock_pool.return_value = _mock_arq_pool()
        resp = await client.post(
            "/api/v1/settings/qr-codes/generate",
            json={"qr_type": "headstone", "reference_id": "A-100"},
            headers=hs_staff_headers,
        )

    assert resp.status_code == 202, resp.text
    body = resp.json()
    assert body["success"] is True
    qr_id = body["data"]["qr_code_id"]

    from src.apps.settings.models.qr_code import QRCode

    row = (
        await db_session.execute(select(QRCode).where(QRCode.id == qr_id))
    ).scalar_one()
    assert row.qr_type == "headstone"
    assert row.content_url.endswith("/memorial/jane-doe-a100")
    assert "/find?plot=" not in row.content_url


@pytest.mark.asyncio
async def test_generate_headstone_plot_without_memorial_returns_404(
    client: AsyncClient, db_session, hs_staff_headers, hs_tenant
):
    """Plot exists but has no linked record/memorial -> clear 404, not a 500."""
    await _make_plot(db_session, hs_tenant.id, plot_ref="B-200", status="occupied")

    resp = await client.post(
        "/api/v1/settings/qr-codes/generate",
        json={"qr_type": "headstone", "reference_id": "B-200"},
        headers=hs_staff_headers,
    )

    assert resp.status_code == 404, resp.text
    body = resp.json()
    assert body["success"] is False
    assert "memorial" in body["message"].lower()


@pytest.mark.asyncio
async def test_generate_headstone_nonexistent_plot_returns_404(
    client: AsyncClient, hs_staff_headers
):
    """Plot doesn't exist at all -> 404 'Plot not found'."""
    resp = await client.post(
        "/api/v1/settings/qr-codes/generate",
        json={"qr_type": "headstone", "reference_id": "DOES-NOT-EXIST"},
        headers=hs_staff_headers,
    )

    assert resp.status_code == 404, resp.text
    body = resp.json()
    assert body["success"] is False
    assert "plot not found" in body["message"].lower()


@pytest.mark.asyncio
async def test_generate_headstone_cross_tenant_plot_returns_404(
    client: AsyncClient, db_session, hs_staff_headers, hs_tenant, hs_tenant_other
):
    """A plot_ref that exists in a DIFFERENT tenant must not be resolvable —
    request scoped to hs_tenant must 404, never touching hs_tenant_other's row."""
    await _make_plot_with_memorial(
        db_session,
        hs_tenant_other.id,
        plot_ref="SHARED-REF",
        slug="other-tenant-memorial",
    )

    resp = await client.post(
        "/api/v1/settings/qr-codes/generate",
        json={"qr_type": "headstone", "reference_id": "SHARED-REF"},
        headers=hs_staff_headers,
    )

    assert resp.status_code == 404, resp.text
    assert "plot not found" in resp.json()["message"].lower()


# ---------------------------------------------------------------------------
# 5. bulk-headstones role gating
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_bulk_headstones_staff_forbidden(client: AsyncClient, hs_staff_headers):
    resp = await client.post(
        "/api/v1/settings/qr-codes/bulk-headstones",
        headers=hs_staff_headers,
    )
    assert resp.status_code == 403


@pytest.mark.asyncio
async def test_bulk_headstones_administrator_returns_202_with_shape(
    client: AsyncClient, db_session, hs_admin_headers, hs_tenant
):
    await _make_plot_with_memorial(
        db_session, hs_tenant.id, plot_ref="C-001", slug="mem-c001"
    )
    await _make_plot_with_memorial(
        db_session, hs_tenant.id, plot_ref="C-002", slug="mem-c002"
    )

    mock_redis = MagicMock()
    mock_redis.incr = AsyncMock(return_value=1)
    mock_redis.expire = AsyncMock()
    mock_redis.ttl = AsyncMock(return_value=86400)
    mock_redis.aclose = AsyncMock()

    with patch(
        "src.database.session.get_redis", AsyncMock(return_value=mock_redis)
    ), patch("arq.create_pool", new_callable=AsyncMock) as mock_pool:
        mock_pool.return_value = _mock_arq_pool()
        resp = await client.post(
            "/api/v1/settings/qr-codes/bulk-headstones",
            headers=hs_admin_headers,
        )

    assert resp.status_code == 202, resp.text
    body = resp.json()
    assert body["success"] is True
    assert body["data"] == {"enqueued": 2, "plot_count": 2}


# ---------------------------------------------------------------------------
# 6. bulk-headstones rate limiting
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_bulk_headstones_second_call_within_24h_returns_429(
    client: AsyncClient, db_session, hs_admin_headers, hs_tenant
):
    """First call within the window succeeds; a second call for the same
    tenant within 24h is rejected with 429 + Retry-After.

    Redis is mocked (matching the repo's established convention — see
    tests/test_email_templates.py's SEC-10 rate-limit test) rather than
    relying on a real Redis instance being reachable in the test env.
    """
    await _make_plot_with_memorial(
        db_session, hs_tenant.id, plot_ref="D-001", slug="mem-d001"
    )

    call_count = {"n": 0}

    async def fake_incr(key):
        call_count["n"] += 1
        return call_count["n"]

    mock_redis = MagicMock()
    mock_redis.incr = AsyncMock(side_effect=fake_incr)
    mock_redis.expire = AsyncMock()
    mock_redis.ttl = AsyncMock(return_value=86399)
    mock_redis.aclose = AsyncMock()

    with patch(
        "src.database.session.get_redis", AsyncMock(return_value=mock_redis)
    ), patch("arq.create_pool", new_callable=AsyncMock) as mock_pool:
        mock_pool.return_value = _mock_arq_pool()

        first = await client.post(
            "/api/v1/settings/qr-codes/bulk-headstones",
            headers=hs_admin_headers,
        )
        second = await client.post(
            "/api/v1/settings/qr-codes/bulk-headstones",
            headers=hs_admin_headers,
        )

    assert first.status_code == 202, first.text
    assert second.status_code == 429, second.text
    assert "Retry-After" in second.headers


# ---------------------------------------------------------------------------
# 7. bulk-headstones excludes vacant/reserved and memorial-less occupied plots
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_bulk_headstones_excludes_non_qualifying_plots(
    client: AsyncClient, db_session, hs_admin_headers, hs_tenant
):
    # Qualifies: occupied + memorial
    await _make_plot_with_memorial(
        db_session, hs_tenant.id, plot_ref="E-QUALIFIES", slug="mem-e-qualifies"
    )
    # Does not qualify: vacant, no record/memorial at all
    await _make_plot(db_session, hs_tenant.id, plot_ref="E-VACANT", status="vacant")
    # Does not qualify: reserved, no record/memorial at all
    await _make_plot(db_session, hs_tenant.id, plot_ref="E-RESERVED", status="reserved")
    # Does not qualify: occupied but no memorial
    await _make_plot(db_session, hs_tenant.id, plot_ref="E-NO-MEMORIAL", status="occupied")
    occupied_no_memorial = await _make_plot(
        db_session, hs_tenant.id, plot_ref="E-NO-MEMORIAL-2", status="occupied"
    )
    await _make_record(db_session, hs_tenant.id, plot_id=occupied_no_memorial.id)

    mock_redis = MagicMock()
    mock_redis.incr = AsyncMock(return_value=1)
    mock_redis.expire = AsyncMock()
    mock_redis.aclose = AsyncMock()

    with patch(
        "src.database.session.get_redis", AsyncMock(return_value=mock_redis)
    ), patch("arq.create_pool", new_callable=AsyncMock) as mock_pool:
        mock_pool.return_value = _mock_arq_pool()
        resp = await client.post(
            "/api/v1/settings/qr-codes/bulk-headstones",
            headers=hs_admin_headers,
        )

    assert resp.status_code == 202, resp.text
    body = resp.json()["data"]
    assert body["plot_count"] == 1
    assert body["enqueued"] == 1

    from src.apps.settings.models.qr_code import QRCode

    rows = (
        await db_session.execute(
            select(QRCode.reference_id).where(
                QRCode.tenant_id == hs_tenant.id, QRCode.qr_type == "headstone"
            )
        )
    ).scalars().all()
    assert rows == ["E-QUALIFIES"]


# ---------------------------------------------------------------------------
# 8. extra="forbid" mass-assignment rejection
# ---------------------------------------------------------------------------


class TestExtraForbid:
    def test_generate_request_rejects_extra_content_url_field(self):
        from src.apps.settings.schemas.requests import GenerateQRRequest

        with pytest.raises(ValidationError):
            GenerateQRRequest(
                qr_type="plot",
                reference_id="A-1",
                content_url="https://evil.example.com/",
            )

    def test_generate_request_rejects_extra_svg_s3_key_field(self):
        from src.apps.settings.schemas.requests import GenerateQRRequest

        with pytest.raises(ValidationError):
            GenerateQRRequest(
                qr_type="plot", reference_id="A-1", svg_s3_key="qr/a1.svg"
            )

    def test_regenerate_all_request_rejects_extra_field(self):
        from src.apps.settings.schemas.requests import RegenerateAllRequest

        with pytest.raises(ValidationError):
            RegenerateAllRequest(qr_type="plot", enqueued=999)

    @pytest.mark.asyncio
    async def test_generate_endpoint_rejects_extra_field_via_api(
        self, client: AsyncClient, hs_staff_headers
    ):
        resp = await client.post(
            "/api/v1/settings/qr-codes/generate",
            json={
                "qr_type": "entrance",
                "reference_id": "gate",
                "content_url": "https://evil.example.com/",
            },
            headers=hs_staff_headers,
        )
        assert resp.status_code == 422


# ---------------------------------------------------------------------------
# 9. reference_id validation — path traversal / control chars vs legitimate values
# ---------------------------------------------------------------------------


class TestReferenceIdValidation:
    @pytest.mark.parametrize(
        "bad_value",
        [
            "A..B",
            "../etc/passwd",
            "A\r\nB",
            "A\rB",
            "A\nB",
            "A\x00B",
            "..",
        ],
    )
    def test_rejects_dangerous_reference_id(self, bad_value):
        from src.apps.settings.schemas.requests import GenerateQRRequest

        with pytest.raises(ValidationError):
            GenerateQRRequest(qr_type="plot", reference_id=bad_value)

    @pytest.mark.parametrize(
        "good_value",
        [
            "Row 2 (Old)",
            "A.12",
            "B/12",
            "B-204",
            "Plot A.1 (North)",
        ],
    )
    def test_accepts_legitimate_reference_id(self, good_value):
        from src.apps.settings.schemas.requests import GenerateQRRequest

        req = GenerateQRRequest(qr_type="plot", reference_id=good_value)
        assert req.reference_id == good_value

    @pytest.mark.asyncio
    async def test_api_rejects_path_traversal_reference_id(
        self, client: AsyncClient, hs_staff_headers
    ):
        resp = await client.post(
            "/api/v1/settings/qr-codes/generate",
            json={"qr_type": "plot", "reference_id": "../../etc/passwd"},
            headers=hs_staff_headers,
        )
        assert resp.status_code == 422

    @pytest.mark.asyncio
    async def test_api_accepts_legitimate_punctuation_reference_id(
        self, client: AsyncClient, db_session, hs_staff_headers, hs_tenant
    ):
        await _make_plot(db_session, hs_tenant.id, plot_ref="Row 2 (Old)")

        with patch("arq.create_pool", new_callable=AsyncMock) as mock_pool:
            mock_pool.return_value = _mock_arq_pool()
            resp = await client.post(
                "/api/v1/settings/qr-codes/generate",
                json={"qr_type": "plot", "reference_id": "Row 2 (Old)"},
                headers=hs_staff_headers,
            )
        assert resp.status_code == 202, resp.text


# ---------------------------------------------------------------------------
# 10. Cross-tenant download 404
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_download_cross_tenant_qr_returns_404(
    client: AsyncClient,
    db_session,
    hs_staff_headers,
    hs_tenant,
    hs_admin_headers_other,
    hs_tenant_other,
):
    """A QR code created under hs_tenant_other must not be downloadable using
    hs_tenant's credentials — must 404, not leak the other tenant's file."""
    from src.apps.settings.models.qr_code import QRCode

    other_qr = QRCode(
        tenant_id=hs_tenant_other.id,
        qr_type="entrance",
        reference_id="main-gate",
        content_url="https://other.indelis.com/",
        is_active=True,
    )
    db_session.add(other_qr)
    await db_session.flush()

    resp = await client.get(
        f"/api/v1/settings/qr-codes/{other_qr.id}/download",
        headers=hs_staff_headers,
    )
    assert resp.status_code == 404, resp.text


# ---------------------------------------------------------------------------
# 11. QRCodeResponse never leaks svg_s3_key / pdf_s3_key
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_list_response_excludes_s3_key_fields(
    client: AsyncClient, db_session, hs_staff_headers, hs_tenant
):
    from src.apps.settings.models.qr_code import QRCode

    qr = QRCode(
        tenant_id=hs_tenant.id,
        qr_type="entrance",
        reference_id="main-gate",
        content_url="https://hs-test.indelis.com/",
        is_active=True,
        svg_s3_key="qr/secret-svg-key.svg",
        pdf_s3_key="qr/secret-pdf-key.pdf",
    )
    db_session.add(qr)
    await db_session.flush()

    resp = await client.get("/api/v1/settings/qr-codes", headers=hs_staff_headers)
    assert resp.status_code == 200
    raw_text = resp.text
    assert "svg_s3_key" not in raw_text
    assert "pdf_s3_key" not in raw_text
    assert "secret-svg-key" not in raw_text
    assert "secret-pdf-key" not in raw_text


# ---------------------------------------------------------------------------
# 12. Audit logging is invoked
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_generate_invokes_audit_log_with_generate_action(
    client: AsyncClient, hs_staff_headers
):
    with patch("arq.create_pool", new_callable=AsyncMock) as mock_pool, patch(
        "src.apps.settings.router.AuditService.log", new_callable=AsyncMock
    ) as mock_audit:
        mock_pool.return_value = _mock_arq_pool()
        resp = await client.post(
            "/api/v1/settings/qr-codes/generate",
            json={"qr_type": "entrance", "reference_id": "main-gate"},
            headers=hs_staff_headers,
        )

    assert resp.status_code == 202
    mock_audit.assert_awaited_once()
    args = mock_audit.call_args.args
    # AuditService.log(db, entity_type, entity_id, action, user, request, ...)
    assert args[1] == "qr_codes"
    assert args[3] == "generate"


@pytest.mark.asyncio
async def test_regenerate_all_invokes_audit_log_with_regenerate_all_action(
    client: AsyncClient, hs_admin_headers
):
    with patch("arq.create_pool", new_callable=AsyncMock) as mock_pool, patch(
        "src.apps.settings.router.AuditService.log", new_callable=AsyncMock
    ) as mock_audit:
        mock_pool.return_value = _mock_arq_pool()
        resp = await client.post(
            "/api/v1/settings/qr-codes/regenerate-all",
            json={},
            headers=hs_admin_headers,
        )

    assert resp.status_code == 202
    mock_audit.assert_awaited_once()
    args = mock_audit.call_args.args
    assert args[1] == "qr_codes"
    assert args[3] == "regenerate_all"


@pytest.mark.asyncio
async def test_bulk_headstones_invokes_audit_log_with_bulk_headstone_export_action(
    client: AsyncClient, db_session, hs_admin_headers, hs_tenant
):
    await _make_plot_with_memorial(
        db_session, hs_tenant.id, plot_ref="F-001", slug="mem-f001"
    )

    mock_redis = MagicMock()
    mock_redis.incr = AsyncMock(return_value=1)
    mock_redis.expire = AsyncMock()
    mock_redis.aclose = AsyncMock()

    with patch(
        "src.database.session.get_redis", AsyncMock(return_value=mock_redis)
    ), patch("arq.create_pool", new_callable=AsyncMock) as mock_pool, patch(
        "src.apps.settings.router.AuditService.log", new_callable=AsyncMock
    ) as mock_audit:
        mock_pool.return_value = _mock_arq_pool()
        resp = await client.post(
            "/api/v1/settings/qr-codes/bulk-headstones",
            headers=hs_admin_headers,
        )

    assert resp.status_code == 202
    mock_audit.assert_awaited_once()
    args = mock_audit.call_args.args
    assert args[1] == "qr_codes"
    assert args[3] == "bulk_headstone_export"


# ---------------------------------------------------------------------------
# 13. DELETE /qr-codes/{id} — soft-delete (is_active=False)
# ---------------------------------------------------------------------------


async def _make_qr(db_session, tenant_id, **overrides):
    from src.apps.settings.models.qr_code import QRCode

    defaults = dict(
        tenant_id=tenant_id,
        qr_type="plot",
        reference_id="Z-999",
        content_url="https://hs-test.indelis.com/find?plot=Z-999",
        is_active=True,
        svg_s3_key="qr/plot/Z-999.svg",
        pdf_s3_key="qr/plot/Z-999.pdf",
    )
    defaults.update(overrides)
    qr = QRCode(**defaults)
    db_session.add(qr)
    await db_session.flush()
    return qr


@pytest.mark.asyncio
async def test_delete_requires_auth(client: AsyncClient, hs_tenant):
    resp = await client.delete(
        f"/api/v1/settings/qr-codes/{uuid4()}",
        headers={"X-Tenant-ID": str(hs_tenant.id)},
    )
    assert resp.status_code == 401


@pytest.mark.asyncio
async def test_delete_staff_forbidden(
    client: AsyncClient, db_session, hs_staff_headers, hs_tenant
):
    """Staff role is below ADMINISTRATOR — deletion must return 403."""
    qr = await _make_qr(db_session, hs_tenant.id)

    resp = await client.delete(
        f"/api/v1/settings/qr-codes/{qr.id}",
        headers=hs_staff_headers,
    )
    assert resp.status_code == 403


@pytest.mark.asyncio
async def test_delete_nonexistent_qr_returns_404(
    client: AsyncClient, hs_admin_headers
):
    resp = await client.delete(
        f"/api/v1/settings/qr-codes/{uuid4()}",
        headers=hs_admin_headers,
    )
    assert resp.status_code == 404


@pytest.mark.asyncio
async def test_delete_cross_tenant_qr_returns_404(
    client: AsyncClient, db_session, hs_admin_headers, hs_tenant_other
):
    """An administrator of hs_tenant must not be able to delete a QR code
    belonging to a different tenant — must 404, not leak/affect it."""
    other_qr = await _make_qr(db_session, hs_tenant_other.id, reference_id="OTHER-1")

    resp = await client.delete(
        f"/api/v1/settings/qr-codes/{other_qr.id}",
        headers=hs_admin_headers,
    )
    assert resp.status_code == 404

    # Confirm the other tenant's row was untouched.
    await db_session.refresh(other_qr)
    assert other_qr.is_active is True


@pytest.mark.asyncio
async def test_delete_administrator_succeeds_and_deactivates(
    client: AsyncClient, db_session, hs_admin_headers, hs_tenant
):
    qr = await _make_qr(db_session, hs_tenant.id)

    resp = await client.delete(
        f"/api/v1/settings/qr-codes/{qr.id}",
        headers=hs_admin_headers,
    )
    assert resp.status_code == 204
    assert resp.content == b""

    await db_session.refresh(qr)
    assert qr.is_active is False


@pytest.mark.asyncio
async def test_deleted_qr_excluded_from_list(
    client: AsyncClient, db_session, hs_admin_headers, hs_tenant
):
    qr = await _make_qr(db_session, hs_tenant.id, reference_id="Z-DELETED")

    del_resp = await client.delete(
        f"/api/v1/settings/qr-codes/{qr.id}", headers=hs_admin_headers
    )
    assert del_resp.status_code == 204

    list_resp = await client.get(
        "/api/v1/settings/qr-codes", headers=hs_admin_headers
    )
    assert list_resp.status_code == 200
    ids = [item["id"] for item in list_resp.json()["data"]]
    assert str(qr.id) not in ids


@pytest.mark.asyncio
async def test_deleted_qr_download_returns_404(
    client: AsyncClient, db_session, hs_admin_headers, hs_tenant
):
    """Once deactivated, the download endpoint must treat it as gone —
    deletion must not leave the artifact reachable by ID."""
    qr = await _make_qr(db_session, hs_tenant.id, reference_id="Z-GONE")

    del_resp = await client.delete(
        f"/api/v1/settings/qr-codes/{qr.id}", headers=hs_admin_headers
    )
    assert del_resp.status_code == 204

    download_resp = await client.get(
        f"/api/v1/settings/qr-codes/{qr.id}/download", headers=hs_admin_headers
    )
    assert download_resp.status_code == 404


@pytest.mark.asyncio
async def test_delete_invokes_audit_log_with_delete_action(
    client: AsyncClient, db_session, hs_admin_headers, hs_tenant
):
    qr = await _make_qr(db_session, hs_tenant.id, reference_id="Z-AUDIT")

    with patch(
        "src.apps.settings.router.AuditService.log", new_callable=AsyncMock
    ) as mock_audit:
        resp = await client.delete(
            f"/api/v1/settings/qr-codes/{qr.id}", headers=hs_admin_headers
        )

    assert resp.status_code == 204
    mock_audit.assert_awaited_once()
    args = mock_audit.call_args.args
    assert args[1] == "qr_codes"
    assert args[3] == "delete"
