"""Tests for Phase 15b — Dashboard Core Templates."""

from __future__ import annotations

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

import pytest
from backend.dashboard.middleware import (
    CustomerContext,
    DashboardAuthError,
    dashboard_auth_error_response,
    require_dashboard_auth,
)
from backend.dashboard.middleware.security_headers import (
    SecurityHeadersMiddleware,
)
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_FIRESTORE_GET_CUSTOMER = "backend.shared.firestore.get_customer"
_PATCH_GCS_DOWNLOAD = "backend.shared.gcs.download_findings"


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


def _ctx(
    tier: str = "team",
    customer_id: str = "cus_test",
    email: str = "user@example.com",
    cancellation_pending: bool = False,
    role: str = "owner",
    features: list[str] | None = None,
    project_limit: int | None = 1,
) -> 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=project_limit,
        batch_limit=5,
        cancellation_pending=cancellation_pending,
        subscription_expires_at="2027-01-01T00:00:00+00:00",
        consent_accepted=True,
        tos_version="1.0",
        privacy_version="1.0",
    )


def _session_data(
    csrf_token: str = "test-csrf-token",
) -> dict[str, Any]:
    return {
        "session_id": "sess_abc123",
        "customer_id": "cus_test",
        "email": "user@example.com",
        "tier": "team",
        "csrf_token": 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 _mock_firestore_db(
    project_docs: list[dict[str, Any]] | None = None,
    scan_history: list[dict[str, Any]] | None = None,
    customer_data: dict[str, Any] | None = None,
) -> MagicMock:
    """Create chainable mock Firestore DB with optional project data."""
    mock_db = MagicMock()

    # Build a project doc mock
    if project_docs is not None:
        mock_project_docs = []
        for pd in project_docs:
            doc_mock = MagicMock()
            doc_mock.id = pd.get("project_id", "proj_1")
            doc_mock.exists = True
            doc_mock.to_dict.return_value = pd
            mock_project_docs.append(doc_mock)

        # For query.stream()
        async def _stream_projects() -> Any:
            for d in mock_project_docs:
                yield d

        mock_query = MagicMock()
        mock_query.where = MagicMock(return_value=mock_query)
        mock_query.order_by = MagicMock(return_value=mock_query)
        mock_query.limit = MagicMock(return_value=mock_query)
        mock_query.offset = MagicMock(return_value=mock_query)
        mock_query.stream = _stream_projects

        # For scan_history subcollection
        async def _stream_history() -> Any:
            if scan_history:
                for s in scan_history:
                    sdoc = MagicMock()
                    sdoc.to_dict.return_value = s
                    yield sdoc

        mock_history_query = MagicMock()
        mock_history_query.order_by = MagicMock(return_value=mock_history_query)
        mock_history_query.limit = MagicMock(return_value=mock_history_query)
        mock_history_query.offset = MagicMock(return_value=mock_history_query)
        mock_history_query.stream = _stream_history

        # Single project doc .get()
        single_doc = mock_project_docs[0] if mock_project_docs else MagicMock()
        if not mock_project_docs:
            single_doc.exists = False

        # Chain: db.collection("customers").document(cid).collection("projects")
        # For project subcollection
        mock_projects_coll = MagicMock()
        mock_projects_coll.where = MagicMock(return_value=mock_query)
        mock_projects_coll.order_by = MagicMock(return_value=mock_query)
        mock_projects_coll.document = MagicMock()

        # The project document mock
        mock_proj_doc_ref = MagicMock()
        mock_proj_doc_ref.get = AsyncMock(return_value=single_doc)

        # scan_history subcollection on project document
        mock_scan_history_coll = MagicMock()
        mock_scan_history_coll.order_by = MagicMock(return_value=mock_history_query)
        mock_proj_doc_ref.collection = MagicMock(return_value=mock_scan_history_coll)
        mock_proj_doc_ref.update = AsyncMock()

        mock_projects_coll.document = MagicMock(return_value=mock_proj_doc_ref)

        # Customer document
        mock_customer_doc_ref = MagicMock()
        mock_customer_doc_ref.collection = MagicMock(return_value=mock_projects_coll)

        if customer_data:
            cust_doc = MagicMock()
            cust_doc.exists = True
            cust_doc.to_dict.return_value = customer_data
            mock_customer_doc_ref.get = AsyncMock(return_value=cust_doc)
        else:
            cust_doc = MagicMock()
            cust_doc.exists = True
            cust_doc.to_dict.return_value = {}
            mock_customer_doc_ref.get = AsyncMock(return_value=cust_doc)

        mock_customers_coll = MagicMock()
        mock_customers_coll.document = MagicMock(return_value=mock_customer_doc_ref)

        mock_db.collection = MagicMock(return_value=mock_customers_coll)
    else:
        mock_doc = MagicMock()
        mock_doc.set = AsyncMock()
        mock_doc.update = AsyncMock()
        mock_doc.exists = False
        inner_coll = MagicMock()
        inner_coll.document = MagicMock(return_value=mock_doc)
        mock_doc.collection = MagicMock(return_value=inner_coll)
        mock_coll = MagicMock()
        mock_coll.document = MagicMock(return_value=mock_doc)
        mock_db.collection = MagicMock(return_value=mock_coll)

    return mock_db


def _sample_project(
    project_id: str = "proj_1",
    label: str = "Production",
    client_label: str | None = None,
    cloud: str = "aws",
    score: float | None = 85.0,
    score_delta: float | None = 2.0,
    severity_counts: dict[str, int] | None = None,
    last_scan_at: str | None = "2025-01-15T10:00:00+00:00",
    drift_monitoring: bool = False,
    has_cloud_intelligence: bool = False,
    latest_scan: str = "scan_001",
) -> dict[str, Any]:
    if severity_counts is None:
        severity_counts = {"critical": 1, "high": 3, "medium": 5, "low": 2}
    return {
        "project_id": project_id,
        "project_label": label,
        "client_label": client_label,
        "cloud": cloud,
        "frameworks": ["soc2", "cis_aws"],
        "status": "active",
        "score": score,
        "score_delta": score_delta,
        "passed": 45,
        "failed": 8,
        "severity_counts": severity_counts,
        "last_scan_at": last_scan_at,
        "drift_monitoring": drift_monitoring,
        "drift_mode": None,
        "drift_last_event_at": None,
        "has_cloud_intelligence": has_cloud_intelligence,
        "cross_validation_score": None,
        "latest_scan": latest_scan,
        "summary": {
            "total_controls": 53,
            "passed": 45,
            "failed": 8,
            "manual": 0,
            "score": score,
            "severity_counts": severity_counts,
            "resource_count": 30,
        },
        "frameworks_detail": [],
    }


def _sample_findings(count: int = 5) -> list[dict[str, Any]]:
    severities = ["critical", "high", "medium", "low"]
    findings = []
    for i in range(count):
        findings.append(
            {
                "control_id": f"CKV_AWS_{i + 1:03d}",
                "control_title": f"Check {i + 1}",
                "resource": f"aws_s3_bucket.bucket_{i}",
                "severity": severities[i % 4],
                "status": "FAIL" if i % 3 != 0 else "MANUAL_REVIEW",
                "framework": "soc2",
                "frameworks": ["soc2", "cis_aws"],
                "cross_mappings": [
                    f"SOC2-CC{i}.1",
                    f"CIS-{i}.2",
                    f"NIST-AC-{i}",
                    f"ISO-A.8.{i}",
                    f"PCI-{i}.1",
                ],
                "native_ids": [f"SH-{i}"] if i % 2 == 0 else [],
            }
        )
    return findings


def _build_app(
    auth_ctx: CustomerContext | None = None,
    auth_error: DashboardAuthError | None = None,
) -> FastAPI:
    """Build test app with auth dependency overridden."""
    from starlette.staticfiles import StaticFiles

    app = FastAPI()
    app.add_middleware(SecurityHeadersMiddleware)
    app.include_router(router)

    with contextlib.suppress(Exception):
        app.mount(
            "/static",
            StaticFiles(directory="backend/dashboard/static"),
            name="static",
        )

    @app.exception_handler(DashboardAuthError)
    async def _handler(request: object, exc: DashboardAuthError) -> object:
        return dashboard_auth_error_response(exc, request)  # type: ignore[arg-type]

    if auth_error is not None:

        async def _raise_auth() -> CustomerContext:
            raise auth_error

        app.dependency_overrides[require_dashboard_auth] = _raise_auth
    elif auth_ctx is not None:
        app.dependency_overrides[require_dashboard_auth] = lambda: auth_ctx

    return app


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------


@pytest.fixture
def client() -> TestClient:
    """Client with default team-tier auth."""
    app = _build_app(auth_ctx=_ctx())
    return TestClient(app, raise_server_exceptions=False)


@pytest.fixture
def agency_client() -> TestClient:
    """Client with agency-tier auth."""
    app = _build_app(
        auth_ctx=_ctx(
            tier="agency",
            features=[
                "hosted_dashboard",
                "multi_project_dashboard",
                "shared_scan_links",
            ],
            project_limit=5,
        )
    )
    return TestClient(app, raise_server_exceptions=False)


# ---------------------------------------------------------------------------
# Projects Overview Tests
# ---------------------------------------------------------------------------


class TestProjectsOverview:
    def test_projects_overview_renders(self, client: TestClient) -> None:
        projects = [_sample_project()]
        mock_db = _mock_firestore_db(project_docs=projects)

        with (
            patch(_PATCH_FIRESTORE_DB, return_value=mock_db),
            patch("backend.dashboard.cache.get_cached_projects", return_value=None),
            patch("backend.dashboard.cache.set_cached_projects"),
        ):
            resp = client.get("/dashboard/")

        assert resp.status_code == 200
        assert "project-grid" in resp.text

    def test_projects_overview_empty_state(self, client: TestClient) -> None:
        mock_db = _mock_firestore_db(project_docs=[])
        with (
            patch(_PATCH_FIRESTORE_DB, return_value=mock_db),
            patch("backend.dashboard.cache.get_cached_projects", return_value=None),
            patch("backend.dashboard.cache.set_cached_projects"),
        ):
            resp = client.get("/dashboard/")

        assert resp.status_code == 200
        assert "No projects yet" in resp.text

    def test_projects_overview_agency_filter(self, agency_client: TestClient) -> None:
        projects = [
            _sample_project(project_id="p1", label="Prod", client_label="acme"),
            _sample_project(project_id="p2", label="Dev", client_label="beta"),
        ]
        mock_db = _mock_firestore_db(project_docs=projects)

        with (
            patch(_PATCH_FIRESTORE_DB, return_value=mock_db),
            patch("backend.dashboard.cache.get_cached_projects", return_value=None),
            patch("backend.dashboard.cache.set_cached_projects"),
        ):
            resp = agency_client.get("/dashboard/?client=acme")

        assert resp.status_code == 200
        assert "Prod" in resp.text

    def test_projects_overview_agency_usage_bar(
        self, agency_client: TestClient
    ) -> None:
        projects = [_sample_project()]
        mock_db = _mock_firestore_db(project_docs=projects)

        with (
            patch(_PATCH_FIRESTORE_DB, return_value=mock_db),
            patch("backend.dashboard.cache.get_cached_projects", return_value=None),
            patch("backend.dashboard.cache.set_cached_projects"),
        ):
            resp = agency_client.get("/dashboard/")

        assert resp.status_code == 200
        assert "engagements used" in resp.text

    def test_projects_overview_posture_bar(self, client: TestClient) -> None:
        projects = [_sample_project(score=85.0)]
        mock_db = _mock_firestore_db(project_docs=projects)

        with (
            patch(_PATCH_FIRESTORE_DB, return_value=mock_db),
            patch("backend.dashboard.cache.get_cached_projects", return_value=None),
            patch("backend.dashboard.cache.set_cached_projects"),
        ):
            resp = client.get("/dashboard/")

        assert resp.status_code == 200
        assert "posture-summary" in resp.text
        assert "<svg" in resp.text

    def test_projects_overview_no_scans_posture(self, client: TestClient) -> None:
        projects = [_sample_project(score=None)]
        mock_db = _mock_firestore_db(project_docs=projects)

        with (
            patch(_PATCH_FIRESTORE_DB, return_value=mock_db),
            patch("backend.dashboard.cache.get_cached_projects", return_value=None),
            patch("backend.dashboard.cache.set_cached_projects"),
        ):
            resp = client.get("/dashboard/")

        assert resp.status_code == 200
        assert "No scans yet" in resp.text

    def test_projects_overview_auto_refresh(self, client: TestClient) -> None:
        projects = [_sample_project()]
        mock_db = _mock_firestore_db(project_docs=projects)

        with (
            patch(_PATCH_FIRESTORE_DB, return_value=mock_db),
            patch("backend.dashboard.cache.get_cached_projects", return_value=None),
            patch("backend.dashboard.cache.set_cached_projects"),
        ):
            resp = client.get("/dashboard/")

        assert resp.status_code == 200
        assert 'hx-trigger="every 60s"' in resp.text


# ---------------------------------------------------------------------------
# Project Detail Tests
# ---------------------------------------------------------------------------


class TestProjectDetail:
    def _get_detail(
        self,
        client: TestClient,
        mock_db: MagicMock,
        tab: str | None = None,
    ) -> Any:
        url = "/dashboard/projects/proj_1"
        if tab:
            url += f"?tab={tab}"
        with (
            patch(_PATCH_FIRESTORE_DB, return_value=mock_db),
            patch(
                "backend.dashboard.cache.get_cached_score_history",
                return_value=[],
            ),
            patch("backend.dashboard.cache.set_cached_score_history"),
        ):
            return client.get(url)

    def test_project_detail_tabs(self, client: TestClient) -> None:
        mock_db = _mock_firestore_db(project_docs=[_sample_project()], scan_history=[])
        resp = self._get_detail(client, mock_db)

        assert resp.status_code == 200
        # All 6 tabs
        assert "hx-get" in resp.text
        tab_ids = [
            "findings",
            "controls",
            "history",
            "drift",
            "intelligence",
            "settings",
        ]
        for tab_id in tab_ids:
            assert f"tab={tab_id}" in resp.text
        # Check hx-push-url
        assert "hx-push-url" in resp.text

    def test_project_detail_default_tab(self, client: TestClient) -> None:
        mock_db = _mock_firestore_db(project_docs=[_sample_project()], scan_history=[])
        resp = self._get_detail(client, mock_db)

        assert resp.status_code == 200
        # Findings tab should be active
        assert 'aria-current="true"' in resp.text

    def test_project_detail_tab_bookmark(self, client: TestClient) -> None:
        mock_db = _mock_firestore_db(project_docs=[_sample_project()], scan_history=[])
        resp = self._get_detail(client, mock_db, tab="history")

        assert resp.status_code == 200
        # History tab should have aria-current
        text = resp.text
        # Find the history tab link and check it has aria-current
        history_idx = text.find("tab=history")
        assert history_idx > 0

    def test_project_detail_score_trend_lazy(self, client: TestClient) -> None:
        mock_db = _mock_firestore_db(project_docs=[_sample_project()], scan_history=[])
        resp = self._get_detail(client, mock_db)

        assert resp.status_code == 200
        assert 'hx-trigger="load"' in resp.text

    def test_project_detail_score_panel(self, client: TestClient) -> None:
        mock_db = _mock_firestore_db(
            project_docs=[_sample_project(score=85.0)], scan_history=[]
        )
        resp = self._get_detail(client, mock_db)

        assert resp.status_code == 200
        assert "<svg" in resp.text
        assert "controls passed" in resp.text


# ---------------------------------------------------------------------------
# Findings Partial Tests
# ---------------------------------------------------------------------------


class TestFindingsPartial:
    def _get_findings(
        self,
        client: TestClient,
        mock_db: MagicMock,
        findings: list[dict[str, Any]] | None = None,
        gcs_error: bool = False,
        **params: str,
    ) -> Any:
        query = "&".join(f"{k}={v}" for k, v in params.items())
        base = "/dashboard/partials/findings/proj_1"
        url = f"{base}?{query}" if query else base

        patches = [patch(_PATCH_FIRESTORE_DB, return_value=mock_db)]

        if gcs_error:
            patches.append(
                patch(
                    _PATCH_GCS_DOWNLOAD,
                    new_callable=AsyncMock,
                    side_effect=Exception("GCS error"),
                )
            )
            patches.append(
                patch(
                    "backend.dashboard.cache.get_cached_findings",
                    return_value=None,
                )
            )
            patches.append(patch("backend.dashboard.cache.set_cached_findings"))
        elif findings is not None:
            patches.append(
                patch(
                    "backend.dashboard.cache.get_cached_findings",
                    return_value=findings,
                )
            )
        else:
            patches.append(
                patch(
                    "backend.dashboard.cache.get_cached_findings",
                    return_value=_sample_findings(5),
                )
            )

        with contextlib.ExitStack() as stack:
            for p in patches:
                stack.enter_context(p)
            return client.get(url)

    def test_findings_filter_severity(self, client: TestClient) -> None:
        findings = _sample_findings(8)
        mock_db = _mock_firestore_db(project_docs=[_sample_project()])
        resp = self._get_findings(
            client, mock_db, findings=findings, severity="critical"
        )

        assert resp.status_code == 200
        assert "CRITICAL" in resp.text

    def test_findings_filter_framework(self, client: TestClient) -> None:
        findings = _sample_findings(5)
        mock_db = _mock_firestore_db(project_docs=[_sample_project()])
        resp = self._get_findings(client, mock_db, findings=findings, framework="soc2")

        assert resp.status_code == 200

    def test_findings_filter_status(self, client: TestClient) -> None:
        findings = _sample_findings(6)
        mock_db = _mock_firestore_db(project_docs=[_sample_project()])
        resp = self._get_findings(client, mock_db, findings=findings, status="manual")

        assert resp.status_code == 200
        assert "MANUAL_REVIEW" in resp.text

    def test_findings_pagination(self, client: TestClient) -> None:
        findings = _sample_findings(30)
        mock_db = _mock_firestore_db(project_docs=[_sample_project()])
        resp = self._get_findings(client, mock_db, findings=findings, status="all")

        assert resp.status_code == 200
        assert "Load more" in resp.text

    def test_findings_last_page_no_button(self, client: TestClient) -> None:
        findings = _sample_findings(3)
        mock_db = _mock_firestore_db(project_docs=[_sample_project()])
        resp = self._get_findings(client, mock_db, findings=findings)

        assert resp.status_code == 200
        assert "Load more" not in resp.text

    def test_findings_cross_mapping_chips(self, client: TestClient) -> None:
        findings = _sample_findings(1)
        # First finding has 5 cross_mappings
        mock_db = _mock_firestore_db(project_docs=[_sample_project()])
        resp = self._get_findings(client, mock_db, findings=findings, status="all")

        assert resp.status_code == 200
        assert "+2 more" in resp.text

    def test_findings_missing_json(self, client: TestClient) -> None:
        mock_db = _mock_firestore_db(project_docs=[_sample_project()])
        resp = self._get_findings(client, mock_db, gcs_error=True)

        assert resp.status_code == 200
        assert "Findings unavailable" in resp.text

    def test_findings_returns_fragment(self, client: TestClient) -> None:
        findings = _sample_findings(3)
        mock_db = _mock_firestore_db(project_docs=[_sample_project()])
        resp = self._get_findings(client, mock_db, findings=findings)

        assert resp.status_code == 200
        assert "<html" not in resp.text
        assert "<head" not in resp.text


# ---------------------------------------------------------------------------
# Scan History Tests
# ---------------------------------------------------------------------------


class TestScanHistory:
    def test_scan_history_renders(self, client: TestClient) -> None:
        scans = [
            {
                "scanned_at": "2025-01-15T10:00:00+00:00",
                "frameworks": ["soc2"],
                "score": 85.0,
                "score_delta": 2.0,
                "severity_counts": {"critical": 0, "high": 1, "medium": 2, "low": 1},
                "scan_type": "scheduled",
            }
        ]
        mock_db = _mock_firestore_db(
            project_docs=[_sample_project()], scan_history=scans
        )

        with patch(_PATCH_FIRESTORE_DB, return_value=mock_db):
            resp = client.get("/dashboard/partials/scan-history/proj_1")

        assert resp.status_code == 200
        assert "<tr" in resp.text

    def test_scan_history_pagination(self, client: TestClient) -> None:
        # 26 scans = more than default 25
        scans = [
            {
                "scanned_at": f"2025-01-{i:02d}T10:00:00+00:00",
                "frameworks": ["soc2"],
                "score": 80.0 + i,
                "score_delta": None,
                "severity_counts": {"critical": 0, "high": 0, "medium": 0, "low": 0},
                "scan_type": "manual",
            }
            for i in range(1, 27)
        ]
        mock_db = _mock_firestore_db(
            project_docs=[_sample_project()], scan_history=scans
        )

        with patch(_PATCH_FIRESTORE_DB, return_value=mock_db):
            resp = client.get("/dashboard/partials/scan-history/proj_1")

        assert resp.status_code == 200
        assert "Load more" in resp.text


# ---------------------------------------------------------------------------
# Score Trend Tests
# ---------------------------------------------------------------------------


class TestScoreTrend:
    def test_score_trend_returns_svg(self, client: TestClient) -> None:
        history = [
            {"score": 80.0, "scanned_at": "2025-01-01T00:00:00+00:00"},
            {"score": 85.0, "scanned_at": "2025-02-01T00:00:00+00:00"},
        ]
        mock_db = _mock_firestore_db(
            project_docs=[_sample_project()], scan_history=history
        )

        with (
            patch(_PATCH_FIRESTORE_DB, return_value=mock_db),
            patch(
                "backend.dashboard.cache.get_cached_score_history",
                return_value=history,
            ),
        ):
            resp = client.get("/dashboard/partials/score-trend/proj_1")

        assert resp.status_code == 200
        assert "<svg" in resp.text


# ---------------------------------------------------------------------------
# Alert Config Tests
# ---------------------------------------------------------------------------


class TestAlertConfig:
    def test_alert_config_get(self, client: TestClient) -> None:
        mock_db = _mock_firestore_db(project_docs=[_sample_project()])

        with patch(_PATCH_FIRESTORE_DB, return_value=mock_db):
            resp = client.get("/dashboard/partials/alert-config/proj_1")

        assert resp.status_code == 200
        assert "alert_threshold" in resp.text

    def test_alert_config_put_valid(self, client: TestClient) -> None:
        mock_db = _mock_firestore_db(project_docs=[_sample_project()])
        session = _session_data()

        with (
            patch(_PATCH_SESSION_COOKIE, return_value="__Host-session"),
            patch(
                _PATCH_SESSION_GET,
                new_callable=AsyncMock,
                return_value=session,
            ),
            patch(_PATCH_SESSION_VALIDATE, return_value=True),
            patch(_PATCH_FIRESTORE_DB, return_value=mock_db),
        ):
            client.cookies.set("__Host-session", "encrypted")
            resp = client.put(
                "/dashboard/partials/alert-config/proj_1",
                data={
                    "alert_threshold": "10",
                    "alert_email": "alert@example.com",
                    "webhook_url": "https://hooks.example.com/alert",
                },
                headers={"X-CSRF-Token": "test-csrf-token"},
            )

        assert resp.status_code == 200
        assert "Alert configuration saved" in resp.text

    def test_alert_config_threshold_zero(self, client: TestClient) -> None:
        session = _session_data()
        mock_db = _mock_firestore_db(project_docs=[_sample_project()])

        with (
            patch(_PATCH_SESSION_COOKIE, return_value="__Host-session"),
            patch(
                _PATCH_SESSION_GET,
                new_callable=AsyncMock,
                return_value=session,
            ),
            patch(_PATCH_SESSION_VALIDATE, return_value=True),
            patch(_PATCH_FIRESTORE_DB, return_value=mock_db),
        ):
            client.cookies.set("__Host-session", "encrypted")
            resp = client.put(
                "/dashboard/partials/alert-config/proj_1",
                data={
                    "alert_threshold": "0",
                    "alert_email": "a@b.com",
                    "webhook_url": "",
                },
                headers={"X-CSRF-Token": "test-csrf-token"},
            )

        assert resp.status_code == 422

    def test_alert_config_threshold_51(self, client: TestClient) -> None:
        session = _session_data()
        mock_db = _mock_firestore_db(project_docs=[_sample_project()])

        with (
            patch(_PATCH_SESSION_COOKIE, return_value="__Host-session"),
            patch(
                _PATCH_SESSION_GET,
                new_callable=AsyncMock,
                return_value=session,
            ),
            patch(_PATCH_SESSION_VALIDATE, return_value=True),
            patch(_PATCH_FIRESTORE_DB, return_value=mock_db),
        ):
            client.cookies.set("__Host-session", "encrypted")
            resp = client.put(
                "/dashboard/partials/alert-config/proj_1",
                data={
                    "alert_threshold": "51",
                    "alert_email": "a@b.com",
                    "webhook_url": "",
                },
                headers={"X-CSRF-Token": "test-csrf-token"},
            )

        assert resp.status_code == 422

    def test_alert_config_invalid_email(self, client: TestClient) -> None:
        session = _session_data()
        mock_db = _mock_firestore_db(project_docs=[_sample_project()])

        with (
            patch(_PATCH_SESSION_COOKIE, return_value="__Host-session"),
            patch(
                _PATCH_SESSION_GET,
                new_callable=AsyncMock,
                return_value=session,
            ),
            patch(_PATCH_SESSION_VALIDATE, return_value=True),
            patch(_PATCH_FIRESTORE_DB, return_value=mock_db),
        ):
            client.cookies.set("__Host-session", "encrypted")
            resp = client.put(
                "/dashboard/partials/alert-config/proj_1",
                data={
                    "alert_threshold": "10",
                    "alert_email": "not-an-email",
                    "webhook_url": "",
                },
                headers={"X-CSRF-Token": "test-csrf-token"},
            )

        assert resp.status_code == 422

    def test_alert_config_webhook_http(self, client: TestClient) -> None:
        session = _session_data()
        mock_db = _mock_firestore_db(project_docs=[_sample_project()])

        with (
            patch(_PATCH_SESSION_COOKIE, return_value="__Host-session"),
            patch(
                _PATCH_SESSION_GET,
                new_callable=AsyncMock,
                return_value=session,
            ),
            patch(_PATCH_SESSION_VALIDATE, return_value=True),
            patch(_PATCH_FIRESTORE_DB, return_value=mock_db),
        ):
            client.cookies.set("__Host-session", "encrypted")
            resp = client.put(
                "/dashboard/partials/alert-config/proj_1",
                data={
                    "alert_threshold": "10",
                    "alert_email": "a@b.com",
                    "webhook_url": "http://insecure.example.com",
                },
                headers={"X-CSRF-Token": "test-csrf-token"},
            )

        assert resp.status_code == 422

    def test_alert_config_webhook_empty(self, client: TestClient) -> None:
        session = _session_data()
        mock_db = _mock_firestore_db(project_docs=[_sample_project()])

        with (
            patch(_PATCH_SESSION_COOKIE, return_value="__Host-session"),
            patch(
                _PATCH_SESSION_GET,
                new_callable=AsyncMock,
                return_value=session,
            ),
            patch(_PATCH_SESSION_VALIDATE, return_value=True),
            patch(_PATCH_FIRESTORE_DB, return_value=mock_db),
        ):
            client.cookies.set("__Host-session", "encrypted")
            resp = client.put(
                "/dashboard/partials/alert-config/proj_1",
                data={
                    "alert_threshold": "10",
                    "alert_email": "a@b.com",
                    "webhook_url": "",
                },
                headers={"X-CSRF-Token": "test-csrf-token"},
            )

        assert resp.status_code == 200


# ---------------------------------------------------------------------------
# Settings Tests
# ---------------------------------------------------------------------------


class TestSettings:
    def _get_settings(self, client: TestClient, **ctx_kw: Any) -> Any:
        ctx = _ctx(**ctx_kw) if ctx_kw else _ctx()
        app = _build_app(auth_ctx=ctx)
        c = TestClient(app, raise_server_exceptions=False)

        customer_data = {
            "status": ctx_kw.get("status_override", "active"),
            "paddle_customer_id": "pdl_123",
            "api_key_last4": "abcd",
            "bundle_version": "1.2.3",
        }

        with patch(
            _PATCH_FIRESTORE_GET_CUSTOMER,
            new_callable=AsyncMock,
            return_value=customer_data,
        ):
            return c.get("/dashboard/settings")

    def test_settings_renders(self) -> None:
        resp = self._get_settings(None)
        assert resp.status_code == 200

    def test_settings_paddle_portal_link(self) -> None:
        resp = self._get_settings(None)
        assert resp.status_code == 200
        assert "paddle" in resp.text.lower() or "Manage Billing" in resp.text

    def test_settings_api_key_masked(self) -> None:
        resp = self._get_settings(None)
        assert resp.status_code == 200
        assert "sk_****" in resp.text

    def test_settings_download_links(self) -> None:
        resp = self._get_settings(None)
        assert resp.status_code == 200
        assert "Download" in resp.text

    def test_settings_expired_banner(self) -> None:
        ctx = _ctx()
        app = _build_app(auth_ctx=ctx)
        c = TestClient(app, raise_server_exceptions=False)

        customer_data = {
            "status": "expired",
            "paddle_customer_id": "",
            "api_key_last4": "abcd",
            "bundle_version": "1.2.3",
        }

        with patch(
            _PATCH_FIRESTORE_GET_CUSTOMER,
            new_callable=AsyncMock,
            return_value=customer_data,
        ):
            resp = c.get("/dashboard/settings")

        assert resp.status_code == 200
        assert "Expired on" in resp.text

    def test_settings_past_due_banner(self) -> None:
        ctx = _ctx()
        app = _build_app(auth_ctx=ctx)
        c = TestClient(app, raise_server_exceptions=False)

        customer_data = {
            "status": "past_due",
            "paddle_customer_id": "pdl_123",
            "api_key_last4": "abcd",
            "bundle_version": "1.2.3",
        }

        with patch(
            _PATCH_FIRESTORE_GET_CUSTOMER,
            new_callable=AsyncMock,
            return_value=customer_data,
        ):
            resp = c.get("/dashboard/settings")

        assert resp.status_code == 200
        assert "Payment failed" in resp.text

    def test_settings_cancelled_pending_banner(self) -> None:
        ctx = _ctx(cancellation_pending=True)
        app = _build_app(auth_ctx=ctx)
        c = TestClient(app, raise_server_exceptions=False)

        customer_data = {
            "status": "cancelled",
            "paddle_customer_id": "",
            "api_key_last4": "abcd",
            "bundle_version": "1.2.3",
        }

        with patch(
            _PATCH_FIRESTORE_GET_CUSTOMER,
            new_callable=AsyncMock,
            return_value=customer_data,
        ):
            resp = c.get("/dashboard/settings")

        assert resp.status_code == 200
        assert "ends on" in resp.text


# ---------------------------------------------------------------------------
# Placeholder Partial Tests
# ---------------------------------------------------------------------------


class TestPlaceholders:
    def test_drift_timeline_placeholder(self, client: TestClient) -> None:
        resp = client.get("/dashboard/partials/drift-timeline/proj_1")

        assert resp.status_code == 200
        assert "Drift monitoring not yet configured" in resp.text

    def test_cloud_intelligence_placeholder(self, client: TestClient) -> None:
        resp = client.get("/dashboard/partials/cloud-intelligence/proj_1")

        assert resp.status_code == 200
        assert "Cloud Intelligence" in resp.text


# ---------------------------------------------------------------------------
# Template Structure Tests
# ---------------------------------------------------------------------------


class TestTemplateStructure:
    def test_consent_banner_shown(self) -> None:
        ctx_obj = CustomerContext(
            customer_id="cus_test",
            email="user@example.com",
            tier="team",
            role="owner",
            member_email="user@example.com",
            features=["hosted_dashboard"],
            project_limit=1,
            batch_limit=5,
            cancellation_pending=False,
            subscription_expires_at="2027-01-01T00:00:00+00:00",
            consent_accepted=False,
            tos_version="1.0",
            privacy_version="1.0",
        )
        app = _build_app(auth_ctx=ctx_obj)
        c = TestClient(app, raise_server_exceptions=False)

        projects = [_sample_project()]
        mock_db = _mock_firestore_db(project_docs=projects)

        with (
            patch(_PATCH_FIRESTORE_DB, return_value=mock_db),
            patch("backend.dashboard.cache.get_cached_projects", return_value=None),
            patch("backend.dashboard.cache.set_cached_projects"),
        ):
            resp = c.get("/dashboard/")

        assert resp.status_code == 200
        assert "I agree" in resp.text

    def test_consent_banner_hidden(self, client: TestClient) -> None:
        # Default _ctx has consent_accepted=True
        projects = [_sample_project()]
        mock_db = _mock_firestore_db(project_docs=projects)

        with (
            patch(_PATCH_FIRESTORE_DB, return_value=mock_db),
            patch("backend.dashboard.cache.get_cached_projects", return_value=None),
            patch("backend.dashboard.cache.set_cached_projects"),
        ):
            resp = client.get("/dashboard/")

        assert resp.status_code == 200
        assert "I agree" not in resp.text

    def test_nav_active_projects(self, client: TestClient) -> None:
        projects = [_sample_project()]
        mock_db = _mock_firestore_db(project_docs=projects)

        with (
            patch(_PATCH_FIRESTORE_DB, return_value=mock_db),
            patch("backend.dashboard.cache.get_cached_projects", return_value=None),
            patch("backend.dashboard.cache.set_cached_projects"),
        ):
            resp = client.get("/dashboard/")

        assert resp.status_code == 200
        text = resp.text
        # The nav has "Projects" text near aria-current="page"
        assert 'aria-current="page"' in text
        # And it should be near the Projects link text
        idx = text.find("Projects")
        assert idx > 0

    def test_nav_active_settings(self) -> None:
        ctx = _ctx()
        app = _build_app(auth_ctx=ctx)
        c = TestClient(app, raise_server_exceptions=False)

        customer_data = {
            "status": "active",
            "paddle_customer_id": "",
            "api_key_last4": "abcd",
            "bundle_version": "1.2.3",
        }

        with patch(
            _PATCH_FIRESTORE_GET_CUSTOMER,
            new_callable=AsyncMock,
            return_value=customer_data,
        ):
            resp = c.get("/dashboard/settings")

        assert resp.status_code == 200
        text = resp.text
        settings_link_idx = text.find('href="/dashboard/settings"')
        assert settings_link_idx > 0
        nearby = text[max(0, settings_link_idx - 50) : settings_link_idx + 100]
        assert 'aria-current="page"' in nearby
