"""Reports & Analytics — cross-cutting security controls.

Centralises the validation, authorization, sanitisation, audit-logging, rate-limiting
and response-header helpers shared by every reports endpoint so the controls are
applied consistently (and can be unit-tested in isolation).
"""
from __future__ import annotations

import re
import time
from datetime import date
from typing import Optional
from uuid import UUID

from fastapi import Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from src.apps.sections.models.section import Section
from src.apps.site_admin.models.audit_log import AuditLog
from src.core.constants import ROLE_HIERARCHY, UserRole
from src.core.exceptions import (
    ForbiddenError,
    NotFoundError,
    RateLimitError,
    ValidationError,
)
from .constants import (
    EXPORT_RATE_LIMIT,
    EXPORT_RATE_WINDOW_SECONDS,
    MAX_DATE_RANGE_DAYS,
    REPORT_MIN_ROLES,
    VALID_REPORT_TYPES,
)

# ── Response headers for streamed export files (OWASP A05 / ASVS V8.1, V14.4) ──
# StreamingResponse bypasses the JSON envelope, so security headers are attached
# explicitly. no-store prevents proxy/CDN/browser caching of PII/financial files;
# nosniff blocks MIME-sniffing of the PDF/Excel blob.
EXPORT_SECURITY_HEADERS = {
    "Cache-Control": "no-store, no-cache, must-revalidate, private",
    "Pragma": "no-cache",
    "X-Content-Type-Options": "nosniff",
    "Content-Security-Policy": "default-src 'none'",
}

_FILENAME_SAFE = re.compile(r"[^a-z0-9_-]")


# ── Report-type & role validation ─────────────────────────────────────────────

def validate_report_type(report_type: str) -> str:
    """Reject any report_type not on the server-side allowlist (HTTP 422).

    Runs before any query, header or filename is built — the primary defence
    against ``{report_type}`` path traversal / CRLF header injection.
    """
    if report_type not in VALID_REPORT_TYPES:
        raise ValidationError(f"Unknown report type: '{report_type}'")
    return report_type


def assert_report_role(user, report_type: str) -> None:
    """Enforce the per-report-type minimum role (OWASP API5).

    ``report_type`` must already be validated. Raises ForbiddenError (403) when the
    authenticated user's role is below the level required for this specific report.
    """
    min_role = REPORT_MIN_ROLES[report_type]
    user_level = ROLE_HIERARCHY.get(UserRole(user.role), 0)
    required_level = ROLE_HIERARCHY.get(min_role, 0)
    if user_level < required_level:
        raise ForbiddenError(
            f"This report requires the '{min_role.value}' role or higher."
        )


# ── Date-range validation (OWASP API4 — unrestricted resource consumption) ────

def validate_date_range(
    from_date: Optional[date],
    to_date: Optional[date],
    *,
    require: bool = False,
) -> None:
    """Validate an optional (or, when ``require``, mandatory) report date range.

    Raises ValidationError (HTTP 422) when the range is required but absent, when
    from_date > to_date, or when the span exceeds MAX_DATE_RANGE_DAYS.
    """
    if require and (from_date is None or to_date is None):
        raise ValidationError(
            "from_date and to_date are required for this export."
        )
    if from_date is not None and to_date is not None:
        if from_date > to_date:
            raise ValidationError("from_date must be on or before to_date.")
        span_days = (to_date - from_date).days
        if span_days > MAX_DATE_RANGE_DAYS:
            raise ValidationError(
                f"Date range exceeds the maximum of {MAX_DATE_RANGE_DAYS} days."
            )


# ── Tenant-scoped section resolution (OWASP API1 — BOLA / IDOR) ───────────────

async def resolve_section(
    db: AsyncSession,
    tenant_id: str,
    section_id: Optional[UUID],
) -> Optional[Section]:
    """Verify ``section_id`` belongs to the caller's tenant before it is used.

    Returns the Section, or None when no filter was supplied. Raises NotFoundError
    (404) when the section does not exist under the current tenant — this prevents a
    cross-tenant attacker from filtering another tenant's data by guessing a UUID.
    """
    if section_id is None:
        return None
    result = await db.execute(
        select(Section).where(
            Section.id == section_id,
            Section.tenant_id == tenant_id,
        )
    )
    section = result.scalar_one_or_none()
    if section is None:
        raise NotFoundError("Section not found for this cemetery.")
    return section


# ── Filename sanitisation (OWASP A03 — Content-Disposition injection) ─────────

def safe_filename(report_type: str, ext: str, today: date) -> str:
    """Build a Content-Disposition filename stripped of any non-safe characters."""
    slug = _FILENAME_SAFE.sub("", report_type.lower())
    ext = _FILENAME_SAFE.sub("", ext.lower())
    return f"{slug}_{today.isoformat()}.{ext}"


# ── Per-user export rate limiting (OWASP API6) ────────────────────────────────
# In-process sliding window. Keyed by (tenant_id, user_id) so switching report type
# cannot reset the counter. For multi-worker deployments back this with Redis.
_EXPORT_HITS: dict[str, list[float]] = {}


def check_export_rate_limit(
    key: str,
    *,
    limit: int = EXPORT_RATE_LIMIT,
    window: int = EXPORT_RATE_WINDOW_SECONDS,
    now: Optional[float] = None,
) -> None:
    """Raise RateLimitError (429) if ``key`` exceeds ``limit`` hits within ``window``."""
    now = time.time() if now is None else now
    cutoff = now - window
    hits = [t for t in _EXPORT_HITS.get(key, []) if t > cutoff]
    if len(hits) >= limit:
        raise RateLimitError(
            "Export rate limit exceeded. Please wait before exporting again.",
            retry_after=window,
        )
    hits.append(now)
    _EXPORT_HITS[key] = hits


def reset_export_rate_limit() -> None:
    """Clear all rate-limit state (used by tests)."""
    _EXPORT_HITS.clear()


# ── Export audit logging (OWASP A09 / ASVS V4.2.1, V7.2.1) ────────────────────

async def log_export(
    db: AsyncSession,
    *,
    tenant_id: str,
    user,
    report_type: str,
    fmt: str,
    from_date: Optional[date],
    to_date: Optional[date],
    section_id: Optional[UUID],
    request: Request,
) -> None:
    """Write a structured audit event for every export (metadata only, never body).

    The session is committed by the get_db() dependency at end of request.
    """
    ip = request.client.host if request and request.client else None
    ua = request.headers.get("user-agent") if request else None
    entry = AuditLog(
        tenant_id=tenant_id,
        user_id=getattr(user, "id", None),
        entity_type="report_export",
        entity_id=None,
        action="export",
        new_value={
            "report_type": report_type,
            "format": fmt,
            "from_date": from_date.isoformat() if from_date else None,
            "to_date": to_date.isoformat() if to_date else None,
            "section_id": str(section_id) if section_id else None,
        },
        ip_address=ip,
        user_agent=ua,
    )
    db.add(entry)
    await db.flush()
