"""Tests for Phase 15c — SPEC-015 Features."""

from __future__ import annotations

import hashlib
from datetime import UTC, datetime, timedelta
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch

from backend.dashboard.middleware import CustomerContext
from backend.dashboard.routes import router
from fastapi import FastAPI
from fastapi.testclient import TestClient

# ---------------------------------------------------------------------------
# Patch targets — definition site (lesson 1.43)
# ---------------------------------------------------------------------------

_PATCH_SESSION_GET = "backend.dashboard.session.get_session"
_PATCH_SESSION_COOKIE = "backend.dashboard.session.get_cookie_name"
_PATCH_SESSION_VALIDATE = "backend.dashboard.session.validate_csrf"
_PATCH_FIRESTORE_DB = "backend.shared.firestore.get_db"
_PATCH_GCS_DOWNLOAD = "backend.shared.gcs.download_findings"
_PATCH_BCRYPT_HASHPW = "backend.dashboard.routes.bcrypt.hashpw"
_PATCH_BCRYPT_CHECKPW = "backend.dashboard.routes.bcrypt.checkpw"
_PATCH_BCRYPT_GENSALT = "backend.dashboard.routes.bcrypt.gensalt"
_PATCH_SECRETS = "backend.dashboard.routes.secrets.token_urlsafe"
_PATCH_CACHED_PROJECTS = "backend.dashboard.cache.get_cached_projects"
_PATCH_CACHED_FINDINGS = "backend.dashboard.cache.get_cached_findings"
_PATCH_SET_CACHED_FINDINGS = "backend.dashboard.cache.set_cached_findings"


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _ctx(
    tier: str = "team",
    customer_id: str = "cus_test",
    email: str = "user@example.com",
    role: str = "owner",
    features: list[str] | None = None,
) -> CustomerContext:
    if features is None:
        features = ["hosted_dashboard", "shared_scan_links"]
    return CustomerContext(
        customer_id=customer_id,
        email=email,
        tier=tier,
        role=role,
        member_email=email,
        features=features,
        project_limit=1,
        batch_limit=5,
        cancellation_pending=False,
        subscription_expires_at="2027-01-01T00:00:00+00:00",
        consent_accepted=True,
        tos_version="1.0",
        privacy_version="1.0",
    )


def _session_data() -> dict[str, Any]:
    return {
        "session_id": "sess_abc123",
        "customer_id": "cus_test",
        "email": "user@example.com",
        "tier": "team",
        "csrf_token": "test-csrf-token",
        "ip_hash": "fakehash",
        "user_agent_hash": "fakehash",
        "created_at": datetime.now(UTC).isoformat(),
        "last_accessed_at": datetime.now(UTC).isoformat(),
        "expires_at": (datetime.now(UTC) + timedelta(days=7)),
        "theme_preference": None,
    }


def _sample_findings(count: int = 3) -> list[dict[str, Any]]:
    """Generate sample findings with distinct control_ids."""
    findings = []
    for i in range(count):
        findings.append(
            {
                "control_id": f"CC6.{i + 1}",
                "control_title": f"Control {i + 1}",
                "framework": "soc2" if i % 2 == 0 else "hipaa",
                "severity": ["critical", "high", "medium"][i % 3],
                "status": "FAIL" if i < 2 else "PASS",
                "resource": f"aws_s3_bucket.bucket_{i}",
                "cross_mappings": [f"NIST-{i}"],
                "frameworks": ["soc2"] if i % 2 == 0 else ["hipaa"],
            }
        )
    return findings


def _make_app(ctx: CustomerContext | None = None) -> FastAPI:
    """Create test app with auth dependency overridden."""
    from backend.dashboard.middleware import require_dashboard_auth

    app = FastAPI()
    app.include_router(router)
    if ctx is None:
        ctx = _ctx()
    app.dependency_overrides[require_dashboard_auth] = lambda: ctx
    return app


def _mock_firestore_for_project(
    project_data: dict[str, Any] | None = None,
    scan_data: dict[str, Any] | None = None,
    shared_links: list[dict[str, Any]] | None = None,
) -> MagicMock:
    """Create chainable mock Firestore DB for project + scan + shared_links."""
    mock_db = MagicMock()

    # Project doc
    proj_doc = MagicMock()
    if project_data is not None:
        proj_doc.exists = True
        proj_doc.id = project_data.get("project_id", "proj_1")
        proj_doc.to_dict.return_value = project_data
    else:
        proj_doc.exists = False
        proj_doc.id = "proj_1"
        proj_doc.to_dict.return_value = {}

    # Scan doc
    scan_doc = MagicMock()
    if scan_data is not None:
        scan_doc.exists = True
        scan_doc.to_dict.return_value = scan_data
    else:
        scan_doc.exists = False
        scan_doc.to_dict.return_value = {}

    # Shared links docs
    async def _stream_links() -> Any:
        if shared_links:
            for sl in shared_links:
                link_doc = MagicMock()
                link_doc.id = sl.get("token_hash", "hash_x")
                link_doc.to_dict.return_value = sl
                link_doc.reference = MagicMock()
                link_doc.reference.parent = MagicMock()
                link_doc.reference.parent.parent = MagicMock()
                pid = (
                    project_data.get("project_id", "proj_1")
                    if project_data
                    else "proj_1"
                )
                link_doc.reference.parent.parent.path = (
                    f"customers/cus_test/projects/{pid}"
                )
                yield link_doc

    mock_links_query = MagicMock()
    mock_links_query.where = MagicMock(return_value=mock_links_query)
    mock_links_query.stream = _stream_links

    mock_links_coll = MagicMock()
    mock_links_coll.where = MagicMock(return_value=mock_links_query)

    mock_link_doc_ref = MagicMock()
    mock_link_doc_ref.set = AsyncMock()
    mock_link_doc_ref.update = AsyncMock()
    mock_links_coll.document = MagicMock(return_value=mock_link_doc_ref)

    # Scan history subcollection
    mock_scan_doc_ref = MagicMock()
    mock_scan_doc_ref.get = AsyncMock(return_value=scan_doc)

    mock_scan_history_coll = MagicMock()
    mock_scan_history_coll.document = MagicMock(return_value=mock_scan_doc_ref)

    # Project doc ref — needs .collection() for scan_history and shared_links
    mock_proj_doc_ref = MagicMock()
    mock_proj_doc_ref.get = AsyncMock(return_value=proj_doc)

    def _proj_subcollection(name: str) -> MagicMock:
        if name == "scan_history":
            return mock_scan_history_coll
        if name == "shared_links":
            return mock_links_coll
        return MagicMock()

    mock_proj_doc_ref.collection = MagicMock(side_effect=_proj_subcollection)
    mock_proj_doc_ref.update = AsyncMock()

    # Projects collection
    mock_projects_coll = MagicMock()
    mock_projects_coll.document = MagicMock(return_value=mock_proj_doc_ref)

    # Customer doc ref
    mock_customer_doc_ref = MagicMock()
    mock_customer_doc_ref.collection = MagicMock(return_value=mock_projects_coll)

    # Customers collection
    mock_customers_coll = MagicMock()
    mock_customers_coll.document = MagicMock(return_value=mock_customer_doc_ref)

    # db.collection("customers")
    mock_db.collection = MagicMock(return_value=mock_customers_coll)

    # db.document(parent_path) for increment
    mock_parent_doc = MagicMock()
    mock_parent_doc.collection = MagicMock(return_value=mock_links_coll)
    mock_db.document = MagicMock(return_value=mock_parent_doc)

    # db.collection_group("shared_links") for public view
    mock_cg_query = MagicMock()
    mock_cg_query.where = MagicMock(return_value=mock_cg_query)
    mock_cg_query.stream = _stream_links
    mock_db.collection_group = MagicMock(return_value=mock_cg_query)

    return mock_db


_DEFAULT_PROJECT = {
    "project_id": "proj_1",
    "project_label": "Production",
    "client_label": None,
    "cloud": "aws",
    "frameworks": ["soc2"],
    "status": "active",
    "score": 85.0,
    "score_delta": 2.0,
    "passed": 10,
    "failed": 3,
    "severity_counts": {"critical": 1, "high": 2, "medium": 3, "low": 4},
    "last_scan_at": "2025-01-15T10:00:00+00:00",
    "drift_monitoring": False,
    "drift_mode": None,
    "drift_last_event_at": None,
    "has_cloud_intelligence": False,
    "cross_validation_score": None,
    "latest_scan": "scan_001",
}


# ===================================================================
# Controls Tests
# ===================================================================


class TestControls:
    """Tests for controls partial."""

    def test_controls_groups_by_control_id(self) -> None:
        """3 findings with 3 distinct control_ids → 3 rows."""
        findings = _sample_findings(3)
        app = _make_app()
        mock_db = _mock_firestore_for_project(_DEFAULT_PROJECT)
        with (
            patch(_PATCH_FIRESTORE_DB, return_value=mock_db),
            patch(_PATCH_CACHED_FINDINGS, return_value=findings),
        ):
            client = TestClient(app)
            client.cookies.set("__Host-session", "encrypted")
            resp = client.get("/dashboard/partials/controls/proj_1")
        assert resp.status_code == 200
        html = resp.text
        assert "CC6.1" in html
        assert "CC6.2" in html
        assert "CC6.3" in html

    def test_controls_pass_fail_counts(self) -> None:
        """2 fail + 1 pass → shown in summary."""
        findings = _sample_findings(3)
        app = _make_app()
        mock_db = _mock_firestore_for_project(_DEFAULT_PROJECT)
        with (
            patch(_PATCH_FIRESTORE_DB, return_value=mock_db),
            patch(_PATCH_CACHED_FINDINGS, return_value=findings),
        ):
            client = TestClient(app)
            client.cookies.set("__Host-session", "encrypted")
            resp = client.get("/dashboard/partials/controls/proj_1")
        assert resp.status_code == 200
        # 2 fail, 1 pass (CC6.3 status is PASS)
        assert "2 fail" in resp.text
        assert "1 pass" in resp.text

    def test_controls_filter_framework(self) -> None:
        """?framework=soc2 → only SOC2 controls."""
        findings = _sample_findings(3)
        app = _make_app()
        mock_db = _mock_firestore_for_project(_DEFAULT_PROJECT)
        with (
            patch(_PATCH_FIRESTORE_DB, return_value=mock_db),
            patch(_PATCH_CACHED_FINDINGS, return_value=findings),
        ):
            client = TestClient(app)
            client.cookies.set("__Host-session", "encrypted")
            resp = client.get("/dashboard/partials/controls/proj_1?framework=soc2")
        assert resp.status_code == 200
        assert "CC6.1" in resp.text
        assert "CC6.3" in resp.text
        # CC6.2 is hipaa, should not appear as a control row
        assert "CC6.2" not in resp.text

    def test_controls_filter_status(self) -> None:
        """?status_filter=fail → only failing controls."""
        findings = _sample_findings(3)
        app = _make_app()
        mock_db = _mock_firestore_for_project(_DEFAULT_PROJECT)
        with (
            patch(_PATCH_FIRESTORE_DB, return_value=mock_db),
            patch(_PATCH_CACHED_FINDINGS, return_value=findings),
        ):
            client = TestClient(app)
            client.cookies.set("__Host-session", "encrypted")
            resp = client.get("/dashboard/partials/controls/proj_1?status_filter=fail")
        assert resp.status_code == 200
        assert "CC6.1" in resp.text  # fail
        assert "CC6.2" in resp.text  # fail
        assert "CC6.3" not in resp.text  # pass

    def test_controls_pagination(self) -> None:
        """More than 25 controls → paginated."""
        # Create 30 findings with distinct control_ids
        findings = []
        for i in range(30):
            findings.append(
                {
                    "control_id": f"CTRL-{i:03d}",
                    "control_title": f"Control {i}",
                    "framework": "soc2",
                    "severity": "medium",
                    "status": "FAIL",
                    "resource": f"resource_{i}",
                    "cross_mappings": [],
                    "frameworks": ["soc2"],
                }
            )
        app = _make_app()
        mock_db = _mock_firestore_for_project(_DEFAULT_PROJECT)
        with (
            patch(_PATCH_FIRESTORE_DB, return_value=mock_db),
            patch(_PATCH_CACHED_FINDINGS, return_value=findings),
        ):
            client = TestClient(app)
            client.cookies.set("__Host-session", "encrypted")
            resp = client.get("/dashboard/partials/controls/proj_1")
        assert resp.status_code == 200
        assert "Load more" in resp.text

    def test_controls_expand_row(self) -> None:
        """?expand=CC6.1 → resource detail rows."""
        findings = _sample_findings(3)
        app = _make_app()
        mock_db = _mock_firestore_for_project(_DEFAULT_PROJECT)
        with (
            patch(_PATCH_FIRESTORE_DB, return_value=mock_db),
            patch(_PATCH_CACHED_FINDINGS, return_value=findings),
        ):
            client = TestClient(app)
            client.cookies.set("__Host-session", "encrypted")
            resp = client.get("/dashboard/partials/controls/proj_1?expand=CC6.1")
        assert resp.status_code == 200
        assert "aws_s3_bucket.bucket_0" in resp.text

    def test_controls_zero_firestore_reads(self) -> None:
        """Uses cached findings — no GCS download."""
        findings = _sample_findings(3)
        app = _make_app()
        mock_db = _mock_firestore_for_project(_DEFAULT_PROJECT)
        with (
            patch(_PATCH_FIRESTORE_DB, return_value=mock_db),
            patch(_PATCH_CACHED_FINDINGS, return_value=findings),
        ):
            client = TestClient(app)
            client.cookies.set("__Host-session", "encrypted")
            resp = client.get("/dashboard/partials/controls/proj_1")
        assert resp.status_code == 200


# ===================================================================
# Command Palette Tests
# ===================================================================


class TestCommandPalette:
    """Tests for command palette search."""

    def test_palette_search_project(self) -> None:
        """?q=prod → project match."""
        from backend.dashboard.context import DashboardProjectCard

        projects = [
            DashboardProjectCard(
                project_id="proj_1",
                project_label="Production",
                client_label=None,
                cloud="aws",
                frameworks=["soc2"],
                status="active",
                score=85.0,
                score_delta=2.0,
                passed=10,
                failed=3,
                severity_counts={"critical": 0, "high": 0, "medium": 0, "low": 0},
                last_scan_at="2025-01-15",
                drift_monitoring=False,
                drift_mode=None,
                drift_last_event_at=None,
                has_cloud_intelligence=False,
                cross_validation_score=None,
            )
        ]

        app = _make_app()
        with (
            patch(_PATCH_CACHED_PROJECTS, return_value=projects),
            patch(_PATCH_CACHED_FINDINGS, return_value=None),
        ):
            client = TestClient(app)
            client.cookies.set("__Host-session", "encrypted")
            resp = client.get("/dashboard/partials/command-palette?q=prod")
        assert resp.status_code == 200
        assert "Production" in resp.text

    def test_palette_search_page(self) -> None:
        """?q=sett → settings page match."""
        app = _make_app()
        with (
            patch(_PATCH_CACHED_PROJECTS, return_value=[]),
            patch(_PATCH_CACHED_FINDINGS, return_value=None),
        ):
            client = TestClient(app)
            client.cookies.set("__Host-session", "encrypted")
            resp = client.get("/dashboard/partials/command-palette?q=sett")
        assert resp.status_code == 200
        assert "Settings" in resp.text
        assert "/dashboard/settings" in resp.text

    def test_palette_search_control(self) -> None:
        """?q=CC6 → control match from cached findings."""
        from backend.dashboard.context import DashboardProjectCard

        projects = [
            DashboardProjectCard(
                project_id="proj_1",
                project_label="Prod",
                client_label=None,
                cloud="aws",
                frameworks=["soc2"],
                status="active",
                score=85.0,
                score_delta=None,
                passed=10,
                failed=3,
                severity_counts={"critical": 0, "high": 0, "medium": 0, "low": 0},
                last_scan_at=None,
                drift_monitoring=False,
                drift_mode=None,
                drift_last_event_at=None,
                has_cloud_intelligence=False,
                cross_validation_score=None,
            )
        ]
        cached_findings = [
            {"control_id": "CC6.1", "control_title": "Logical Access"},
        ]

        app = _make_app()
        # get_cached_findings is called with (customer_id, project_id, "")
        with (
            patch(_PATCH_CACHED_PROJECTS, return_value=projects),
            patch(_PATCH_CACHED_FINDINGS, return_value=cached_findings),
        ):
            client = TestClient(app)
            client.cookies.set("__Host-session", "encrypted")
            resp = client.get("/dashboard/partials/command-palette?q=CC6")
        assert resp.status_code == 200
        assert "CC6.1" in resp.text

    def test_palette_max_8(self) -> None:
        """20 matches → max 8 returned."""
        from backend.dashboard.context import DashboardProjectCard

        projects = [
            DashboardProjectCard(
                project_id=f"proj_{i}",
                project_label=f"Match Project {i}",
                client_label=None,
                cloud="aws",
                frameworks=[],
                status="active",
                score=None,
                score_delta=None,
                passed=0,
                failed=0,
                severity_counts={"critical": 0, "high": 0, "medium": 0, "low": 0},
                last_scan_at=None,
                drift_monitoring=False,
                drift_mode=None,
                drift_last_event_at=None,
                has_cloud_intelligence=False,
                cross_validation_score=None,
            )
            for i in range(20)
        ]

        app = _make_app()
        with (
            patch(_PATCH_CACHED_PROJECTS, return_value=projects),
            patch(_PATCH_CACHED_FINDINGS, return_value=None),
        ):
            client = TestClient(app)
            client.cookies.set("__Host-session", "encrypted")
            resp = client.get("/dashboard/partials/command-palette?q=match")
        assert resp.status_code == 200
        assert resp.text.count('role="option"') == 8

    def test_palette_no_firestore(self) -> None:
        """Command palette makes zero Firestore calls."""
        app = _make_app()
        with (
            patch(_PATCH_CACHED_PROJECTS, return_value=[]),
            patch(_PATCH_CACHED_FINDINGS, return_value=None),
            patch(_PATCH_FIRESTORE_DB) as mock_db,
        ):
            client = TestClient(app)
            client.cookies.set("__Host-session", "encrypted")
            resp = client.get("/dashboard/partials/command-palette?q=test")
        assert resp.status_code == 200
        mock_db.assert_not_called()

    def test_palette_empty_query(self) -> None:
        """Empty query → empty results."""
        app = _make_app()
        client = TestClient(app)
        resp = client.get("/dashboard/partials/command-palette?q=")
        assert resp.status_code == 200
        assert resp.text == ""

    def test_palette_case_insensitive(self) -> None:
        """?q=PROD matches 'Production'."""
        from backend.dashboard.context import DashboardProjectCard

        projects = [
            DashboardProjectCard(
                project_id="proj_1",
                project_label="Production",
                client_label=None,
                cloud="aws",
                frameworks=[],
                status="active",
                score=None,
                score_delta=None,
                passed=0,
                failed=0,
                severity_counts={"critical": 0, "high": 0, "medium": 0, "low": 0},
                last_scan_at=None,
                drift_monitoring=False,
                drift_mode=None,
                drift_last_event_at=None,
                has_cloud_intelligence=False,
                cross_validation_score=None,
            )
        ]

        app = _make_app()
        with (
            patch(_PATCH_CACHED_PROJECTS, return_value=projects),
            patch(_PATCH_CACHED_FINDINGS, return_value=None),
        ):
            client = TestClient(app)
            client.cookies.set("__Host-session", "encrypted")
            resp = client.get("/dashboard/partials/command-palette?q=PROD")
        assert resp.status_code == 200
        assert "Production" in resp.text


# ===================================================================
# Share Link Creation Tests
# ===================================================================


class TestShareLinkCreation:
    """Tests for POST share link creation."""

    def _post_share(
        self,
        app: FastAPI,
        mock_db: MagicMock,
        body: dict[str, Any] | None = None,
    ) -> Any:
        if body is None:
            body = {"expires_in_days": 7, "scope": "full"}
        with (
            patch(_PATCH_FIRESTORE_DB, return_value=mock_db),
            patch(_PATCH_SESSION_GET, return_value=_session_data()),
            patch(_PATCH_SESSION_COOKIE, return_value="__Host-session"),
            patch(_PATCH_SESSION_VALIDATE, return_value=True),
            patch(_PATCH_SECRETS, return_value="test_token_abc123"),
        ):
            client = TestClient(app)
            client.cookies.set("__Host-session", "encrypted")
            return client.post(
                "/dashboard/projects/proj_1/scans/scan_001/share",
                json=body,
                headers={
                    "X-CSRF-Token": "test-csrf-token",
                    "content-type": "application/json",
                },
            )

    def test_create_link_success(self) -> None:
        """Owner Team tier → 200 + url + token."""
        app = _make_app()
        mock_db = _mock_firestore_for_project(_DEFAULT_PROJECT, shared_links=[])
        resp = self._post_share(app, mock_db)
        assert resp.status_code == 200
        data = resp.json()
        assert "share_url" in data
        assert "token" in data
        assert "expires_at" in data

    def test_create_link_viewer_403(self) -> None:
        """Viewer → 403."""
        app = _make_app(_ctx(role="viewer"))
        mock_db = _mock_firestore_for_project(_DEFAULT_PROJECT, shared_links=[])
        resp = self._post_share(app, mock_db)
        assert resp.status_code == 403

    def test_create_link_tier_gate(self) -> None:
        """No shared_scan_links feature → 403."""
        app = _make_app(_ctx(features=["hosted_dashboard"]))
        mock_db = _mock_firestore_for_project(_DEFAULT_PROJECT, shared_links=[])
        resp = self._post_share(app, mock_db)
        assert resp.status_code == 403

    def test_create_link_limit_10(self) -> None:
        """10 active links → error #97 (ShareLinkLimitError)."""
        now = datetime.now(UTC)
        active_links = [
            {
                "token_hash": f"hash_{i}",
                "revoked": False,
                "expires_at": (now + timedelta(days=7)).isoformat(),
                "scan_id": "scan_001",
            }
            for i in range(10)
        ]
        app = _make_app()
        mock_db = _mock_firestore_for_project(
            _DEFAULT_PROJECT, shared_links=active_links
        )
        with (
            patch(_PATCH_FIRESTORE_DB, return_value=mock_db),
            patch(_PATCH_SESSION_GET, return_value=_session_data()),
            patch(_PATCH_SESSION_COOKIE, return_value="__Host-session"),
            patch(_PATCH_SESSION_VALIDATE, return_value=True),
        ):
            client = TestClient(app, raise_server_exceptions=False)
            client.cookies.set("__Host-session", "encrypted")
            resp = client.post(
                "/dashboard/projects/proj_1/scans/scan_001/share",
                json={"expires_in_days": 7, "scope": "full"},
                headers={
                    "X-CSRF-Token": "test-csrf-token",
                    "content-type": "application/json",
                },
            )
        # Unhandled ComplyFormError → 500
        assert resp.status_code == 500

    def test_create_link_password(self) -> None:
        """Password provided → bcrypt stored."""
        app = _make_app()
        mock_db = _mock_firestore_for_project(_DEFAULT_PROJECT, shared_links=[])
        with (
            patch(_PATCH_FIRESTORE_DB, return_value=mock_db),
            patch(_PATCH_SESSION_GET, return_value=_session_data()),
            patch(_PATCH_SESSION_COOKIE, return_value="__Host-session"),
            patch(_PATCH_SESSION_VALIDATE, return_value=True),
            patch(_PATCH_SECRETS, return_value="test_token_abc123"),
            patch(_PATCH_BCRYPT_GENSALT, return_value=b"$2b$12$salt"),
            patch(_PATCH_BCRYPT_HASHPW, return_value=b"$2b$12$hashed_password"),
        ):
            client = TestClient(app)
            client.cookies.set("__Host-session", "encrypted")
            resp = client.post(
                "/dashboard/projects/proj_1/scans/scan_001/share",
                json={"expires_in_days": 7, "password": "secret123", "scope": "full"},
                headers={
                    "X-CSRF-Token": "test-csrf-token",
                    "content-type": "application/json",
                },
            )
        assert resp.status_code == 200
        # Verify bcrypt was called with the password
        data = resp.json()
        assert "share_url" in data

    def test_create_link_scope(self) -> None:
        """Scope stored correctly."""
        app = _make_app()
        mock_db = _mock_firestore_for_project(_DEFAULT_PROJECT, shared_links=[])
        resp = self._post_share(
            app, mock_db, body={"expires_in_days": 7, "scope": "summary"}
        )
        assert resp.status_code == 200

    def test_create_link_expiry_bounds(self) -> None:
        """0 → 422, 31 → 422."""
        app = _make_app()
        mock_db = _mock_firestore_for_project(_DEFAULT_PROJECT, shared_links=[])

        resp = self._post_share(app, mock_db, body={"expires_in_days": 0})
        assert resp.status_code == 422

        resp = self._post_share(app, mock_db, body={"expires_in_days": 31})
        assert resp.status_code == 422


# ===================================================================
# Revocation Tests
# ===================================================================


class TestRevocation:
    """Tests for DELETE share link revocation."""

    def test_revoke_link(self) -> None:
        """revoked=True, 204."""
        app = _make_app()
        mock_db = _mock_firestore_for_project(_DEFAULT_PROJECT)
        with (
            patch(_PATCH_FIRESTORE_DB, return_value=mock_db),
            patch(_PATCH_SESSION_GET, return_value=_session_data()),
            patch(_PATCH_SESSION_COOKIE, return_value="__Host-session"),
            patch(_PATCH_SESSION_VALIDATE, return_value=True),
        ):
            client = TestClient(app)
            client.cookies.set("__Host-session", "encrypted")
            resp = client.delete(
                "/dashboard/projects/proj_1/shared-links/hash_abc",
                headers={"X-CSRF-Token": "test-csrf-token"},
            )
        assert resp.status_code == 204

    def test_revoke_link_viewer(self) -> None:
        """Viewer → 403."""
        app = _make_app(_ctx(role="viewer"))
        mock_db = _mock_firestore_for_project(_DEFAULT_PROJECT)
        with (
            patch(_PATCH_FIRESTORE_DB, return_value=mock_db),
            patch(_PATCH_SESSION_GET, return_value=_session_data()),
            patch(_PATCH_SESSION_COOKIE, return_value="__Host-session"),
            patch(_PATCH_SESSION_VALIDATE, return_value=True),
        ):
            client = TestClient(app)
            client.cookies.set("__Host-session", "encrypted")
            resp = client.delete(
                "/dashboard/projects/proj_1/shared-links/hash_abc",
                headers={"X-CSRF-Token": "test-csrf-token"},
            )
        assert resp.status_code == 403


# ===================================================================
# Links List Tests
# ===================================================================


class TestLinksList:
    """Tests for GET shared links list."""

    def test_links_list(self) -> None:
        """3 active → 3 rows."""
        now = datetime.now(UTC)
        links = [
            {
                "token_hash": f"hash_{i}",
                "scan_id": "scan_001",
                "scope": "full",
                "expires_at": (now + timedelta(days=7)).isoformat(),
                "access_count": i,
                "revoked": False,
            }
            for i in range(3)
        ]
        app = _make_app()
        mock_db = _mock_firestore_for_project(_DEFAULT_PROJECT, shared_links=links)
        with patch(_PATCH_FIRESTORE_DB, return_value=mock_db):
            client = TestClient(app)
            client.cookies.set("__Host-session", "encrypted")
            resp = client.get("/dashboard/partials/shared-links/proj_1")
        assert resp.status_code == 200
        assert resp.text.count("hx-delete") == 3

    def test_links_excludes_revoked(self) -> None:
        """Revoked links not shown (they shouldn't appear in query)."""
        now = datetime.now(UTC)
        links = [
            {
                "token_hash": "hash_1",
                "scan_id": "scan_001",
                "scope": "full",
                "expires_at": (now + timedelta(days=7)).isoformat(),
                "access_count": 0,
                "revoked": False,
            },
        ]
        app = _make_app()
        mock_db = _mock_firestore_for_project(_DEFAULT_PROJECT, shared_links=links)
        with patch(_PATCH_FIRESTORE_DB, return_value=mock_db):
            client = TestClient(app)
            client.cookies.set("__Host-session", "encrypted")
            resp = client.get("/dashboard/partials/shared-links/proj_1")
        assert resp.status_code == 200
        # Only 1 active link shown
        assert resp.text.count("hx-delete") == 1

    def test_links_excludes_expired(self) -> None:
        """Expired links not shown."""
        now = datetime.now(UTC)
        links = [
            {
                "token_hash": "hash_1",
                "scan_id": "scan_001",
                "scope": "full",
                "expires_at": (now - timedelta(days=1)).isoformat(),
                "access_count": 0,
                "revoked": False,
            },
        ]
        app = _make_app()
        mock_db = _mock_firestore_for_project(_DEFAULT_PROJECT, shared_links=links)
        with patch(_PATCH_FIRESTORE_DB, return_value=mock_db):
            client = TestClient(app)
            client.cookies.set("__Host-session", "encrypted")
            resp = client.get("/dashboard/partials/shared-links/proj_1")
        assert resp.status_code == 200
        assert "No active shared links" in resp.text


# ===================================================================
# Public Shared Scan View Tests
# ===================================================================


class TestSharedScanView:
    """Tests for GET /dashboard/shared/{token} — no auth."""

    def _make_public_app(self) -> FastAPI:
        """Create app without auth override (public routes don't need it)."""
        from backend.dashboard.middleware import require_dashboard_auth

        app = FastAPI()
        app.include_router(router)
        # Override auth for non-public routes to avoid failures
        app.dependency_overrides[require_dashboard_auth] = lambda: _ctx()
        return app

    def _mock_for_public(
        self,
        link_data: dict[str, Any] | None = None,
        project_data: dict[str, Any] | None = None,
        findings: list[dict[str, Any]] | None = None,
    ) -> tuple[MagicMock, MagicMock]:
        """Mock DB for public shared scan view."""
        mock_db = MagicMock()

        # Collection group query
        if link_data:
            link_doc = MagicMock()
            link_doc.id = link_data.get("token_hash", "hash_x")
            link_doc.to_dict.return_value = link_data
            link_doc.reference = MagicMock()
            link_doc.reference.parent = MagicMock()
            link_doc.reference.parent.parent = MagicMock()
            link_doc.reference.parent.parent.path = "customers/cus_test/projects/proj_1"

            async def _stream_cg() -> Any:
                yield link_doc

            mock_cg_query = MagicMock()
            mock_cg_query.where = MagicMock(return_value=mock_cg_query)
            mock_cg_query.stream = _stream_cg
        else:

            async def _stream_empty() -> Any:
                return
                yield  # noqa: RET504

            mock_cg_query = MagicMock()
            mock_cg_query.where = MagicMock(return_value=mock_cg_query)
            mock_cg_query.stream = _stream_empty

        mock_db.collection_group = MagicMock(return_value=mock_cg_query)

        # db.document(parent_path).get() for project data
        proj_doc = MagicMock()
        if project_data:
            proj_doc.exists = True
            proj_doc.to_dict.return_value = project_data
        else:
            proj_doc.exists = False
            proj_doc.to_dict.return_value = {}

        mock_parent_ref = MagicMock()
        mock_parent_ref.get = AsyncMock(return_value=proj_doc)

        # For increment: db.document(path).collection(...)
        mock_link_doc_ref = MagicMock()
        mock_link_doc_ref.update = AsyncMock()
        incr_doc = MagicMock()
        incr_doc.exists = True
        incr_doc.to_dict.return_value = {"access_count": 0}
        mock_link_doc_ref.get = AsyncMock(return_value=incr_doc)
        mock_links_coll = MagicMock()
        mock_links_coll.document = MagicMock(return_value=mock_link_doc_ref)
        mock_parent_ref.collection = MagicMock(return_value=mock_links_coll)

        mock_db.document = MagicMock(return_value=mock_parent_ref)

        mock_gcs = AsyncMock(return_value=findings or [])

        return mock_db, mock_gcs

    def test_shared_scan_renders(self) -> None:
        """Valid token → 200 + findings."""
        now = datetime.now(UTC)
        token = "valid_token_abc"
        token_hash = hashlib.sha256(token.encode()).hexdigest()
        link = {
            "token_hash": token_hash,
            "scan_id": "scan_001",
            "revoked": False,
            "expires_at": (now + timedelta(days=7)).isoformat(),
            "password_hash": None,
            "scope": "full",
            "access_count": 0,
        }
        project = {
            "project_label": "Production",
            "cloud": "aws",
            "frameworks": ["soc2"],
            "score": 85.0,
            "last_scan_at": "2025-01-15",
        }
        findings = _sample_findings(2)

        app = self._make_public_app()
        mock_db, mock_gcs = self._mock_for_public(link, project, findings)
        with (
            patch(_PATCH_FIRESTORE_DB, return_value=mock_db),
            patch(_PATCH_GCS_DOWNLOAD, mock_gcs),
        ):
            client = TestClient(app, raise_server_exceptions=False)
            client.cookies.set("__Host-session", "encrypted")
            resp = client.get(f"/dashboard/shared/{token}")
        assert resp.status_code == 200
        assert "Production" in resp.text
        assert "CC6.1" in resp.text

    def test_shared_scan_not_found(self) -> None:
        """Bad token → ShareLinkNotFoundError (#96)."""
        app = self._make_public_app()
        mock_db, mock_gcs = self._mock_for_public(link_data=None)
        with (
            patch(_PATCH_FIRESTORE_DB, return_value=mock_db),
            patch(_PATCH_GCS_DOWNLOAD, mock_gcs),
        ):
            client = TestClient(app, raise_server_exceptions=False)
            client.cookies.set("__Host-session", "encrypted")
            resp = client.get("/dashboard/shared/bad_token")
        assert resp.status_code == 500  # Unhandled ComplyFormError

    def test_shared_scan_expired(self) -> None:
        """Expired → ShareLinkExpiredError (#95)."""
        now = datetime.now(UTC)
        token = "expired_token"
        token_hash = hashlib.sha256(token.encode()).hexdigest()
        link = {
            "token_hash": token_hash,
            "scan_id": "scan_001",
            "revoked": False,
            "expires_at": (now - timedelta(days=1)).isoformat(),
            "password_hash": None,
            "scope": "full",
        }
        app = self._make_public_app()
        mock_db, mock_gcs = self._mock_for_public(link)
        with (
            patch(_PATCH_FIRESTORE_DB, return_value=mock_db),
            patch(_PATCH_GCS_DOWNLOAD, mock_gcs),
        ):
            client = TestClient(app, raise_server_exceptions=False)
            client.cookies.set("__Host-session", "encrypted")
            resp = client.get(f"/dashboard/shared/{token}")
        assert resp.status_code == 500  # ShareLinkExpiredError

    def test_shared_scan_revoked(self) -> None:
        """Revoked → ShareLinkExpiredError (#95)."""
        now = datetime.now(UTC)
        token = "revoked_token"
        token_hash = hashlib.sha256(token.encode()).hexdigest()
        link = {
            "token_hash": token_hash,
            "scan_id": "scan_001",
            "revoked": True,
            "expires_at": (now + timedelta(days=7)).isoformat(),
            "password_hash": None,
            "scope": "full",
        }
        app = self._make_public_app()
        mock_db, mock_gcs = self._mock_for_public(link)
        with (
            patch(_PATCH_FIRESTORE_DB, return_value=mock_db),
            patch(_PATCH_GCS_DOWNLOAD, mock_gcs),
        ):
            client = TestClient(app, raise_server_exceptions=False)
            client.cookies.set("__Host-session", "encrypted")
            resp = client.get(f"/dashboard/shared/{token}")
        assert resp.status_code == 500  # ShareLinkExpiredError (revoked)

    def test_shared_scan_password_prompt(self) -> None:
        """GET with password_hash → password form."""
        now = datetime.now(UTC)
        token = "pw_token"
        token_hash = hashlib.sha256(token.encode()).hexdigest()
        link = {
            "token_hash": token_hash,
            "scan_id": "scan_001",
            "revoked": False,
            "expires_at": (now + timedelta(days=7)).isoformat(),
            "password_hash": "$2b$12$hashed",
            "scope": "full",
        }
        app = self._make_public_app()
        mock_db, mock_gcs = self._mock_for_public(link)
        with (
            patch(_PATCH_FIRESTORE_DB, return_value=mock_db),
            patch(_PATCH_GCS_DOWNLOAD, mock_gcs),
        ):
            client = TestClient(app, raise_server_exceptions=False)
            client.cookies.set("__Host-session", "encrypted")
            resp = client.get(f"/dashboard/shared/{token}")
        assert resp.status_code == 200
        assert "Password Required" in resp.text
        assert 'type="password"' in resp.text

    def test_shared_scan_password_correct(self) -> None:
        """POST correct password → renders scan."""
        now = datetime.now(UTC)
        token = "pw_token"
        token_hash = hashlib.sha256(token.encode()).hexdigest()
        link = {
            "token_hash": token_hash,
            "scan_id": "scan_001",
            "revoked": False,
            "expires_at": (now + timedelta(days=7)).isoformat(),
            "password_hash": "$2b$12$hashed",
            "scope": "full",
            "access_count": 0,
        }
        project = {
            "project_label": "Production",
            "cloud": "aws",
            "frameworks": ["soc2"],
            "score": 85.0,
            "last_scan_at": "2025-01-15",
        }

        app = self._make_public_app()
        mock_db, mock_gcs = self._mock_for_public(link, project, _sample_findings(1))
        with (
            patch(_PATCH_FIRESTORE_DB, return_value=mock_db),
            patch(_PATCH_GCS_DOWNLOAD, mock_gcs),
            patch(_PATCH_BCRYPT_CHECKPW, return_value=True),
        ):
            client = TestClient(app, raise_server_exceptions=False)
            client.cookies.set("__Host-session", "encrypted")
            resp = client.post(
                f"/dashboard/shared/{token}",
                data={"password": "correct"},
            )
        assert resp.status_code == 200
        assert "Production" in resp.text

    def test_shared_scan_password_wrong(self) -> None:
        """POST wrong password → SharedScanPasswordError (#94)."""
        now = datetime.now(UTC)
        token = "pw_token"
        token_hash = hashlib.sha256(token.encode()).hexdigest()
        link = {
            "token_hash": token_hash,
            "scan_id": "scan_001",
            "revoked": False,
            "expires_at": (now + timedelta(days=7)).isoformat(),
            "password_hash": "$2b$12$hashed",
            "scope": "full",
        }
        app = self._make_public_app()
        mock_db, mock_gcs = self._mock_for_public(link)
        with (
            patch(_PATCH_FIRESTORE_DB, return_value=mock_db),
            patch(_PATCH_GCS_DOWNLOAD, mock_gcs),
            patch(_PATCH_BCRYPT_CHECKPW, return_value=False),
        ):
            client = TestClient(app, raise_server_exceptions=False)
            client.cookies.set("__Host-session", "encrypted")
            resp = client.post(
                f"/dashboard/shared/{token}",
                data={"password": "wrong"},
            )
        assert resp.status_code == 500  # SharedScanPasswordError

    def test_shared_scan_access_count(self) -> None:
        """Access count increments."""
        now = datetime.now(UTC)
        token = "count_token"
        token_hash = hashlib.sha256(token.encode()).hexdigest()
        link = {
            "token_hash": token_hash,
            "scan_id": "scan_001",
            "revoked": False,
            "expires_at": (now + timedelta(days=7)).isoformat(),
            "password_hash": None,
            "scope": "full",
            "access_count": 5,
        }
        project = {
            "project_label": "Prod",
            "cloud": "aws",
            "frameworks": [],
            "score": 90.0,
            "last_scan_at": "2025-01-15",
        }

        app = self._make_public_app()
        mock_db, mock_gcs = self._mock_for_public(link, project, [])

        with (
            patch(_PATCH_FIRESTORE_DB, return_value=mock_db),
            patch(_PATCH_GCS_DOWNLOAD, mock_gcs),
        ):
            client = TestClient(app, raise_server_exceptions=False)
            client.cookies.set("__Host-session", "encrypted")
            resp = client.get(f"/dashboard/shared/{token}")
        assert resp.status_code == 200
        # Verify update was called (access_count incremented)
        parent = mock_db.document.return_value
        link_ref = parent.collection.return_value
        link_ref.document.return_value.update.assert_called_once()

    def test_shared_scan_summary_redaction(self) -> None:
        """scope=summary → 'Resource #1' instead of real addresses."""
        now = datetime.now(UTC)
        token = "summary_token"
        token_hash = hashlib.sha256(token.encode()).hexdigest()
        link = {
            "token_hash": token_hash,
            "scan_id": "scan_001",
            "revoked": False,
            "expires_at": (now + timedelta(days=7)).isoformat(),
            "password_hash": None,
            "scope": "summary",
            "access_count": 0,
        }
        project = {
            "project_label": "Prod",
            "cloud": "aws",
            "frameworks": [],
            "score": 90.0,
            "last_scan_at": "2025-01-15",
        }
        findings = [
            {
                "control_id": "CC6.1",
                "control_title": "Test",
                "severity": "high",
                "resource": "aws_s3.real_bucket",
            },
        ]

        app = self._make_public_app()
        mock_db, mock_gcs = self._mock_for_public(link, project, findings)
        with (
            patch(_PATCH_FIRESTORE_DB, return_value=mock_db),
            patch(_PATCH_GCS_DOWNLOAD, mock_gcs),
        ):
            client = TestClient(app, raise_server_exceptions=False)
            client.cookies.set("__Host-session", "encrypted")
            resp = client.get(f"/dashboard/shared/{token}")
        assert resp.status_code == 200
        assert "Resource #1" in resp.text
        assert "aws_s3.real_bucket" not in resp.text

    def test_shared_scan_full_scope(self) -> None:
        """scope=full → real resource addresses."""
        now = datetime.now(UTC)
        token = "full_token"
        token_hash = hashlib.sha256(token.encode()).hexdigest()
        link = {
            "token_hash": token_hash,
            "scan_id": "scan_001",
            "revoked": False,
            "expires_at": (now + timedelta(days=7)).isoformat(),
            "password_hash": None,
            "scope": "full",
            "access_count": 0,
        }
        project = {
            "project_label": "Prod",
            "cloud": "aws",
            "frameworks": [],
            "score": 90.0,
            "last_scan_at": "2025-01-15",
        }
        findings = [
            {
                "control_id": "CC6.1",
                "control_title": "Test",
                "severity": "high",
                "resource": "aws_s3.real_bucket",
            },
        ]

        app = self._make_public_app()
        mock_db, mock_gcs = self._mock_for_public(link, project, findings)
        with (
            patch(_PATCH_FIRESTORE_DB, return_value=mock_db),
            patch(_PATCH_GCS_DOWNLOAD, mock_gcs),
        ):
            client = TestClient(app, raise_server_exceptions=False)
            client.cookies.set("__Host-session", "encrypted")
            resp = client.get(f"/dashboard/shared/{token}")
        assert resp.status_code == 200
        assert "aws_s3.real_bucket" in resp.text

    def test_shared_scan_no_base_html(self) -> None:
        """Shared scan does not contain nav/command palette."""
        now = datetime.now(UTC)
        token = "nobase_token"
        token_hash = hashlib.sha256(token.encode()).hexdigest()
        link = {
            "token_hash": token_hash,
            "scan_id": "scan_001",
            "revoked": False,
            "expires_at": (now + timedelta(days=7)).isoformat(),
            "password_hash": None,
            "scope": "full",
            "access_count": 0,
        }
        project = {
            "project_label": "Prod",
            "cloud": "aws",
            "frameworks": [],
            "score": 90.0,
            "last_scan_at": "2025-01-15",
        }

        app = self._make_public_app()
        mock_db, mock_gcs = self._mock_for_public(link, project, [])
        with (
            patch(_PATCH_FIRESTORE_DB, return_value=mock_db),
            patch(_PATCH_GCS_DOWNLOAD, mock_gcs),
        ):
            client = TestClient(app, raise_server_exceptions=False)
            client.cookies.set("__Host-session", "encrypted")
            resp = client.get(f"/dashboard/shared/{token}")
        assert resp.status_code == 200
        # No navigation or command palette
        assert "command-palette" not in resp.text
        assert "htmx" not in resp.text.lower() or "htmx" not in resp.text

    def test_shared_scan_no_cookie(self) -> None:
        """No session cookie set on shared scan response."""
        now = datetime.now(UTC)
        token = "nocookie_token"
        token_hash = hashlib.sha256(token.encode()).hexdigest()
        link = {
            "token_hash": token_hash,
            "scan_id": "scan_001",
            "revoked": False,
            "expires_at": (now + timedelta(days=7)).isoformat(),
            "password_hash": None,
            "scope": "full",
            "access_count": 0,
        }
        project = {
            "project_label": "Prod",
            "cloud": "aws",
            "frameworks": [],
            "score": 90.0,
            "last_scan_at": "2025-01-15",
        }

        app = self._make_public_app()
        mock_db, mock_gcs = self._mock_for_public(link, project, [])
        with (
            patch(_PATCH_FIRESTORE_DB, return_value=mock_db),
            patch(_PATCH_GCS_DOWNLOAD, mock_gcs),
        ):
            client = TestClient(app, raise_server_exceptions=False)
            client.cookies.set("__Host-session", "encrypted")
            resp = client.get(f"/dashboard/shared/{token}")
        assert resp.status_code == 200
        assert "__Host-session" not in resp.headers.get("set-cookie", "")

    def test_shared_scan_csp(self) -> None:
        """frame-ancestors 'none' in response headers."""
        now = datetime.now(UTC)
        token = "csp_token"
        token_hash = hashlib.sha256(token.encode()).hexdigest()
        link = {
            "token_hash": token_hash,
            "scan_id": "scan_001",
            "revoked": False,
            "expires_at": (now + timedelta(days=7)).isoformat(),
            "password_hash": None,
            "scope": "full",
            "access_count": 0,
        }
        project = {
            "project_label": "Prod",
            "cloud": "aws",
            "frameworks": [],
            "score": 90.0,
            "last_scan_at": "2025-01-15",
        }

        app = self._make_public_app()
        mock_db, mock_gcs = self._mock_for_public(link, project, [])
        with (
            patch(_PATCH_FIRESTORE_DB, return_value=mock_db),
            patch(_PATCH_GCS_DOWNLOAD, mock_gcs),
        ):
            client = TestClient(app, raise_server_exceptions=False)
            client.cookies.set("__Host-session", "encrypted")
            resp = client.get(f"/dashboard/shared/{token}")
        assert resp.status_code == 200
        assert "frame-ancestors 'none'" in resp.headers.get(
            "content-security-policy", ""
        )


# ===================================================================
# Scan Detail Tests
# ===================================================================


class TestScanDetail:
    """Tests for scan detail page."""

    def test_scan_detail_renders(self) -> None:
        """200 + date header."""
        scan_data = {"scanned_at": "2025-01-15T10:00:00", "score": 85.0}
        app = _make_app()
        mock_db = _mock_firestore_for_project(_DEFAULT_PROJECT, scan_data, [])
        with patch(_PATCH_FIRESTORE_DB, return_value=mock_db):
            client = TestClient(app)
            client.cookies.set("__Host-session", "encrypted")
            resp = client.get("/dashboard/projects/proj_1/scans/scan_001")
        assert resp.status_code == 200
        assert "Scan from" in resp.text

    def test_scan_detail_share_visible(self) -> None:
        """Owner Team → share button visible."""
        scan_data = {"scanned_at": "2025-01-15T10:00:00"}
        app = _make_app(
            _ctx(
                role="owner",
                features=["hosted_dashboard", "shared_scan_links"],
            )
        )
        mock_db = _mock_firestore_for_project(_DEFAULT_PROJECT, scan_data, [])
        with patch(_PATCH_FIRESTORE_DB, return_value=mock_db):
            client = TestClient(app)
            client.cookies.set("__Host-session", "encrypted")
            resp = client.get("/dashboard/projects/proj_1/scans/scan_001")
        assert resp.status_code == 200
        assert "Share" in resp.text

    def test_scan_detail_share_hidden_viewer(self) -> None:
        """Viewer → no share button."""
        scan_data = {"scanned_at": "2025-01-15T10:00:00"}
        app = _make_app(
            _ctx(
                role="viewer",
                features=["hosted_dashboard", "shared_scan_links"],
            )
        )
        mock_db = _mock_firestore_for_project(_DEFAULT_PROJECT, scan_data, [])
        with patch(_PATCH_FIRESTORE_DB, return_value=mock_db):
            client = TestClient(app)
            client.cookies.set("__Host-session", "encrypted")
            resp = client.get("/dashboard/projects/proj_1/scans/scan_001")
        assert resp.status_code == 200
        assert "open-share-modal" not in resp.text

    def test_scan_detail_share_hidden_pro(self) -> None:
        """Pro tier (no shared_scan_links feature) → no share button."""
        scan_data = {"scanned_at": "2025-01-15T10:00:00"}
        app = _make_app(_ctx(role="owner", features=["hosted_dashboard"]))
        mock_db = _mock_firestore_for_project(_DEFAULT_PROJECT, scan_data, [])
        with patch(_PATCH_FIRESTORE_DB, return_value=mock_db):
            client = TestClient(app)
            client.cookies.set("__Host-session", "encrypted")
            resp = client.get("/dashboard/projects/proj_1/scans/scan_001")
        assert resp.status_code == 200
        assert "open-share-modal" not in resp.text

    def test_scan_detail_readonly_tabs(self) -> None:
        """3 tabs only: Findings, Controls, Cloud Intel."""
        scan_data = {"scanned_at": "2025-01-15T10:00:00"}
        app = _make_app()
        mock_db = _mock_firestore_for_project(_DEFAULT_PROJECT, scan_data, [])
        with patch(_PATCH_FIRESTORE_DB, return_value=mock_db):
            client = TestClient(app)
            client.cookies.set("__Host-session", "encrypted")
            resp = client.get("/dashboard/projects/proj_1/scans/scan_001")
        assert resp.status_code == 200
        assert "Findings" in resp.text
        assert "Controls" in resp.text
        assert "Cloud Intel" in resp.text
        # Should NOT have History or Drift tabs
        assert "Scan History" not in resp.text
        assert "Drift" not in resp.text

    def test_scan_detail_shared_links_load(self) -> None:
        """htmx trigger for shared links section."""
        scan_data = {"scanned_at": "2025-01-15T10:00:00"}
        app = _make_app(
            _ctx(
                role="owner",
                features=["hosted_dashboard", "shared_scan_links"],
            )
        )
        mock_db = _mock_firestore_for_project(_DEFAULT_PROJECT, scan_data, [])
        with patch(_PATCH_FIRESTORE_DB, return_value=mock_db):
            client = TestClient(app)
            client.cookies.set("__Host-session", "encrypted")
            resp = client.get("/dashboard/projects/proj_1/scans/scan_001")
        assert resp.status_code == 200
        assert "hx-get" in resp.text
        assert "shared-links" in resp.text
