"""Dashboard routes — FastAPI router for /dashboard/* pages and partials.

Serves HTML pages via Jinja2 templates (htmx partials and full pages).
Auth via require_dashboard_auth dependency. CSRF validated on mutations.
"""

from __future__ import annotations

import hashlib
import logging
import math
import os
import re
import secrets
from datetime import UTC, datetime, timedelta
from typing import Any, Literal

import bcrypt
import httpx
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
from src.complyform.core.errors import (
    SharedScanPasswordError,
    ShareLinkExpiredError,
    ShareLinkLimitError,
    ShareLinkNotFoundError,
)

from backend.dashboard.context import (
    AuditLogContext,
    CommandPaletteResult,
    ControlEntry,
    ControlsViewContext,
    DashboardProjectCard,
    LinkAccountContext,
    MembersContext,
    PostureSummaryContext,
    ProjectDetailContext,
    ScanDetailContext,
    SettingsContext,
    SharedScanContext,
    WebhooksContext,
)
from backend.dashboard.middleware import CustomerContext, require_dashboard_auth

_log = logging.getLogger(__name__)

router = APIRouter(tags=["dashboard"])

_LICENSE_KEY_PATTERN = re.compile(r"^cf_(live|test)_[a-f0-9]{32}$")

_auth_dep = Depends(require_dashboard_auth)

_SEVERITY_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3}


def _ip_hash(request: Request) -> str:
    """SHA-256 hash of client IP for audit log."""
    host = request.client.host if request.client else "unknown"
    return hashlib.sha256(host.encode()).hexdigest()


async def _audit(
    request: Request,
    ctx: CustomerContext,
    event: str,
    metadata: dict[str, str | int | bool | None] | None = None,
) -> None:
    """Write audit event — non-fatal wrapper."""
    try:
        from backend.dashboard.audit import write_audit_event
        from backend.shared.tier_config import TIER_CONFIG

        tier_cfg = TIER_CONFIG.get(ctx.tier, {})
        retention = tier_cfg.get("retention_months")
        await write_audit_event(
            customer_id=ctx.customer_id,
            event=event,
            actor_email=ctx.member_email,
            actor_role=ctx.role,
            ip_hash=_ip_hash(request),
            metadata=metadata,
            retention_months=retention,
        )
    except Exception:
        _log.exception("Failed to write audit event %s", event)


# ---------------------------------------------------------------------------
# Pydantic models
# ---------------------------------------------------------------------------


class AlertConfigUpdate(BaseModel):
    """Pydantic model for alert configuration updates."""

    model_config = ConfigDict(strict=True)
    alert_threshold: int = Field(ge=1, le=50)
    alert_email: EmailStr
    webhook_url: str = ""

    @field_validator("webhook_url")
    @classmethod
    def validate_webhook_url(cls, v: str) -> str:
        if v and not v.startswith("https://"):
            raise ValueError("Webhook URL must use HTTPS")
        return v


class CreateShareLinkRequest(BaseModel):
    """Pydantic model for share link creation."""

    model_config = ConfigDict(strict=True)
    expires_in_days: int = Field(ge=1, le=30, default=7)
    password: str | None = None
    scope: Literal["full", "summary"] = "full"


# ---------------------------------------------------------------------------
# CSRF validation helper
# ---------------------------------------------------------------------------


async def _validate_csrf(request: Request) -> None:
    """Validate CSRF token from header or form body.

    Raises 403 if token is missing or invalid.
    """
    from backend.dashboard.session import get_cookie_name, get_session, validate_csrf

    cookie_name = get_cookie_name()
    encrypted_cookie = request.cookies.get(cookie_name)
    if not encrypted_cookie:
        raise _csrf_error()

    session = await get_session(encrypted_cookie)
    if session is None:
        raise _csrf_error()

    session_csrf = session.get("csrf_token", "")

    # Check header first (htmx)
    request_csrf = request.headers.get("X-CSRF-Token", "")

    # Fall back to form field
    if not request_csrf:
        content_type = request.headers.get("content-type", "")
        if "application/x-www-form-urlencoded" in content_type:
            form = await request.form()
            request_csrf = str(form.get("_csrf", ""))
        elif "application/json" in content_type:
            try:
                body = await request.json()
                request_csrf = str(body.get("_csrf", ""))
            except Exception:
                pass

    if not request_csrf or not validate_csrf(request_csrf, session_csrf):
        raise _csrf_error()


def _csrf_error() -> Exception:
    """Return an HTTP 403 exception for CSRF failures."""
    from fastapi import HTTPException

    return HTTPException(status_code=403, detail="CSRF validation failed")


# ---------------------------------------------------------------------------
# Jinja2 rendering helper
# ---------------------------------------------------------------------------


async def _render_template(
    template_name: str,
    context: dict[str, Any],
) -> HTMLResponse:
    """Render a Jinja2 template and return HTMLResponse."""
    from backend.dashboard.jinja_env import jinja_env

    template = jinja_env.get_template(template_name)
    html = await template.render_async(**context)
    return HTMLResponse(html)


# ---------------------------------------------------------------------------
# Data loading helpers
# ---------------------------------------------------------------------------


async def _load_projects(
    customer_id: str,
) -> list[DashboardProjectCard]:
    """Load project cards with caching."""
    from backend.dashboard.cache import get_cached_projects, set_cached_projects
    from backend.shared.firestore import get_db

    cached = get_cached_projects(customer_id)
    if cached is not None:
        return cached

    db = get_db()
    query = (
        db.collection("customers")
        .document(customer_id)
        .collection("projects")
        .where("status", "==", "active")
        .order_by("last_scan_at", direction="DESCENDING")
    )
    docs = [doc async for doc in query.stream()]

    projects: list[DashboardProjectCard] = []
    for doc in docs:
        data: dict[str, Any] = doc.to_dict()  # type: ignore[assignment]
        sev = data.get("severity_counts", {})
        projects.append(
            DashboardProjectCard(
                project_id=doc.id,
                project_label=data.get("project_label", doc.id),
                client_label=data.get("client_label"),
                cloud=data.get("cloud", "gcp"),
                frameworks=data.get("frameworks", []),
                status=data.get("status", "active"),
                score=data.get("score"),
                score_delta=data.get("score_delta"),
                passed=data.get("passed", 0),
                failed=data.get("failed", 0),
                severity_counts={
                    "critical": sev.get("critical", 0),
                    "high": sev.get("high", 0),
                    "medium": sev.get("medium", 0),
                    "low": sev.get("low", 0),
                },
                last_scan_at=data.get("last_scan_at"),
                drift_monitoring=data.get("drift_monitoring", False),
                drift_mode=data.get("drift_mode"),
                drift_last_event_at=data.get("drift_last_event_at"),
                has_cloud_intelligence=data.get("has_cloud_intelligence", False),
                cross_validation_score=data.get("cross_validation_score"),
            )
        )

    set_cached_projects(customer_id, projects)
    return projects


def _build_posture(
    projects: list[DashboardProjectCard],
    score_trend: str = "unknown",
) -> PostureSummaryContext:
    """Build posture summary from project cards."""
    total_critical = sum(p.severity_counts.get("critical", 0) for p in projects)
    total_high = sum(p.severity_counts.get("high", 0) for p in projects)
    total_medium = sum(p.severity_counts.get("medium", 0) for p in projects)
    total_low = sum(p.severity_counts.get("low", 0) for p in projects)

    scored = [p for p in projects if p.score is not None]
    aggregate = (
        sum(p.score for p in scored) / len(scored)  # type: ignore[arg-type]
        if scored
        else None
    )

    drift_24h = sum(1 for p in projects if p.drift_last_event_at is not None)

    worst = min(scored, key=lambda p: p.score, default=None)  # type: ignore[arg-type,return-value]

    return PostureSummaryContext(
        total_projects=len(projects),
        aggregate_score=aggregate,
        total_critical=total_critical,
        total_high=total_high,
        total_medium=total_medium,
        total_low=total_low,
        drift_events_24h=drift_24h,
        worst_project_label=worst.project_label if worst else None,
        worst_project_id=worst.project_id if worst else None,
        worst_project_score=worst.score if worst else None,
        score_trend=score_trend,
    )


def _base_context(ctx: CustomerContext) -> dict[str, Any]:
    """Build base template context from auth context."""
    return {
        "customer_email": ctx.email,
        "tier": ctx.tier,
        "csrf_token": "",
        "theme_preference": "light",
        "show_consent_banner": not ctx.consent_accepted,
        "tos_version": ctx.tos_version,
        "privacy_version": ctx.privacy_version,
        "active_page": "projects",
        "features": ctx.features,
    }


# ---------------------------------------------------------------------------
# Fully implemented routes (Phase 15a)
# ---------------------------------------------------------------------------


@router.post("/dashboard/consent/accept", status_code=204)
async def consent_accept(
    request: Request,
    ctx: CustomerContext = _auth_dep,
) -> None:
    """Accept ToS and privacy policy. Writes to Firestore. Idempotent."""
    await _validate_csrf(request)

    content_type = request.headers.get("content-type", "")
    if "application/json" in content_type:
        body = await request.json()
    else:
        form = await request.form()
        body = dict(form)

    tos_version = str(body.get("tos_version", "1.0"))
    privacy_version = str(body.get("privacy_version", "1.0"))

    from backend.shared.firestore import get_db

    now = datetime.now(UTC).isoformat()
    await (
        get_db()
        .collection("customers")
        .document(ctx.customer_id)
        .collection("consent")
        .document("dashboard")
        .set(
            {
                "dashboard_tos_accepted_at": now,
                "dashboard_tos_version": tos_version,
                "dashboard_privacy_version": privacy_version,
            }
        )
    )

    await _audit(
        request, ctx, "dashboard.settings_updated", {"action": "consent_accept"}
    )

    return None


@router.put("/dashboard/partials/theme-preference", status_code=204)
async def theme_preference(
    request: Request,
    ctx: CustomerContext = _auth_dep,
) -> None:
    """Update theme preference in session. Returns 204."""
    await _validate_csrf(request)

    content_type = request.headers.get("content-type", "")
    if "application/json" in content_type:
        body = await request.json()
    else:
        form = await request.form()
        body = dict(form)

    theme = str(body.get("theme", ""))

    if theme not in ("light", "dark"):
        raise _theme_error(theme)

    # Get session_id from cookie
    from backend.dashboard.session import get_cookie_name, get_session

    cookie_name = get_cookie_name()
    encrypted_cookie = request.cookies.get(cookie_name, "")
    session = await get_session(encrypted_cookie)
    if session is None:
        from fastapi import HTTPException

        raise HTTPException(status_code=401, detail="Session expired")

    session_id = session.get("session_id", "")

    from backend.shared.firestore import get_db

    await (
        get_db()
        .collection("sessions")
        .document(session_id)
        .update({"theme_preference": theme})
    )
    return None


def _theme_error(value: str) -> Exception:
    """Return HTTP 422 for invalid theme value."""
    from fastapi import HTTPException

    return HTTPException(
        status_code=422,
        detail=f"Invalid theme preference '{value}'. Valid values: light, dark.",
    )


@router.post("/dashboard/settings/regenerate-api-key")
async def regenerate_api_key(
    request: Request,
    ctx: CustomerContext = _auth_dep,
) -> JSONResponse:
    """Generate new API key, hash and store, return last 4 chars."""
    await _validate_csrf(request)

    raw_key = "sk_" + secrets.token_hex(32)
    key_hash = hashlib.sha256(raw_key.encode()).hexdigest()

    from backend.shared.firestore import get_db

    await (
        get_db()
        .collection("customers")
        .document(ctx.customer_id)
        .update({"api_key_hash": key_hash})
    )

    await _audit(request, ctx, "dashboard.api_key_regenerated")

    return JSONResponse({"api_key_last4": raw_key[-4:]})


@router.get("/dashboard/link-account")
async def link_account_get(
    request: Request,
) -> HTMLResponse:
    """Render link-account page."""
    context = LinkAccountContext(error=None)
    csrf_token = ""
    from backend.dashboard.session import get_cookie_name, get_session

    cookie_name = get_cookie_name()
    encrypted_cookie = request.cookies.get(cookie_name, "")
    if encrypted_cookie:
        session = await get_session(encrypted_cookie)
        if session:
            csrf_token = session.get("csrf_token", "")

    return await _render_template(
        "link_account.html",
        {
            "error": context.error,
            "csrf_token": csrf_token,
            "theme_preference": "light",
            "show_consent_banner": False,
            "customer_email": "",
            "tier": "",
            "active_page": "",
        },
    )


@router.post("/dashboard/link-account", response_model=None)
async def link_account_post(
    request: Request,
) -> HTMLResponse | RedirectResponse:
    """Validate license key, link customer email, redirect."""
    csrf_token = ""
    from backend.dashboard.session import get_cookie_name, get_session

    cookie_name = get_cookie_name()
    encrypted_cookie = request.cookies.get(cookie_name, "")
    email = ""
    if encrypted_cookie:
        session = await get_session(encrypted_cookie)
        if session:
            csrf_token = session.get("csrf_token", "")
            email = session.get("email", "")

    form = await request.form()
    license_key = str(form.get("license_key", ""))

    if not _LICENSE_KEY_PATTERN.match(license_key):
        return await _render_template(
            "link_account.html",
            {
                "error": (
                    "Invalid license key format. Expected cf_live_... or cf_test_..."
                ),
                "csrf_token": csrf_token,
                "theme_preference": "light",
                "show_consent_banner": False,
                "customer_email": email,
                "tier": "",
                "active_page": "",
            },
        )

    key_hash = hashlib.sha256(license_key.encode()).hexdigest()

    from backend.shared.firestore import get_license_key, update_customer

    key_doc = await get_license_key(key_hash)
    if key_doc is None:
        return await _render_template(
            "link_account.html",
            {
                "error": "License key not found. Check your key and try again.",
                "csrf_token": csrf_token,
                "theme_preference": "light",
                "show_consent_banner": False,
                "customer_email": email,
                "tier": "",
                "active_page": "",
            },
        )

    customer_id = key_doc.get("customer_id", "")
    if customer_id and email:
        await update_customer(customer_id, {"email": email})

    return RedirectResponse(url="/dashboard/", status_code=303)


# ---------------------------------------------------------------------------
# Phase 15b — Projects Overview
# ---------------------------------------------------------------------------


@router.get("/dashboard/")
async def projects_overview(
    request: Request,
    ctx: CustomerContext = _auth_dep,
) -> HTMLResponse:
    """Projects overview page with posture summary and project cards."""
    projects = await _load_projects(ctx.customer_id)

    # Agency tier client filter
    selected_client = request.query_params.get("client", "all")
    client_labels = sorted({p.client_label for p in projects if p.client_label})
    if "multi_project_dashboard" in ctx.features and selected_client != "all":
        projects = [p for p in projects if p.client_label == selected_client]

    posture = _build_posture(projects)
    active_count = len([p for p in projects if p.status == "active"])

    template_ctx = _base_context(ctx)
    template_ctx.update(
        {
            "projects": projects,
            "posture": posture,
            "active_count": active_count,
            "project_limit": ctx.project_limit,
            "client_labels": client_labels,
            "selected_client": selected_client,
            "active_page": "projects",
        }
    )

    return await _render_template("index.html", template_ctx)


# ---------------------------------------------------------------------------
# Phase 15b — Project Detail
# ---------------------------------------------------------------------------


@router.get("/dashboard/projects/{project_id}")
async def project_detail(
    project_id: str,
    request: Request,
    ctx: CustomerContext = _auth_dep,
) -> HTMLResponse:
    """Project detail page with tabbed sections."""
    from backend.dashboard.cache import (
        get_cached_score_history,
        set_cached_score_history,
    )
    from backend.shared.firestore import get_db

    db = get_db()
    doc = await (
        db.collection("customers")
        .document(ctx.customer_id)
        .collection("projects")
        .document(project_id)
        .get()
    )

    if not doc.exists:
        from fastapi import HTTPException

        raise HTTPException(status_code=404, detail="Project not found")

    data: dict[str, Any] = doc.to_dict()  # type: ignore[assignment]
    sev = data.get("severity_counts", {})
    project = DashboardProjectCard(
        project_id=doc.id,
        project_label=data.get("project_label", doc.id),
        client_label=data.get("client_label"),
        cloud=data.get("cloud", "gcp"),
        frameworks=data.get("frameworks", []),
        status=data.get("status", "active"),
        score=data.get("score"),
        score_delta=data.get("score_delta"),
        passed=data.get("passed", 0),
        failed=data.get("failed", 0),
        severity_counts={
            "critical": sev.get("critical", 0),
            "high": sev.get("high", 0),
            "medium": sev.get("medium", 0),
            "low": sev.get("low", 0),
        },
        last_scan_at=data.get("last_scan_at"),
        drift_monitoring=data.get("drift_monitoring", False),
        drift_mode=data.get("drift_mode"),
        drift_last_event_at=data.get("drift_last_event_at"),
        has_cloud_intelligence=data.get("has_cloud_intelligence", False),
        cross_validation_score=data.get("cross_validation_score"),
    )

    # Score history (cached 5 min)
    score_history = get_cached_score_history(ctx.customer_id, project_id)
    if score_history is None:
        history_query = (
            db.collection("customers")
            .document(ctx.customer_id)
            .collection("projects")
            .document(project_id)
            .collection("scan_history")
            .order_by("scanned_at", direction="DESCENDING")
            .limit(90)
        )
        history_docs = [d async for d in history_query.stream()]
        score_history = [d.to_dict() for d in history_docs]  # type: ignore[union-attr]
        set_cached_score_history(ctx.customer_id, project_id, score_history)

    # Drift events
    drift_events: list[dict[str, Any]] = []

    summary = data.get("summary", {})
    if not summary:
        summary = {
            "total_controls": project.passed + project.failed,
            "passed": project.passed,
            "failed": project.failed,
            "manual": 0,
            "score": project.score,
            "severity_counts": project.severity_counts,
            "resource_count": 0,
        }

    frameworks_detail = data.get("frameworks_detail", [])

    active_tab = request.query_params.get("tab", "findings")

    detail_ctx = ProjectDetailContext(
        customer_email=ctx.email,
        tier=ctx.tier,
        project=project,
        summary=summary,
        frameworks_detail=frameworks_detail,
        score_history=score_history,
        drift_events=drift_events,
        active_tab=active_tab,
        theme_preference="light",
        show_consent_banner=not ctx.consent_accepted,
        tos_version=ctx.tos_version,
        privacy_version=ctx.privacy_version,
    )

    template_ctx = _base_context(ctx)
    template_ctx.update(
        {
            "project": detail_ctx.project,
            "summary": detail_ctx.summary,
            "frameworks_detail": detail_ctx.frameworks_detail,
            "score_history": detail_ctx.score_history,
            "drift_events": detail_ctx.drift_events,
            "active_tab": detail_ctx.active_tab,
            "active_page": "projects",
        }
    )

    return await _render_template("project_detail.html", template_ctx)


@router.get("/dashboard/projects/{project_id}/scans/{scan_id}")
async def scan_detail(
    request: Request,
    project_id: str,
    scan_id: str,
    ctx: CustomerContext = _auth_dep,
) -> HTMLResponse:
    """Scan detail page — read-only, 3 tabs, share button."""
    from backend.shared.firestore import get_db

    db = get_db()
    project_doc = await (
        db.collection("customers")
        .document(ctx.customer_id)
        .collection("projects")
        .document(project_id)
        .get()
    )

    if not project_doc.exists:
        from fastapi import HTTPException

        raise HTTPException(status_code=404, detail="Project not found")

    data: dict[str, Any] = project_doc.to_dict()  # type: ignore[assignment]
    sev = data.get("severity_counts", {})
    project = DashboardProjectCard(
        project_id=project_doc.id,
        project_label=data.get("project_label", project_doc.id),
        client_label=data.get("client_label"),
        cloud=data.get("cloud", "gcp"),
        frameworks=data.get("frameworks", []),
        status=data.get("status", "active"),
        score=data.get("score"),
        score_delta=data.get("score_delta"),
        passed=data.get("passed", 0),
        failed=data.get("failed", 0),
        severity_counts={
            "critical": sev.get("critical", 0),
            "high": sev.get("high", 0),
            "medium": sev.get("medium", 0),
            "low": sev.get("low", 0),
        },
        last_scan_at=data.get("last_scan_at"),
        drift_monitoring=data.get("drift_monitoring", False),
        drift_mode=data.get("drift_mode"),
        drift_last_event_at=data.get("drift_last_event_at"),
        has_cloud_intelligence=data.get("has_cloud_intelligence", False),
        cross_validation_score=data.get("cross_validation_score"),
    )

    # Read scan doc
    scan_doc = await (
        db.collection("customers")
        .document(ctx.customer_id)
        .collection("projects")
        .document(project_id)
        .collection("scan_history")
        .document(scan_id)
        .get()
    )

    scan_data: dict[str, Any] = scan_doc.to_dict() if scan_doc.exists else {}  # type: ignore[assignment]
    scanned_at = str(scan_data.get("scanned_at", data.get("last_scan_at", "")))
    score = float(scan_data.get("score", data.get("score", 0.0) or 0.0))
    frameworks = list(scan_data.get("frameworks", data.get("frameworks", [])))
    scan_sev = scan_data.get("severity_counts", project.severity_counts)

    can_share = ctx.role in ("owner", "admin") and "shared_scan_links" in ctx.features

    # Count active shared links
    shared_links_count = 0
    if can_share:
        now = datetime.now(UTC)
        links_query = (
            db.collection("customers")
            .document(ctx.customer_id)
            .collection("projects")
            .document(project_id)
            .collection("shared_links")
            .where("revoked", "==", False)
        )
        link_docs = [d async for d in links_query.stream()]
        shared_links_count = sum(
            1
            for d in link_docs
            if _link_not_expired(d.to_dict(), now)  # type: ignore[arg-type]
        )

    detail = ScanDetailContext(
        customer_email=ctx.email,
        tier=ctx.tier,
        project=project,
        scan_id=scan_id,
        scanned_at=scanned_at,
        frameworks=frameworks,
        score=score,
        severity_counts=scan_sev,
        can_share=can_share,
        shared_links_count=shared_links_count,
        theme_preference="light",
        show_consent_banner=not ctx.consent_accepted,
        tos_version=ctx.tos_version,
        privacy_version=ctx.privacy_version,
    )

    template_ctx = _base_context(ctx)
    template_ctx.update(
        {
            "project": detail.project,
            "scan_id": detail.scan_id,
            "scanned_at": detail.scanned_at,
            "frameworks": detail.frameworks,
            "score": detail.score,
            "severity_counts": detail.severity_counts,
            "can_share": detail.can_share,
            "shared_links_count": detail.shared_links_count,
            "active_page": "projects",
        }
    )

    label = data.get("project_label", project_doc.id)
    await _audit(
        request,
        ctx,
        "dashboard.report_viewed",
        {"project_label": label, "scan_id": scan_id},
    )

    return await _render_template("scan_detail.html", template_ctx)


# ---------------------------------------------------------------------------
# Phase 15b — Settings
# ---------------------------------------------------------------------------


@router.get("/dashboard/settings")
async def settings_page(
    ctx: CustomerContext = _auth_dep,
) -> HTMLResponse:
    """Settings page with Paddle portal, API key, downloads."""
    from backend.shared.firestore import get_customer

    customer = await get_customer(ctx.customer_id)
    if customer is None:
        customer = {}

    status = customer.get("status", "active")

    # Paddle portal — fresh per load, never cached
    paddle_portal_url = ""
    paddle_customer_id = customer.get("paddle_customer_id", "")
    if paddle_customer_id:
        paddle_portal_url = (
            f"https://checkout.paddle.com/customer/portal/{paddle_customer_id}"
        )

    # API key last 4
    api_key_last4 = customer.get("api_key_last4", "****")

    # GCS signed URLs (15 min)
    license_download_url = "#"
    bundle_download_url = "#"
    bundle_version = customer.get("bundle_version", "latest")

    settings_ctx = SettingsContext(
        customer_email=ctx.email,
        tier=ctx.tier,
        status=status,
        subscription_expires_at=ctx.subscription_expires_at,
        cancellation_pending=ctx.cancellation_pending,
        api_key_last4=api_key_last4,
        paddle_portal_url=paddle_portal_url,
        license_download_url=license_download_url,
        bundle_download_url=bundle_download_url,
        bundle_version=bundle_version,
        show_consent_banner=not ctx.consent_accepted,
        tos_version=ctx.tos_version,
        privacy_version=ctx.privacy_version,
    )

    template_ctx = _base_context(ctx)
    template_ctx.update(
        {
            "status": settings_ctx.status,
            "subscription_expires_at": settings_ctx.subscription_expires_at,
            "cancellation_pending": settings_ctx.cancellation_pending,
            "api_key_last4": settings_ctx.api_key_last4,
            "paddle_portal_url": settings_ctx.paddle_portal_url,
            "license_download_url": settings_ctx.license_download_url,
            "bundle_download_url": settings_ctx.bundle_download_url,
            "bundle_version": settings_ctx.bundle_version,
            "active_page": "settings",
        }
    )

    return await _render_template("settings.html", template_ctx)


@router.get("/dashboard/shared/{share_token}")
async def shared_scan_get(
    share_token: str,
    request: Request,
) -> HTMLResponse:
    """Shared scan page GET (no auth). Renders password form or scan data."""
    link_data, parent_path = await _resolve_share_link(share_token)

    # Password required — show form
    if link_data.get("password_hash"):
        return await _render_shared_scan_template(
            password_required=True,
            password_error=None,
        )

    # No password — increment and render
    await _increment_access_count(parent_path, link_data["token_hash"])
    return await _render_shared_scan_from_link(link_data, parent_path)


@router.post("/dashboard/shared/{share_token}")
async def shared_scan_post(
    share_token: str,
    request: Request,
) -> HTMLResponse:
    """Shared scan page POST (no auth). Verifies password and renders."""
    link_data, parent_path = await _resolve_share_link(share_token)

    form = await request.form()
    password = str(form.get("password", ""))

    stored_hash = link_data.get("password_hash", "")
    if not stored_hash or not bcrypt.checkpw(
        password.encode(),
        stored_hash.encode(),
    ):
        raise SharedScanPasswordError()

    await _increment_access_count(parent_path, link_data["token_hash"])
    return await _render_shared_scan_from_link(link_data, parent_path)


# ---------------------------------------------------------------------------
# Phase 15b — Partials
# ---------------------------------------------------------------------------


@router.get("/dashboard/partials/project-card/{project_id}")
async def partial_project_card(
    project_id: str,
    ctx: CustomerContext = _auth_dep,
) -> HTMLResponse:
    """htmx partial: single project card."""
    from backend.shared.firestore import get_db

    db = get_db()
    doc = await (
        db.collection("customers")
        .document(ctx.customer_id)
        .collection("projects")
        .document(project_id)
        .get()
    )

    if not doc.exists:
        return HTMLResponse("<div>Project not found</div>", status_code=404)

    data: dict[str, Any] = doc.to_dict()  # type: ignore[assignment]
    sev = data.get("severity_counts", {})
    project = DashboardProjectCard(
        project_id=doc.id,
        project_label=data.get("project_label", doc.id),
        client_label=data.get("client_label"),
        cloud=data.get("cloud", "gcp"),
        frameworks=data.get("frameworks", []),
        status=data.get("status", "active"),
        score=data.get("score"),
        score_delta=data.get("score_delta"),
        passed=data.get("passed", 0),
        failed=data.get("failed", 0),
        severity_counts={
            "critical": sev.get("critical", 0),
            "high": sev.get("high", 0),
            "medium": sev.get("medium", 0),
            "low": sev.get("low", 0),
        },
        last_scan_at=data.get("last_scan_at"),
        drift_monitoring=data.get("drift_monitoring", False),
        drift_mode=data.get("drift_mode"),
        drift_last_event_at=data.get("drift_last_event_at"),
        has_cloud_intelligence=data.get("has_cloud_intelligence", False),
        cross_validation_score=data.get("cross_validation_score"),
    )

    return await _render_template(
        "partials/project_card.html",
        {"project": project},
    )


@router.get("/dashboard/partials/findings/{project_id}")
async def partial_findings(
    project_id: str,
    request: Request,
    ctx: CustomerContext = _auth_dep,
) -> HTMLResponse:
    """htmx partial: findings list with filters and pagination."""
    from backend.dashboard.cache import get_cached_findings, set_cached_findings

    severity = request.query_params.get("severity", "all")
    framework = request.query_params.get("framework", "all")
    status = request.query_params.get("status", "fail")
    sort_by = request.query_params.get("sort", "severity")
    page = int(request.query_params.get("page", "1"))
    per_page = int(request.query_params.get("per_page", "25"))

    # Clamp per_page
    if per_page not in (10, 25, 50):
        per_page = 25

    from backend.shared.firestore import get_db

    db = get_db()
    project_doc = await (
        db.collection("customers")
        .document(ctx.customer_id)
        .collection("projects")
        .document(project_id)
        .get()
    )

    if not project_doc.exists:
        return HTMLResponse("<div>Project not found</div>", status_code=404)

    project_data: dict[str, Any] = project_doc.to_dict()  # type: ignore[assignment]
    scan_id = project_data.get("latest_scan", "")

    findings: list[dict[str, Any]] = []
    error_message = ""

    if scan_id:
        cached = get_cached_findings(ctx.customer_id, project_id, scan_id)
        if cached is not None:
            findings = cached
        else:
            try:
                from backend.shared.gcs import download_findings

                uri = f"gs://complyform-prod-findings/{ctx.customer_id}/{project_id}/{scan_id}.json"
                findings = await download_findings(uri)
                set_cached_findings(ctx.customer_id, project_id, scan_id, findings)
            except Exception:
                error_message = (
                    "Findings unavailable. Re-push scan: complyform dashboard push"
                )
    else:
        error_message = "Findings unavailable. Re-push scan: complyform dashboard push"

    # Filter
    if severity != "all":
        findings = [f for f in findings if f.get("severity") == severity]
    if framework != "all":
        findings = [
            f
            for f in findings
            if framework in f.get("frameworks", []) or f.get("framework") == framework
        ]
    if status != "all":
        if status == "fail":
            findings = [
                f for f in findings if f.get("status", "").upper() in ("FAIL", "FAILED")
            ]
        elif status == "manual":
            findings = [
                f for f in findings if f.get("status", "").upper() == "MANUAL_REVIEW"
            ]

    # Sort
    if sort_by == "severity":
        findings.sort(key=lambda f: _SEVERITY_ORDER.get(f.get("severity", "low"), 4))
    elif sort_by == "framework":
        findings.sort(key=lambda f: f.get("framework", ""))
    elif sort_by == "resource":
        findings.sort(key=lambda f: f.get("resource", ""))
    elif sort_by == "control":
        findings.sort(key=lambda f: f.get("control_id", ""))

    # Paginate
    total = len(findings)
    start = (page - 1) * per_page
    end = start + per_page
    page_findings = findings[start:end]
    has_more = end < total

    # Extract available frameworks for filter dropdown
    frameworks_available = sorted(
        {
            fw
            for f in findings
            for fw in (f.get("frameworks", []) or [f.get("framework", "")])
            if fw
        }
    )

    return await _render_template(
        "partials/findings.html",
        {
            "project_id": project_id,
            "findings": page_findings,
            "severity": severity,
            "framework": framework,
            "status": status,
            "sort": sort_by,
            "page": page,
            "per_page": per_page,
            "has_more": has_more,
            "total": total,
            "frameworks_available": frameworks_available,
            "error_message": error_message,
        },
    )


@router.get("/dashboard/partials/controls/{project_id}")
async def partial_controls(
    project_id: str,
    request: Request,
    ctx: CustomerContext = _auth_dep,
) -> HTMLResponse:
    """htmx partial: controls list grouped by control_id."""
    from backend.dashboard.cache import get_cached_findings, set_cached_findings

    framework_filter = request.query_params.get("framework", "all")
    status_filter = request.query_params.get(
        "status_filter",
        request.query_params.get("status", "all"),
    )
    page = int(request.query_params.get("page", "1"))
    per_page = int(request.query_params.get("per_page", "25"))
    expand = request.query_params.get("expand")

    from backend.shared.firestore import get_db

    db = get_db()
    project_doc = await (
        db.collection("customers")
        .document(ctx.customer_id)
        .collection("projects")
        .document(project_id)
        .get()
    )

    if not project_doc.exists:
        return HTMLResponse("<div>Project not found</div>", status_code=404)

    project_data: dict[str, Any] = project_doc.to_dict()  # type: ignore[assignment]
    scan_id = project_data.get("latest_scan", "")

    findings: list[dict[str, Any]] = []
    if scan_id:
        cached = get_cached_findings(ctx.customer_id, project_id, scan_id)
        if cached is not None:
            findings = cached
        else:
            try:
                from backend.shared.gcs import download_findings

                uri = f"gs://complyform-prod-findings/{ctx.customer_id}/{project_id}/{scan_id}.json"
                findings = await download_findings(uri)
                set_cached_findings(ctx.customer_id, project_id, scan_id, findings)
            except Exception:
                findings = []

    # Group by control_id
    controls_map: dict[str, ControlEntry] = {}
    for f in findings:
        cid = f.get("control_id", "unknown")
        if cid not in controls_map:
            controls_map[cid] = ControlEntry(
                control_id=cid,
                control_title=f.get("control_title", ""),
                framework=f.get("framework", ""),
                status="pass",
                resource_count=0,
                failing_resources=0,
                severity=f.get("severity", "low"),
                cross_mapped=list(f.get("cross_mappings", []) or []),
                evidence_summary=None,
            )
        entry = controls_map[cid]
        entry.resource_count += 1
        f_status = f.get("status", "").upper()
        if f_status in ("FAIL", "FAILED"):
            entry.failing_resources += 1
            entry.status = "fail"
        elif f_status == "MANUAL_REVIEW" and entry.status != "fail":
            entry.status = "manual"
        # Track highest severity
        if _SEVERITY_ORDER.get(f.get("severity", "low"), 4) < _SEVERITY_ORDER.get(
            entry.severity, 4
        ):
            entry.severity = f.get("severity", "low")
        # Merge cross_mapped
        for cm in f.get("cross_mappings", []) or []:
            if cm not in entry.cross_mapped:
                entry.cross_mapped.append(cm)

    all_controls = list(controls_map.values())

    # If expand requested, return resource detail rows
    if expand:
        expand_findings = [f for f in findings if f.get("control_id") == expand]
        rows_html = ""
        for ef in expand_findings:
            sev = ef.get("severity", "low")
            resource = ef.get("resource", "")
            status = ef.get("status", "")
            rows_html += (
                f'<div class="px-6 py-1 text-xs" style="color: var(--text-secondary);">'
                f"{resource} — {status} ({sev})</div>"
            )
        if not rows_html:
            rows_html = (
                '<div class="px-6 py-2 text-xs"'
                ' style="color: var(--text-muted);">'
                "No resources</div>"
            )
        return HTMLResponse(rows_html)

    # Frameworks available
    frameworks_available = sorted({c.framework for c in all_controls if c.framework})

    # Filter
    if framework_filter != "all":
        all_controls = [c for c in all_controls if c.framework == framework_filter]
    if status_filter != "all":
        all_controls = [c for c in all_controls if c.status == status_filter]

    # Counts (after filter)
    passing = sum(1 for c in all_controls if c.status == "pass")
    failing = sum(1 for c in all_controls if c.status == "fail")
    manual = sum(1 for c in all_controls if c.status == "manual")
    total_controls = len(all_controls)

    # Paginate
    total_pages = max(1, math.ceil(total_controls / per_page))
    start = (page - 1) * per_page
    page_controls = all_controls[start : start + per_page]

    controls_ctx = ControlsViewContext(
        project_id=project_id,
        controls=page_controls,
        total_controls=total_controls,
        passing=passing,
        failing=failing,
        manual=manual,
        frameworks_available=frameworks_available,
        selected_framework=framework_filter,
        selected_status=status_filter,
        page=page,
        total_pages=total_pages,
    )

    return await _render_template(
        "partials/controls.html",
        {
            "project_id": project_id,
            "controls": controls_ctx.controls,
            "total_controls": controls_ctx.total_controls,
            "passing": controls_ctx.passing,
            "failing": controls_ctx.failing,
            "manual": controls_ctx.manual,
            "frameworks_available": controls_ctx.frameworks_available,
            "selected_framework": controls_ctx.selected_framework,
            "selected_status": controls_ctx.selected_status,
            "page": controls_ctx.page,
            "total_pages": controls_ctx.total_pages,
        },
    )


@router.get("/dashboard/partials/scan-history/{project_id}")
async def partial_scan_history(
    project_id: str,
    request: Request,
    ctx: CustomerContext = _auth_dep,
) -> HTMLResponse:
    """htmx partial: scan history with pagination."""
    from backend.shared.firestore import get_db

    page = int(request.query_params.get("page", "1"))
    per_page = int(request.query_params.get("per_page", "25"))

    db = get_db()
    query = (
        db.collection("customers")
        .document(ctx.customer_id)
        .collection("projects")
        .document(project_id)
        .collection("scan_history")
        .order_by("scanned_at", direction="DESCENDING")
        .limit(per_page + 1)
        .offset((page - 1) * per_page)
    )

    docs = [d async for d in query.stream()]
    has_more = len(docs) > per_page
    scan_docs = docs[:per_page]

    scans = [d.to_dict() for d in scan_docs]  # type: ignore[union-attr]

    return await _render_template(
        "partials/scan_history.html",
        {
            "project_id": project_id,
            "scans": scans,
            "page": page,
            "per_page": per_page,
            "has_more": has_more,
        },
    )


@router.get("/dashboard/partials/drift-timeline/{project_id}")
async def partial_drift_timeline(
    project_id: str,
    ctx: CustomerContext = _auth_dep,
) -> HTMLResponse:
    """htmx partial: drift timeline placeholder."""
    return await _render_template(
        "partials/drift_timeline.html",
        {"project_id": project_id},
    )


@router.get("/dashboard/partials/score-trend/{project_id}")
async def partial_score_trend(
    project_id: str,
    ctx: CustomerContext = _auth_dep,
) -> HTMLResponse:
    """htmx partial: score trend chart."""
    from backend.dashboard.cache import (
        get_cached_score_history,
        set_cached_score_history,
    )
    from backend.shared.firestore import get_db

    score_history = get_cached_score_history(ctx.customer_id, project_id)
    if score_history is None:
        db = get_db()
        history_query = (
            db.collection("customers")
            .document(ctx.customer_id)
            .collection("projects")
            .document(project_id)
            .collection("scan_history")
            .order_by("scanned_at", direction="DESCENDING")
            .limit(90)
        )
        history_docs = [d async for d in history_query.stream()]
        score_history = [d.to_dict() for d in history_docs]  # type: ignore[union-attr]
        set_cached_score_history(ctx.customer_id, project_id, score_history)

    drift_events: list[dict[str, Any]] = []

    from backend.dashboard.jinja_env import jinja_env

    template = jinja_env.get_template("partials/score_trend.html")
    html = await template.render_async(
        score_history=score_history,
        drift_events=drift_events,
    )
    return HTMLResponse(html)


@router.get("/dashboard/partials/cloud-intelligence/{project_id}")
async def partial_cloud_intelligence(
    project_id: str,
    ctx: CustomerContext = _auth_dep,
) -> HTMLResponse:
    """htmx partial: cloud intelligence placeholder."""
    return await _render_template(
        "partials/cloud_intelligence.html",
        {"project_id": project_id},
    )


@router.get("/dashboard/partials/alert-config/{project_id}")
async def partial_alert_config_get(
    project_id: str,
    ctx: CustomerContext = _auth_dep,
) -> HTMLResponse:
    """htmx partial: alert configuration GET."""
    from backend.shared.firestore import get_db

    db = get_db()
    doc = await (
        db.collection("customers")
        .document(ctx.customer_id)
        .collection("projects")
        .document(project_id)
        .get()
    )

    alert_config: dict[str, Any] = {}
    if doc.exists:
        data: dict[str, Any] = doc.to_dict()  # type: ignore[assignment]
        alert_config = data.get("alert_config", {})

    return await _render_template(
        "partials/alert_config.html",
        {
            "project_id": project_id,
            "alert_threshold": alert_config.get("alert_threshold", 10),
            "alert_email": alert_config.get("alert_email", ctx.email),
            "webhook_url": alert_config.get("webhook_url", ""),
            "validation_error": "",
            "success_message": "",
        },
    )


@router.put("/dashboard/partials/alert-config/{project_id}")
async def partial_alert_config_put(
    project_id: str,
    request: Request,
    ctx: CustomerContext = _auth_dep,
) -> HTMLResponse:
    """htmx partial: alert configuration PUT."""
    await _validate_csrf(request)

    content_type = request.headers.get("content-type", "")
    if "application/json" in content_type:
        body = await request.json()
    else:
        form = await request.form()
        body = dict(form)

    try:
        config = AlertConfigUpdate(
            alert_threshold=int(body.get("alert_threshold", 10)),
            alert_email=str(body.get("alert_email", "")),
            webhook_url=str(body.get("webhook_url", "")),
        )
    except Exception as e:
        from fastapi import HTTPException

        raise HTTPException(status_code=422, detail=str(e)) from e

    from backend.shared.firestore import get_db

    db = get_db()
    await (
        db.collection("customers")
        .document(ctx.customer_id)
        .collection("projects")
        .document(project_id)
        .update(
            {
                "alert_config": {
                    "alert_threshold": config.alert_threshold,
                    "alert_email": config.alert_email,
                    "webhook_url": config.webhook_url,
                }
            }
        )
    )

    await _audit(
        request,
        ctx,
        "dashboard.drift_config_changed",
        {"project_id": project_id, "threshold": config.alert_threshold},
    )
    if config.webhook_url:
        await _audit(
            request,
            ctx,
            "dashboard.alert_webhook_updated",
            {"project_id": project_id},
        )

    return await _render_template(
        "partials/alert_config.html",
        {
            "project_id": project_id,
            "alert_threshold": config.alert_threshold,
            "alert_email": config.alert_email,
            "webhook_url": config.webhook_url,
            "validation_error": "",
            "success_message": "Alert configuration saved.",
        },
    )


@router.get("/dashboard/partials/command-palette")
async def partial_command_palette(
    request: Request,
    ctx: CustomerContext = _auth_dep,
) -> HTMLResponse:
    """htmx partial: command palette search results."""
    query = request.query_params.get("q", "").strip()
    if not query:
        return HTMLResponse("")

    results: list[CommandPaletteResult] = []
    q_lower = query.lower()

    # Search projects (from cache — zero Firestore reads)
    from backend.dashboard.cache import get_cached_findings, get_cached_projects

    projects = get_cached_projects(ctx.customer_id) or []
    for p in projects:
        if q_lower in p.project_label.lower():
            results.append(
                CommandPaletteResult(
                    type="project",
                    icon="project",
                    label=p.project_label,
                    subtitle=f"{p.cloud} · {p.score}%"
                    if p.score is not None
                    else p.cloud,
                    url=f"/dashboard/projects/{p.project_id}",
                )
            )

    # Search controls (from cached findings — zero Firestore reads)
    seen_controls: set[str] = set()
    for p in projects:
        cached_findings = get_cached_findings(
            ctx.customer_id,
            p.project_id,
            "",
        )
        if cached_findings is None:
            continue
        for f in cached_findings:
            cid = f.get("control_id", "")
            if cid and q_lower in cid.lower() and cid not in seen_controls:
                seen_controls.add(cid)
                results.append(
                    CommandPaletteResult(
                        type="control",
                        icon="control",
                        label=cid,
                        subtitle=f.get("control_title"),
                        url=f"/dashboard/projects/{p.project_id}?tab=controls",
                    )
                )

    # Search pages (hardcoded)
    pages = [
        ("Projects", "/dashboard/"),
        ("Settings", "/dashboard/settings"),
    ]
    for page_label, page_url in pages:
        if q_lower in page_label.lower():
            results.append(
                CommandPaletteResult(
                    type="page",
                    icon="page",
                    label=page_label,
                    subtitle=None,
                    url=page_url,
                )
            )

    # Max 8 results
    results = results[:8]

    return await _render_template(
        "partials/command_palette_results.html",
        {"results": results},
    )


@router.get("/dashboard/partials/shared-links/{project_id}")
async def partial_shared_links(
    project_id: str,
    ctx: CustomerContext = _auth_dep,
) -> HTMLResponse:
    """htmx partial: shared links list — active only."""
    from backend.shared.firestore import get_db

    db = get_db()
    now = datetime.now(UTC)
    links_query = (
        db.collection("customers")
        .document(ctx.customer_id)
        .collection("projects")
        .document(project_id)
        .collection("shared_links")
        .where("revoked", "==", False)
    )

    link_docs = [d async for d in links_query.stream()]
    links: list[dict[str, Any]] = []
    for d in link_docs:
        link_data: dict[str, Any] = d.to_dict()  # type: ignore[assignment]
        if _link_not_expired(link_data, now):
            links.append(
                {
                    "token_hash": d.id,
                    "scan_id": link_data.get("scan_id", ""),
                    "scope": link_data.get("scope", "full"),
                    "expires_at": str(link_data.get("expires_at", "")),
                    "access_count": link_data.get("access_count", 0),
                }
            )

    return await _render_template(
        "partials/shared_links.html",
        {"project_id": project_id, "links": links},
    )


@router.post("/dashboard/projects/{project_id}/scans/{scan_id}/share")
async def create_share_link(
    project_id: str,
    scan_id: str,
    request: Request,
    ctx: CustomerContext = _auth_dep,
) -> JSONResponse:
    """Create share link — owner/admin only, tier-gated."""
    await _validate_csrf(request)

    from fastapi import HTTPException

    if ctx.role not in ("owner", "admin"):
        raise HTTPException(status_code=403, detail="Insufficient permissions")
    if "shared_scan_links" not in ctx.features:
        raise HTTPException(
            status_code=403,
            detail="Feature not available on your tier",
        )

    from backend.shared.firestore import get_db

    db = get_db()
    now = datetime.now(UTC)

    # Count active links (max 10)
    links_ref = (
        db.collection("customers")
        .document(ctx.customer_id)
        .collection("projects")
        .document(project_id)
        .collection("shared_links")
        .where("revoked", "==", False)
    )
    link_docs = [d async for d in links_ref.stream()]
    active_count = sum(
        1
        for d in link_docs
        if _link_not_expired(d.to_dict(), now)  # type: ignore[arg-type]
    )

    if active_count >= 10:
        # Get project label for error
        project_doc = await (
            db.collection("customers")
            .document(ctx.customer_id)
            .collection("projects")
            .document(project_id)
            .get()
        )
        label = project_id
        if project_doc.exists:
            label = project_doc.to_dict().get("project_label", project_id)  # type: ignore[union-attr]
        raise ShareLinkLimitError(label, 10)

    body = await request.json()
    try:
        req = CreateShareLinkRequest(**body)
    except Exception as e:
        raise HTTPException(status_code=422, detail=str(e)) from e

    token = secrets.token_urlsafe(32)
    token_hash = hashlib.sha256(token.encode()).hexdigest()

    password_hash: str | None = None
    if req.password:
        password_hash = bcrypt.hashpw(
            req.password.encode(),
            bcrypt.gensalt(),
        ).decode()

    expires_at = now + timedelta(days=req.expires_in_days)

    await (
        db.collection("customers")
        .document(ctx.customer_id)
        .collection("projects")
        .document(project_id)
        .collection("shared_links")
        .document(token_hash)
        .set(
            {
                "token_hash": token_hash,
                "scan_id": scan_id,
                "created_at": now.isoformat(),
                "expires_at": expires_at.isoformat(),
                "created_by": ctx.email,
                "access_count": 0,
                "password_hash": password_hash,
                "scope": req.scope,
                "revoked": False,
            }
        )
    )

    await _audit(
        request,
        ctx,
        "dashboard.share_link_created",
        {"project_id": project_id, "scan_id": scan_id, "scope": req.scope},
    )

    share_url = f"https://dashboard.complyform.dev/dashboard/shared/{token}"

    return JSONResponse(
        {
            "share_url": share_url,
            "token": token,
            "expires_at": expires_at.isoformat(),
        }
    )


@router.delete("/dashboard/projects/{project_id}/shared-links/{link_id}")
async def revoke_share_link(
    project_id: str,
    link_id: str,
    request: Request,
    ctx: CustomerContext = _auth_dep,
) -> Response:
    """Revoke share link — set revoked=True, return 204."""
    await _validate_csrf(request)

    from fastapi import HTTPException

    if ctx.role not in ("owner", "admin"):
        raise HTTPException(status_code=403, detail="Insufficient permissions")

    from backend.shared.firestore import get_db

    await (
        get_db()
        .collection("customers")
        .document(ctx.customer_id)
        .collection("projects")
        .document(project_id)
        .collection("shared_links")
        .document(link_id)
        .update({"revoked": True})
    )

    await _audit(
        request,
        ctx,
        "dashboard.share_link_revoked",
        {"project_id": project_id, "link_id": link_id},
    )

    return Response(status_code=204)


# ---------------------------------------------------------------------------
# Shared scan helpers
# ---------------------------------------------------------------------------


def _link_not_expired(link_data: dict[str, Any], now: datetime) -> bool:
    """Check if a shared link is not expired."""
    expires_at_str = link_data.get("expires_at", "")
    if not expires_at_str:
        return False
    try:
        expires_at = datetime.fromisoformat(str(expires_at_str))
        return now < expires_at
    except ValueError, TypeError:
        return False


async def _resolve_share_link(
    share_token: str,
) -> tuple[dict[str, Any], str]:
    """Hash token, find link doc, validate not revoked/expired.

    Returns (link_data, parent_path) where parent_path is the
    Firestore path to the shared_links collection parent.
    """
    from backend.shared.firestore import get_db

    token_hash = hashlib.sha256(share_token.encode()).hexdigest()
    db = get_db()

    # Collection group query on shared_links
    query = db.collection_group("shared_links").where("token_hash", "==", token_hash)
    docs = [d async for d in query.stream()]

    if not docs:
        raise ShareLinkNotFoundError()

    doc = docs[0]
    link_data: dict[str, Any] = doc.to_dict()  # type: ignore[assignment]
    link_data["token_hash"] = token_hash

    if link_data.get("revoked"):
        raise ShareLinkExpiredError(token_hash[:8])

    now = datetime.now(UTC)
    if not _link_not_expired(link_data, now):
        raise ShareLinkExpiredError(token_hash[:8])

    # Extract parent path for increment
    parent_path = doc.reference.parent.parent.path  # type: ignore[union-attr]

    return link_data, parent_path


async def _increment_access_count(parent_path: str, token_hash: str) -> None:
    """Increment access_count atomically."""
    from backend.shared.firestore import get_db

    db = get_db()
    doc_ref = db.document(parent_path).collection("shared_links").document(token_hash)
    doc = await doc_ref.get()
    current = 0
    if doc.exists:
        current = doc.to_dict().get("access_count", 0)  # type: ignore[union-attr]
    await doc_ref.update({"access_count": current + 1})


async def _render_shared_scan_template(
    *,
    password_required: bool,
    password_error: str | None,
) -> HTMLResponse:
    """Render the shared scan password page."""
    response = await _render_template(
        "shared_scan.html",
        {
            "password_required": password_required,
            "password_error": password_error,
        },
    )
    response.headers["X-Frame-Options"] = "DENY"
    response.headers["Content-Security-Policy"] = "frame-ancestors 'none'"
    return response


async def _render_shared_scan_from_link(
    link_data: dict[str, Any],
    parent_path: str,
) -> HTMLResponse:
    """Load findings and render the shared scan page."""
    from backend.shared.firestore import get_db
    from backend.shared.gcs import download_findings

    db = get_db()

    # Extract customer_id and project_id from parent_path
    # parent_path: customers/{cid}/projects/{pid}
    parts = parent_path.split("/")
    customer_id = parts[1] if len(parts) > 1 else ""
    project_id = parts[3] if len(parts) > 3 else ""
    scan_id = link_data.get("scan_id", "")

    # Load project data
    project_doc = await db.document(parent_path).get()
    project_data: dict[str, Any] = project_doc.to_dict() if project_doc.exists else {}  # type: ignore[assignment]

    # Load findings from GCS
    uri = f"gs://complyform-prod-findings/{customer_id}/{project_id}/{scan_id}.json"
    try:
        findings = await download_findings(uri)
    except Exception:
        findings = []

    # Scope redaction
    scope = link_data.get("scope", "full")
    if scope == "summary":
        for i, f in enumerate(findings):
            f["resource"] = f"Resource #{i + 1}"

    # Build severity counts from findings
    severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0}
    for f in findings:
        sev = f.get("severity", "low")
        if sev in severity_counts:
            severity_counts[sev] += 1

    scan_ctx = SharedScanContext(
        scan_id=scan_id,
        scanned_at=str(project_data.get("last_scan_at", "")),
        project_label=project_data.get("project_label", project_id),
        cloud=project_data.get("cloud", ""),
        frameworks=project_data.get("frameworks", []),
        score=float(project_data.get("score", 0.0) or 0.0),
        severity_counts=severity_counts,
        findings=findings,
        scope=scope,
        password_required=False,
    )

    response = await _render_template(
        "shared_scan.html",
        {
            "password_required": False,
            "password_error": None,
            "scan_id": scan_ctx.scan_id,
            "scanned_at": scan_ctx.scanned_at,
            "project_label": scan_ctx.project_label,
            "cloud": scan_ctx.cloud,
            "frameworks": scan_ctx.frameworks,
            "score": scan_ctx.score,
            "severity_counts": scan_ctx.severity_counts,
            "findings": scan_ctx.findings,
            "scope": scan_ctx.scope,
        },
    )
    response.headers["X-Frame-Options"] = "DENY"
    response.headers["Content-Security-Policy"] = "frame-ancestors 'none'"
    return response


# ---------------------------------------------------------------------------
# Phase 17: Members routes
# ---------------------------------------------------------------------------


class InviteMemberRequest(BaseModel):
    """Pydantic model for member invitation."""

    model_config = ConfigDict(strict=True)
    email: EmailStr
    role: Literal["admin", "viewer"]


class UpdateRoleRequest(BaseModel):
    """Pydantic model for role update."""

    model_config = ConfigDict(strict=True)
    role: Literal["admin", "viewer"]


@router.get("/dashboard/settings/members")
async def settings_members(
    request: Request,
    ctx: CustomerContext = _auth_dep,
) -> HTMLResponse:
    """Render members management page."""
    from fastapi import HTTPException

    if "team_members" not in ctx.features:
        raise HTTPException(
            status_code=403, detail="Feature not available on your tier"
        )
    if ctx.role not in ("owner", "admin"):
        raise HTTPException(status_code=403, detail="Insufficient permissions")

    from backend.dashboard.members import list_members
    from backend.shared.tier_config import TIER_CONFIG

    members = await list_members(ctx.customer_id)
    tier_cfg = TIER_CONFIG.get(ctx.tier, {})
    member_limit: int | None = tier_cfg.get("member_limit")

    active_count = sum(1 for m in members if m.status == "active")
    pending_count = sum(1 for m in members if m.status == "pending")

    can_invite = ctx.role in ("owner", "admin") and (
        member_limit is None or (active_count + pending_count) < member_limit
    )

    members_ctx = MembersContext(
        members=members,
        active_count=active_count,
        pending_count=pending_count,
        member_limit=member_limit,
        can_invite=can_invite,
    )

    template_ctx = _base_context(ctx)
    template_ctx.update(
        {
            "members": [
                {
                    "email_hash": m.email_hash,
                    "email": m.email,
                    "role": m.role,
                    "status": m.status,
                    "invited_by": m.invited_by,
                    "invited_at": m.invited_at,
                    "accepted_at": m.accepted_at,
                    "last_accessed_at": m.last_accessed_at,
                }
                for m in members_ctx.members
            ],
            "active_count": members_ctx.active_count,
            "pending_count": members_ctx.pending_count,
            "member_limit": members_ctx.member_limit,
            "can_invite": members_ctx.can_invite,
            "active_page": "settings",
        }
    )

    return await _render_template("settings_members.html", template_ctx)


@router.post("/dashboard/settings/members/invite", response_model=None)
async def invite_member_route(
    request: Request,
    ctx: CustomerContext = _auth_dep,
) -> HTMLResponse | JSONResponse:
    """Invite a new team member."""
    await _validate_csrf(request)

    from fastapi import HTTPException

    if "team_members" not in ctx.features:
        raise HTTPException(
            status_code=403, detail="Feature not available on your tier"
        )
    if ctx.role not in ("owner", "admin"):
        raise HTTPException(status_code=403, detail="Insufficient permissions")

    content_type = request.headers.get("content-type", "")
    if "application/json" in content_type:
        body = await request.json()
    else:
        form = await request.form()
        body = dict(form)

    try:
        req = InviteMemberRequest(
            email=str(body.get("email", "")),
            role=str(body.get("role", "viewer")),
        )
    except Exception as e:
        raise HTTPException(status_code=400, detail=str(e)) from e

    from backend.dashboard.members import invite_member

    member = await invite_member(
        customer_id=ctx.customer_id,
        email=req.email,
        role=req.role,
        invited_by=ctx.member_email,
        tier=ctx.tier,
    )

    await _audit(
        request,
        ctx,
        "dashboard.member_invited",
        {"email": member.email, "role": member.role},
    )

    return RedirectResponse(
        url="/dashboard/settings/members",
        status_code=303,
    )


@router.put("/dashboard/settings/members/{email_hash}/role", response_model=None)
async def update_member_role_route(
    email_hash: str,
    request: Request,
    ctx: CustomerContext = _auth_dep,
) -> HTMLResponse | RedirectResponse:
    """Update member role."""
    await _validate_csrf(request)

    from fastapi import HTTPException

    if ctx.role not in ("owner", "admin"):
        raise HTTPException(status_code=403, detail="Insufficient permissions")

    content_type = request.headers.get("content-type", "")
    if "application/json" in content_type:
        body = await request.json()
    else:
        form = await request.form()
        body = dict(form)

    new_role = str(body.get("role", ""))

    from backend.dashboard.members import update_member_role

    member = await update_member_role(
        customer_id=ctx.customer_id,
        email_hash=email_hash,
        new_role=new_role,
        actor_email=ctx.member_email,
    )

    await _audit(
        request,
        ctx,
        "dashboard.member_role_changed",
        {"email": member.email, "old_role": "", "new_role": member.role},
    )

    return RedirectResponse(
        url="/dashboard/settings/members",
        status_code=303,
    )


@router.delete("/dashboard/settings/members/{email_hash}")
async def remove_member_route(
    email_hash: str,
    request: Request,
    ctx: CustomerContext = _auth_dep,
) -> Response:
    """Remove a team member."""
    await _validate_csrf(request)

    from fastapi import HTTPException

    if ctx.role not in ("owner", "admin"):
        raise HTTPException(status_code=403, detail="Insufficient permissions")

    from backend.dashboard.members import remove_member

    await remove_member(
        customer_id=ctx.customer_id,
        email_hash=email_hash,
        actor_email=ctx.member_email,
    )

    await _audit(
        request,
        ctx,
        "dashboard.member_removed",
        {"email_hash": email_hash},
    )

    return RedirectResponse(
        url="/dashboard/settings/members",
        status_code=303,
    )


@router.post("/dashboard/settings/members/{email_hash}/resend")
async def resend_invitation_route(
    email_hash: str,
    request: Request,
    ctx: CustomerContext = _auth_dep,
) -> Response:
    """Resend invitation email for pending member."""
    await _validate_csrf(request)

    from fastapi import HTTPException

    if ctx.role not in ("owner", "admin"):
        raise HTTPException(status_code=403, detail="Insufficient permissions")

    from backend.dashboard.members import resend_invitation

    await resend_invitation(
        customer_id=ctx.customer_id,
        email_hash=email_hash,
    )

    return RedirectResponse(
        url="/dashboard/settings/members",
        status_code=303,
    )


# ---------------------------------------------------------------------------
# Phase 17: Webhooks routes
# ---------------------------------------------------------------------------


class CreateWebhookRequest(BaseModel):
    """Pydantic model for webhook creation."""

    model_config = ConfigDict(strict=True)
    url: str
    event_types: list[str]

    @field_validator("url")
    @classmethod
    def validate_url(cls, v: str) -> str:
        if not v.startswith("https://"):
            raise ValueError("Webhook URL must use HTTPS")
        return v


@router.get("/dashboard/settings/webhooks")
async def settings_webhooks(
    request: Request,
    ctx: CustomerContext = _auth_dep,
) -> HTMLResponse:
    """Render webhooks management page."""
    from fastapi import HTTPException

    if "webhooks" not in ctx.features:
        raise HTTPException(
            status_code=403, detail="Feature not available on your tier"
        )
    if ctx.role not in ("owner", "admin"):
        raise HTTPException(status_code=403, detail="Insufficient permissions")

    from backend.dashboard.webhooks import list_webhooks

    webhooks = await list_webhooks(ctx.customer_id)
    active_count = sum(1 for w in webhooks if w.status in ("active", "unhealthy"))

    webhooks_ctx = WebhooksContext(
        webhooks=webhooks,
        webhook_limit=3,
        can_create=ctx.role in ("owner", "admin") and active_count < 3,
    )

    template_ctx = _base_context(ctx)
    template_ctx.update(
        {
            "webhooks": [
                {
                    "webhook_id": w.webhook_id,
                    "url": w.url,
                    "event_types": w.event_types,
                    "status": w.status,
                    "created_at": w.created_at,
                    "last_delivery_at": w.last_delivery_at,
                    "consecutive_failures": w.consecutive_failures,
                    "signing_secret_last4": w.signing_secret_last4,
                }
                for w in webhooks_ctx.webhooks
            ],
            "webhook_limit": webhooks_ctx.webhook_limit,
            "can_create": webhooks_ctx.can_create,
            "active_page": "settings",
        }
    )

    return await _render_template("settings_webhooks.html", template_ctx)


@router.post("/dashboard/settings/webhooks/create")
async def create_webhook_route(
    request: Request,
    ctx: CustomerContext = _auth_dep,
) -> JSONResponse:
    """Create a new webhook endpoint."""
    await _validate_csrf(request)

    from fastapi import HTTPException

    if "webhooks" not in ctx.features:
        raise HTTPException(
            status_code=403, detail="Feature not available on your tier"
        )
    if ctx.role not in ("owner", "admin"):
        raise HTTPException(status_code=403, detail="Insufficient permissions")

    content_type = request.headers.get("content-type", "")
    if "application/json" in content_type:
        body = await request.json()
    else:
        form = await request.form()
        body = dict(form)
        # Handle multi-value checkboxes from form
        event_types = form.getlist("event_types")
        body["event_types"] = list(event_types)

    try:
        req = CreateWebhookRequest(
            url=str(body.get("url", "")),
            event_types=body.get("event_types", []),
        )
    except Exception as e:
        raise HTTPException(status_code=400, detail=str(e)) from e

    from backend.dashboard.webhooks import create_webhook

    webhook = await create_webhook(
        customer_id=ctx.customer_id,
        url=req.url,
        event_types=req.event_types,
        actor_email=ctx.member_email,
    )

    await _audit(
        request,
        ctx,
        "dashboard.alert_webhook_updated",
        {"webhook_id": webhook.webhook_id, "action": "created"},
    )

    return JSONResponse(
        {
            "webhook_id": webhook.webhook_id,
            "url": webhook.url,
            "signing_secret": webhook.raw_secret,
        }
    )


@router.delete("/dashboard/settings/webhooks/{webhook_id}")
async def delete_webhook_route(
    webhook_id: str,
    request: Request,
    ctx: CustomerContext = _auth_dep,
) -> Response:
    """Delete a webhook endpoint."""
    await _validate_csrf(request)

    from fastapi import HTTPException

    if ctx.role not in ("owner", "admin"):
        raise HTTPException(status_code=403, detail="Insufficient permissions")

    from backend.dashboard.webhooks import delete_webhook

    await delete_webhook(
        customer_id=ctx.customer_id,
        webhook_id=webhook_id,
    )

    await _audit(
        request,
        ctx,
        "dashboard.alert_webhook_updated",
        {"webhook_id": webhook_id, "action": "deleted"},
    )

    return Response(status_code=204)


@router.put("/dashboard/settings/webhooks/{webhook_id}")
async def update_webhook_route(
    webhook_id: str,
    request: Request,
    ctx: CustomerContext = _auth_dep,
) -> JSONResponse:
    """Update webhook URL or event types."""
    await _validate_csrf(request)

    from fastapi import HTTPException

    if ctx.role not in ("owner", "admin"):
        raise HTTPException(status_code=403, detail="Insufficient permissions")

    body = await request.json()

    from backend.dashboard.webhooks import update_webhook

    webhook = await update_webhook(
        customer_id=ctx.customer_id,
        webhook_id=webhook_id,
        url=body.get("url"),
        event_types=body.get("event_types"),
    )

    await _audit(
        request,
        ctx,
        "dashboard.alert_webhook_updated",
        {"webhook_id": webhook_id, "action": "updated"},
    )

    return JSONResponse(
        {
            "webhook_id": webhook.webhook_id,
            "url": webhook.url,
            "event_types": webhook.event_types,
        }
    )


@router.post("/dashboard/settings/webhooks/{webhook_id}/test")
async def test_webhook_route(
    webhook_id: str,
    request: Request,
    ctx: CustomerContext = _auth_dep,
) -> JSONResponse:
    """Send a test event to a webhook."""
    await _validate_csrf(request)

    from fastapi import HTTPException

    if ctx.role not in ("owner", "admin"):
        raise HTTPException(status_code=403, detail="Insufficient permissions")

    from backend.dashboard.webhooks import send_test_event

    delivery = await send_test_event(
        customer_id=ctx.customer_id,
        webhook_id=webhook_id,
    )

    return JSONResponse(
        {
            "delivery_id": delivery.delivery_id,
            "status": delivery.status,
            "response_code": delivery.response_code,
        }
    )


@router.get("/dashboard/settings/webhooks/{webhook_id}/deliveries")
async def webhook_deliveries_route(
    webhook_id: str,
    ctx: CustomerContext = _auth_dep,
) -> HTMLResponse:
    """Get delivery log for a webhook (htmx partial)."""
    from backend.dashboard.webhooks import get_delivery_log

    deliveries = await get_delivery_log(
        customer_id=ctx.customer_id,
        webhook_id=webhook_id,
    )

    rows = ""
    for d in deliveries:
        bg = (
            "#dcfce7; color: #166534"
            if d.status == "success"
            else "#fef2f2; color: #dc2626"
        )
        code = d.response_code or "-"
        rows += (
            '<tr style="border-bottom: 1px solid '
            'var(--border-default);">'
            '<td class="py-1" style="color: '
            f'var(--text-secondary);">{d.attempted_at[:19]}</td>'
            '<td class="py-1" style="color: '
            f'var(--text-primary);">{d.event_type}</td>'
            f'<td class="py-1"><span class="px-1.5 py-0.5 '
            f'rounded text-xs" style="background: {bg};">'
            f"{d.status}</span></td>"
            '<td class="py-1" style="color: '
            f'var(--text-secondary);">{code}</td>'
            "</tr>"
        )

    html = (
        '<table class="w-full text-xs">'
        '<thead><tr style="color: var(--text-secondary);">'
        '<th class="text-left py-1">Time</th>'
        '<th class="text-left py-1">Event</th>'
        '<th class="text-left py-1">Status</th>'
        '<th class="text-left py-1">Code</th>'
        "</tr></thead>"
        f"<tbody>{rows}</tbody></table>"
    )

    if not deliveries:
        html = (
            '<p class="text-xs" style="color: '
            'var(--text-secondary);">No deliveries yet.</p>'
        )

    return HTMLResponse(html)


# ---------------------------------------------------------------------------
# Phase 17: Audit log routes
# ---------------------------------------------------------------------------


@router.get("/dashboard/audit-log")
async def audit_log_page(
    request: Request,
    ctx: CustomerContext = _auth_dep,
) -> HTMLResponse:
    """Render audit log page with filters."""
    from fastapi import HTTPException

    if "audit_log" not in ctx.features:
        raise HTTPException(
            status_code=403, detail="Feature not available on your tier"
        )
    if ctx.role == "viewer":
        raise HTTPException(status_code=403, detail="Insufficient permissions")

    from backend.dashboard.audit import AUDIT_EVENT_TYPES, query_audit_log

    event_filter = request.query_params.get("event") or None
    actor_filter = request.query_params.get("actor") or None
    start_date = request.query_params.get("start_date") or None
    end_date = request.query_params.get("end_date") or None
    cursor = request.query_params.get("cursor") or None

    page = await query_audit_log(
        customer_id=ctx.customer_id,
        event_filter=event_filter,
        actor_filter=actor_filter,
        start_date=start_date,
        end_date=end_date,
        cursor=cursor,
    )

    # Build distinct actors list from current page (best effort)
    actors = sorted({e.actor_email for e in page.events if e.actor_email})

    audit_ctx = AuditLogContext(
        page=page,
        event_filter=event_filter,
        actor_filter=actor_filter,
        start_date=start_date,
        end_date=end_date,
        actors=actors,
        event_types=AUDIT_EVENT_TYPES,
    )

    template_ctx = _base_context(ctx)
    template_ctx.update(
        {
            "page": {
                "events": [
                    {
                        "event_id": e.event_id,
                        "event": e.event,
                        "actor_email": e.actor_email,
                        "actor_role": e.actor_role,
                        "timestamp": e.timestamp,
                        "ip_hash": e.ip_hash,
                        "metadata": e.metadata,
                    }
                    for e in audit_ctx.page.events
                ],
                "next_cursor": audit_ctx.page.next_cursor,
            },
            "event_filter": audit_ctx.event_filter,
            "actor_filter": audit_ctx.actor_filter,
            "start_date": audit_ctx.start_date,
            "end_date": audit_ctx.end_date,
            "actors": audit_ctx.actors,
            "event_types": audit_ctx.event_types,
            "active_page": "audit-log",
        }
    )

    return await _render_template("audit_log.html", template_ctx)


@router.get("/dashboard/partials/audit-log")
async def partial_audit_log(
    request: Request,
    ctx: CustomerContext = _auth_dep,
) -> HTMLResponse:
    """htmx partial with filtered audit log results."""
    from fastapi import HTTPException

    if "audit_log" not in ctx.features:
        raise HTTPException(
            status_code=403, detail="Feature not available on your tier"
        )
    if ctx.role == "viewer":
        raise HTTPException(status_code=403, detail="Insufficient permissions")

    from backend.dashboard.audit import query_audit_log

    event_filter = request.query_params.get("event") or None
    actor_filter = request.query_params.get("actor") or None
    start_date = request.query_params.get("start_date") or None
    end_date = request.query_params.get("end_date") or None
    cursor = request.query_params.get("cursor") or None

    page = await query_audit_log(
        customer_id=ctx.customer_id,
        event_filter=event_filter,
        actor_filter=actor_filter,
        start_date=start_date,
        end_date=end_date,
        cursor=cursor,
    )

    rows = ""
    for e in page.events:
        meta = ", ".join(f"{k}={v}" for k, v in e.metadata.items())
        rows += (
            '<tr style="border-bottom: 1px solid '
            'var(--border-default);">'
            '<td class="py-2" style="color: '
            f'var(--text-secondary);">{e.timestamp[:19]}</td>'
            '<td class="py-2" style="color: '
            f'var(--text-primary);">{e.actor_email}</td>'
            '<td class="py-2"><span class="px-2 py-0.5 '
            'rounded text-xs font-medium" style="background: '
            "var(--bg-surface-hover); color: "
            f'var(--text-secondary);">{e.event}</span></td>'
            '<td class="py-2 text-xs" style="color: '
            f'var(--text-secondary);">{meta}</td>'
            "</tr>"
        )

    load_more = ""
    if page.next_cursor:
        params = "&".join(
            f"{k}={v}"
            for k, v in {
                "cursor": page.next_cursor,
                "event": event_filter or "",
                "actor": actor_filter or "",
                "start_date": start_date or "",
                "end_date": end_date or "",
            }.items()
            if v
        )
        load_more = (
            '<div class="mt-4 text-center">'
            "<button "
            f'hx-get="/dashboard/partials/audit-log?{params}" '
            'hx-target="#audit-events" hx-swap="outerHTML" '
            'class="text-sm px-4 py-1.5 rounded" '
            'style="color: var(--accent); border: '
            '1px solid var(--border-default);">'
            "Load More</button></div>"
        )

    _sect_style = (
        "background: var(--bg-surface); border: 1px solid var(--border-default);"
    )
    _tr_style = (
        "color: var(--text-secondary); border-bottom: 1px solid var(--border-default);"
    )
    html = (
        '<div id="audit-events">'
        '<section class="p-4 rounded-lg" '
        f'style="{_sect_style}">'
        '<table class="w-full text-sm"><thead>'
        f'<tr style="{_tr_style}">'
        '<th class="text-left py-2">Timestamp</th>'
        '<th class="text-left py-2">Actor</th>'
        '<th class="text-left py-2">Event</th>'
        '<th class="text-left py-2">Details</th>'
        "</tr></thead>"
        f"<tbody>{rows}</tbody></table>"
        f"{load_more}</section></div>"
    )

    return HTMLResponse(html)


@router.get("/dashboard/audit-log/export-csv")
async def export_audit_csv_route(
    request: Request,
    ctx: CustomerContext = _auth_dep,
) -> Response:
    """Export audit log as CSV."""
    from fastapi import HTTPException

    if "audit_log" not in ctx.features:
        raise HTTPException(
            status_code=403, detail="Feature not available on your tier"
        )
    if ctx.role == "viewer":
        raise HTTPException(status_code=403, detail="Insufficient permissions")

    from backend.dashboard.audit import export_audit_csv

    event_filter = request.query_params.get("event") or None
    actor_filter = request.query_params.get("actor") or None
    start_date = request.query_params.get("start_date") or None
    end_date = request.query_params.get("end_date") or None

    csv_content = await export_audit_csv(
        customer_id=ctx.customer_id,
        event_filter=event_filter,
        actor_filter=actor_filter,
        start_date=start_date,
        end_date=end_date,
    )

    await _audit(
        request, ctx, "dashboard.data_exported", {"format": "csv", "type": "audit_log"}
    )

    return Response(
        content=csv_content,
        media_type="text/csv",
        headers={"Content-Disposition": "attachment; filename=audit_log.csv"},
    )


# ---------------------------------------------------------------------------
# Phase 18: SSO settings routes
# ---------------------------------------------------------------------------


@router.get("/dashboard/settings/sso")
async def settings_sso(
    request: Request,
    ctx: CustomerContext = _auth_dep,
) -> HTMLResponse:
    """SSO configuration page. Requires owner role + sso_saml feature."""
    from fastapi import HTTPException

    if "sso_saml" not in ctx.features:
        raise HTTPException(
            status_code=403, detail="Feature not available on your tier"
        )
    if ctx.role != "owner":
        raise HTTPException(status_code=403, detail="Owner role required")

    from backend.dashboard.sso import get_sso_config

    sso_config = await get_sso_config(ctx.customer_id)

    template_ctx = _base_context(ctx)
    template_ctx.update(
        {
            "active_page": "settings",
            "sso_config": sso_config,
            "can_enforce": "enforce_sso" in ctx.features,
        }
    )
    return await _render_template("settings_sso.html", template_ctx)


@router.post("/dashboard/settings/sso")
async def settings_sso_save(
    request: Request,
    ctx: CustomerContext = _auth_dep,
) -> RedirectResponse:
    """Save SSO config. Owner role required."""
    await _validate_csrf(request)

    from fastapi import HTTPException

    if "sso_saml" not in ctx.features:
        raise HTTPException(
            status_code=403, detail="Feature not available on your tier"
        )
    if ctx.role != "owner":
        raise HTTPException(status_code=403, detail="Owner role required")

    from backend.dashboard.sso import SSOConfigInput, save_sso_config

    form = await request.form()
    config_input = SSOConfigInput(
        enabled=form.get("enabled") == "on",
        email_domain=str(form.get("email_domain", "")),
        enforce_sso=form.get("enforce_sso") == "on" and "enforce_sso" in ctx.features,
        provider_type=str(form.get("provider_type", "saml")),  # type: ignore[arg-type]
        provider_name=str(form.get("provider_name", "custom")),  # type: ignore[arg-type]
        group_mapping={
            str(form.get("admin_group", "")): "admin",
            str(form.get("viewer_group", "")): "viewer",
        }
        if form.get("admin_group")
        else {},
    )

    connection_id = str(form.get("connection_id", ""))
    await save_sso_config(
        ctx.customer_id, config_input, ctx.member_email, connection_id
    )

    await _audit(
        request,
        ctx,
        "dashboard.settings_updated",
        {"section": "sso", "action": "saved"},
    )

    return RedirectResponse(url="/dashboard/settings/sso", status_code=303)


@router.post("/dashboard/settings/sso/delete")
async def settings_sso_delete(
    request: Request,
    ctx: CustomerContext = _auth_dep,
) -> RedirectResponse:
    """Delete SSO config. Owner role required."""
    await _validate_csrf(request)

    from fastapi import HTTPException

    if ctx.role != "owner":
        raise HTTPException(status_code=403, detail="Owner role required")

    from backend.dashboard.sso import delete_sso_config

    await delete_sso_config(ctx.customer_id)

    await _audit(
        request,
        ctx,
        "dashboard.settings_updated",
        {"section": "sso", "action": "deleted"},
    )

    return RedirectResponse(url="/dashboard/settings/sso", status_code=303)


# ---------------------------------------------------------------------------
# Phase 18: IP allowlist / security settings routes
# ---------------------------------------------------------------------------


@router.get("/dashboard/settings/security")
async def settings_security(
    request: Request,
    ctx: CustomerContext = _auth_dep,
) -> HTMLResponse:
    """IP allowlist settings. Enterprise+ only."""
    from fastapi import HTTPException

    if "ip_allowlist" not in ctx.features:
        raise HTTPException(
            status_code=403, detail="Feature not available on your tier"
        )
    if ctx.role != "owner":
        raise HTTPException(status_code=403, detail="Owner role required")

    from backend.dashboard.ip_allowlist import get_ip_allowlist

    ip_config = await get_ip_allowlist(ctx.customer_id)
    client_ip = request.client.host if request.client else "unknown"

    template_ctx = _base_context(ctx)
    template_ctx.update(
        {
            "active_page": "settings",
            "ip_config": ip_config,
            "client_ip": client_ip,
        }
    )
    return await _render_template("settings_security.html", template_ctx)


@router.post("/dashboard/settings/security/ip-allowlist")
async def settings_ip_allowlist_save(
    request: Request,
    ctx: CustomerContext = _auth_dep,
) -> RedirectResponse:
    """Save IP allowlist. Owner role required."""
    await _validate_csrf(request)

    from fastapi import HTTPException

    if "ip_allowlist" not in ctx.features:
        raise HTTPException(
            status_code=403, detail="Feature not available on your tier"
        )
    if ctx.role != "owner":
        raise HTTPException(status_code=403, detail="Owner role required")

    from backend.dashboard.ip_allowlist import (
        IPAllowlistConfig,
        save_ip_allowlist,
        validate_cidrs,
    )

    form = await request.form()
    enabled = form.get("enabled") == "on"
    cidrs_raw = str(form.get("cidrs", "")).strip()
    cidrs = [c.strip() for c in cidrs_raw.split("\n") if c.strip()]

    try:
        validated = validate_cidrs(cidrs)
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=f"Invalid CIDR: {exc}") from exc

    config = IPAllowlistConfig(enabled=enabled, cidrs=validated)
    await save_ip_allowlist(ctx.customer_id, config)

    await _audit(
        request,
        ctx,
        "dashboard.settings_updated",
        {"section": "ip_allowlist"},
    )

    return RedirectResponse(url="/dashboard/settings/security", status_code=303)


# ---------------------------------------------------------------------------
# Phase 18: Data settings routes
# ---------------------------------------------------------------------------


@router.get("/dashboard/settings/data")
async def settings_data(
    request: Request,
    ctx: CustomerContext = _auth_dep,
) -> HTMLResponse:
    """Data export/delete settings."""
    template_ctx = _base_context(ctx)
    template_ctx.update({"active_page": "settings"})
    return await _render_template("settings_data.html", template_ctx)


@router.post("/dashboard/settings/data/export")
async def settings_data_export(
    request: Request,
    ctx: CustomerContext = _auth_dep,
) -> RedirectResponse:
    """Trigger data export. Owner/admin role."""
    await _validate_csrf(request)

    from fastapi import HTTPException

    if ctx.role not in ("owner", "admin"):
        raise HTTPException(status_code=403, detail="Insufficient permissions")

    await _audit(request, ctx, "dashboard.data_exported")

    return RedirectResponse(url="/dashboard/settings/data", status_code=303)


@router.post("/dashboard/settings/data/delete")
async def settings_data_delete(
    request: Request,
    ctx: CustomerContext = _auth_dep,
) -> RedirectResponse:
    """Trigger data deletion. Owner role ONLY."""
    await _validate_csrf(request)

    from fastapi import HTTPException

    if ctx.role != "owner":
        raise HTTPException(status_code=403, detail="Owner role required")

    await _audit(
        request,
        ctx,
        "dashboard.settings_updated",
        {"section": "data", "action": "deletion_requested"},
    )

    return RedirectResponse(url="/dashboard/settings/data", status_code=303)


@router.get(
    "/dashboard/projects/{project_id}/scans/{scan_id}/report-download",
)
async def report_download(
    request: Request,
    project_id: str,
    scan_id: str,
    format: str = "html",
    ctx: CustomerContext = _auth_dep,
) -> JSONResponse:
    """Trigger report download — records an audit event."""
    from backend.shared.firestore import get_db

    db = get_db()
    project_doc = await (
        db.collection("customers")
        .document(ctx.customer_id)
        .collection("projects")
        .document(project_id)
        .get()
    )
    label = project_id
    if project_doc.exists:
        data: dict[str, Any] = project_doc.to_dict()  # type: ignore[assignment]
        label = data.get("project_label", project_id)

    await _audit(
        request,
        ctx,
        "dashboard.report_exported",
        {"project_label": label, "format": format},
    )

    return JSONResponse({"status": "ok", "project_label": label, "format": format})


# TODO Phase 20/21: wire deliver_webhook_event
#   "report.generated" in report generation
# TODO Phase 20/21: wire deliver_webhook_event
#   "license.expiring" in license expiry check
# TODO Phase 18: wire write_audit_event
#   "dashboard.project_archived" when archive route is implemented


# ---------------------------------------------------------------------------
# Phase 21d — Upgrade page
# ---------------------------------------------------------------------------

_ERR_CARD_OPEN = (
    '<div class="rounded-lg p-4"'
    ' style="background: #fef2f2;'
    ' border: 1px solid #fecaca;">'
    '<p class="text-sm" style="color: #991b1b;">'
)
_ERR_CARD_CLOSE = "</p></div>"


def _error_card(msg: str) -> HTMLResponse:
    """Return an inline error card as HTMLResponse."""
    return HTMLResponse(_ERR_CARD_OPEN + msg + _ERR_CARD_CLOSE)


TIER_PRICE_IDS: dict[str, str] = {
    "pro": os.environ.get("PADDLE_PRICE_PRO", ""),
    "team": os.environ.get("PADDLE_PRICE_TEAM", ""),
    "agency": os.environ.get("PADDLE_PRICE_AGENCY", ""),
    "enterprise": os.environ.get("PADDLE_PRICE_ENTERPRISE", ""),
}

TIER_ORDER: dict[str, int] = {
    "community": 0,
    "pro": 1,
    "team": 2,
    "agency": 3,
    "enterprise": 4,
    "enterprise_unlimited": 5,
}

_TIER_HIGHLIGHTS: dict[str, list[str]] = {
    "pro": ["1 project", "11 frameworks", "HTML reports", "AI Explain"],
    "team": ["1 project", "23 frameworks", "Dashboard", "Drift alerts", "SSO"],
    "agency": ["5 projects", "30 frameworks", "Branded PDF", "Batch scan"],
    "enterprise": ["5+ projects", "All frameworks", "SCIM", "IP allowlist"],
}


@router.get("/dashboard/upgrade")
async def upgrade_page(
    request: Request,
    ctx: CustomerContext = _auth_dep,
) -> HTMLResponse:
    """Show upgrade options for current tier.

    Upgrade-only matrix: only tiers above current_tier are shown.
    Enterprise Unlimited is quote-based and never shown.
    Community has no subscription to upgrade — redirects to pricing.
    """
    from backend.shared.tier_config import TIER_CONFIG

    current_rank = TIER_ORDER.get(ctx.tier, 0)

    # Community has no subscription — cannot upgrade inline
    if ctx.tier == "community":
        return RedirectResponse(  # type: ignore[return-value]
            url="https://complyform.dev/pricing",
            status_code=302,
        )

    available: list[dict[str, Any]] = []
    for tier_name in ("pro", "team", "agency", "enterprise"):
        if TIER_ORDER.get(tier_name, 0) > current_rank:
            cfg = TIER_CONFIG.get(tier_name, {})
            available.append(
                {
                    "name": tier_name,
                    "price_annual": cfg.get("price_annual", 0),
                    "highlights": _TIER_HIGHLIGHTS.get(tier_name, []),
                }
            )

    template_ctx = _base_context(ctx)
    template_ctx.update(
        {
            "current_tier": ctx.tier,
            "available_tiers": available,
            "success_message": "",
            "error_message": "",
            "active_page": "settings",
        }
    )

    return await _render_template("upgrade.html", template_ctx)


@router.post("/dashboard/upgrade/preview")
async def upgrade_preview(
    request: Request,
    ctx: CustomerContext = _auth_dep,
) -> HTMLResponse:
    """Paddle proration preview — htmx partial.

    1. Validate target_tier > current_tier.
    2. Call Paddle PATCH /subscriptions/{id}/preview.
    3. Render proration detail card.
    """
    await _validate_csrf(request)

    from backend.shared.firestore import get_customer

    form = await request.form()
    target_tier = str(form.get("target_tier", ""))
    target_rank = TIER_ORDER.get(target_tier, -1)
    current_rank = TIER_ORDER.get(ctx.tier, 0)

    if target_rank <= current_rank:
        return _error_card("Cannot downgrade via this page.")

    target_price_id = TIER_PRICE_IDS.get(target_tier, "")
    if not target_price_id:
        return _error_card("Price configuration missing. Contact support.")

    customer = await get_customer(ctx.customer_id)
    sub_id = (customer or {}).get("paddle_subscription_id", "")
    if not sub_id:
        return _error_card("No active subscription found.")

    # Call Paddle preview API
    try:
        from backend.ops.founder_metrics import (
            _get_paddle_api_key,
        )

        paddle_key = await _get_paddle_api_key()
        url = f"https://api.paddle.com/subscriptions/{sub_id}/preview"
        async with httpx.AsyncClient(timeout=15.0) as client:
            resp = await client.patch(
                url,
                headers={
                    "Authorization": f"Bearer {paddle_key}",
                    "Content-Type": "application/json",
                },
                json={
                    "items": [
                        {
                            "price_id": target_price_id,
                            "quantity": 1,
                        }
                    ],
                    "proration_billing_mode": ("prorated_immediately"),
                },
            )
            resp.raise_for_status()
            preview = resp.json().get("data", {})
    except Exception:
        _log.exception(
            "Paddle preview failed for %s",
            ctx.customer_id,
        )
        return _error_card("Failed to load preview. Try again later.")

    # Parse preview response
    immediate = preview.get("immediate_transaction", {})
    next_txn = preview.get("next_transaction", {})

    imm_totals = immediate.get("details", {}).get("totals", {})
    imm_total = imm_totals.get("total", "0")
    nxt_totals = next_txn.get("details", {}).get("totals", {})
    next_total = nxt_totals.get("total", "0")
    next_date = next_txn.get("billing_period", {}).get("starts_at", "")

    # Format amounts (Paddle sends in minor units)
    try:
        imm_amount = int(imm_total) / 100
    except ValueError, TypeError:
        imm_amount = 0.0
    try:
        next_amount = int(next_total) / 100
    except ValueError, TypeError:
        next_amount = 0.0

    csrf_token = _base_context(ctx).get("csrf_token", "")
    date_suffix = f" on {next_date[:10]}" if next_date else ""

    html = (
        '<div class="rounded-lg p-4"'
        ' style="background: var(--bg-surface);'
        ' border: 1px solid var(--border-default);">'
        '<h3 class="text-lg font-bold mb-2"'
        ' style="color: var(--text-primary);">'
        f"Upgrade to {target_tier.title()}</h3>"
        '<p class="text-sm mb-3"'
        ' style="color: var(--text-secondary);">'
        "You&rsquo;ll be charged "
        f"<strong>${imm_amount:,.2f}</strong>"
        " now (prorated). "
        f"Next renewal: <strong>${next_amount:,.2f}"
        f"</strong>{date_suffix}.</p>"
        '<button class="py-2 px-6 rounded'
        ' text-sm font-medium text-white" '
        'style="background: var(--accent);" '
        'hx-post="/dashboard/upgrade/confirm" '
        f'hx-vals=\'{{"target_tier": "{target_tier}"}}\' '
        'hx-target="#preview-card" hx-swap="innerHTML" '
        f'hx-headers=\'{{"X-CSRF-Token": "{csrf_token}"}}\'>'
        "Confirm Upgrade</button></div>"
    )

    return HTMLResponse(html)


@router.post("/dashboard/upgrade/confirm", response_model=None)
async def upgrade_confirm(
    request: Request,
    ctx: CustomerContext = _auth_dep,
) -> HTMLResponse | RedirectResponse:
    """Execute tier upgrade via Paddle API.

    Defense-in-depth: verify target_tier > current_tier. Reject downgrades with 400.
    """
    await _validate_csrf(request)

    from fastapi import HTTPException

    from backend.shared.firestore import get_customer

    form = await request.form()
    target_tier = str(form.get("target_tier", ""))
    target_rank = TIER_ORDER.get(target_tier, -1)
    current_rank = TIER_ORDER.get(ctx.tier, 0)

    if target_rank <= current_rank:
        raise HTTPException(
            status_code=400,
            detail="Downgrades not permitted via this endpoint",
        )

    target_price_id = TIER_PRICE_IDS.get(target_tier, "")
    if not target_price_id:
        raise HTTPException(status_code=400, detail="Invalid target tier")

    customer = await get_customer(ctx.customer_id)
    sub_id = (customer or {}).get("paddle_subscription_id", "")
    if not sub_id:
        raise HTTPException(status_code=400, detail="No active subscription")

    try:
        from backend.ops.founder_metrics import (
            _get_paddle_api_key,
        )

        paddle_key = await _get_paddle_api_key()
        url = f"https://api.paddle.com/subscriptions/{sub_id}"
        async with httpx.AsyncClient(timeout=15.0) as client:
            resp = await client.patch(
                url,
                headers={
                    "Authorization": f"Bearer {paddle_key}",
                    "Content-Type": "application/json",
                },
                json={
                    "items": [
                        {
                            "price_id": target_price_id,
                            "quantity": 1,
                        }
                    ],
                    "proration_billing_mode": ("prorated_immediately"),
                    "on_payment_failure": "prevent_change",
                },
            )
            resp.raise_for_status()
    except httpx.HTTPStatusError as exc:
        _log.warning(
            "Paddle upgrade failed for %s: %s",
            ctx.customer_id,
            exc.response.text,
        )
        return _error_card(
            "Payment failed. Update your payment method"
            ' via the <a href="/dashboard/settings"'
            ' class="underline">Paddle portal</a>.'
        )
    except Exception:
        _log.exception("Paddle upgrade error for %s", ctx.customer_id)
        return _error_card("Upgrade failed. Try again later.")

    await _audit(
        request,
        ctx,
        "dashboard.tier_upgraded",
        {"from_tier": ctx.tier, "to_tier": target_tier},
    )

    return RedirectResponse(url="/dashboard/settings", status_code=303)
