"""Service Scheduling Phase 2 (INDL-34) — /scheduling router (10 endpoints).

Additive: the legacy /services router (INDL-10) is untouched and still mounted.

Per-route role guards (declared on EACH route — not router-level, API5/S-08):
  GET list/detail/activity  → view_only
  POST/PUT/PATCH/upload/del-doc → staff
  DELETE service            → manager

Rate limits (SAC-09 / API6), Redis fixed-window, fail-open on Redis outage:
  upload → 20 / service / hour / user
  status PATCH → 5 / min / user
  GET list/week → 60 / min per (tenant, user)
  week-pdf → 5 / min per tenant (tenant-wide by design — the PRD requires
             "max 5 PDF generation requests/minute per tenant", so this key
             intentionally omits a user component)
  service pdf (INDL-37) → 10 / min / user
"""
import asyncio
import logging
import re
from datetime import date, timedelta
from typing import Literal, Optional, get_args
from uuid import UUID

from fastapi import APIRouter, Depends, File, Query, Request, Response, UploadFile
from sqlalchemy.ext.asyncio import AsyncSession

from src.apps.auth.models.user import User
from src.apps.scheduling.schemas.scheduling import (
    ActivityLogEntrySchema,
    DocumentSchema,
    SchedulingView,
    ServiceCalendarItem,
    ServiceCreate,
    ServiceDetail,
    ServiceListItem,
    ServiceUpdate,
    StatusUpdateRequest,
)
from src.apps.scheduling.services.scheduling_pdf import build_week_runsheet_pdf_bytes
from src.apps.scheduling.services.scheduling_service import SchedulingService
from src.core.constants import ServiceType, UserRole
from src.core.dependencies import require_min_role, require_tenant
from src.core.exceptions import RateLimitError, ValidationError
from src.core.schemas.response import paginated, success
from src.database.session import get_db, get_redis

router = APIRouter(prefix="/scheduling", tags=["Scheduling"])
logger = logging.getLogger(__name__)

_UPLOAD_RATE_LIMIT = 20
_UPLOAD_RATE_WINDOW = 3600  # 1 hour
_STATUS_RATE_LIMIT = 5
_STATUS_RATE_WINDOW = 60  # 1 minute
_LIST_RATE_LIMIT = 60
_LIST_RATE_WINDOW = 60  # 1 minute
_WEEKPDF_RATE_LIMIT = 5
_WEEKPDF_RATE_WINDOW = 60  # 1 minute
_SERVICEPDF_RATE_LIMIT = 10
_SERVICEPDF_RATE_WINDOW = 60  # 1 minute

# Reject calendar/PDF requests wildly outside the operational window (INDL-36).
_MAX_WEEK_OFFSET_DAYS = 730  # ~2 years past/future

# Filename sanitiser for the service-detail PDF (INDL-37): keep only
# alphanumerics, dot, underscore, hyphen — strips anything else.
_UNSAFE_FILENAME_CHARS_RE = re.compile(r"[^A-Za-z0-9._-]")


def _validate_week_start(week_start: date) -> None:
    """422 if ``week_start`` is more than ~2 years in the past or future."""
    today = date.today()
    if abs((week_start - today).days) > _MAX_WEEK_OFFSET_DAYS:
        raise ValidationError(
            message="week_start must be within ~2 years of today.",
            errors=[{"field": "week_start", "reason": "out_of_range"}],
        )


def _safe_pdf_filename(date_str: Optional[str], type_str: Optional[str]) -> str:
    """Build ``service-{date}-{type}.pdf``, stripping anything but [A-Za-z0-9._-]."""
    safe_date = _UNSAFE_FILENAME_CHARS_RE.sub("", str(date_str or "unknown-date"))
    safe_type = _UNSAFE_FILENAME_CHARS_RE.sub("", str(type_str or "service"))
    return f"service-{safe_date}-{safe_type}.pdf"


# The event-type filter is "all" plus every canonical ServiceType value. The
# Literal is spelled out so FastAPI performs its static 422-on-invalid validation
# of the query param; the assertion below fails fast at import time if the enum
# and this Literal ever drift apart, so ServiceType stays the single source of truth.
EventTypeFilter = Literal[
    "all", "interment", "memorial_service", "foundation_pour", "closure"
]

_EXPECTED_EVENT_TYPES = {"all", *(e.value for e in ServiceType)}
assert set(get_args(EventTypeFilter)) == _EXPECTED_EVENT_TYPES, (
    "EventTypeFilter has drifted from core.constants.ServiceType — keep them in sync."
)


async def _get_arq_pool_or_none():
    """Best-effort ARQ pool for enqueueing family-notification jobs (INDL-38).

    Fails open (returns None) on any Redis/connection error — a notification
    enqueue failure must never block the create/update request itself.
    """
    try:
        from arq import create_pool
        from arq.connections import RedisSettings
        from src.core.config import settings as app_settings

        return await create_pool(RedisSettings.from_dsn(app_settings.REDIS_URL))
    except Exception:
        logger.warning("scheduling: ARQ/Redis unavailable — notification jobs will not be queued")
        return None


async def _enforce_rate_limit(key: str, limit: int, window: int) -> None:
    """Redis fixed-window counter. Fails open if Redis is unreachable.

    C3: INCR and EXPIRE are issued atomically in a pipeline, and EXPIRE is set
    on every call, so a crash between the two can never leave the key without a
    TTL (which would permanently block the user).
    """
    try:
        redis = await get_redis()
        if redis is None:
            return
        pipe = redis.pipeline()
        pipe.incr(key)
        pipe.expire(key, window)
        results = await pipe.execute()
        count = results[0]
        if count > limit:
            raise RateLimitError("Too many requests — please slow down.", retry_after=window)
    except RateLimitError:
        raise
    except Exception:  # pragma: no cover - redis outage must not block requests
        return


# ── GET list / kanban ───────────────────────────────────────────────────────────
@router.get("", response_model=dict)
async def list_services(
    status: Optional[str] = Query(None, max_length=30),
    service_type: Optional[str] = Query(None, max_length=100),
    date_from: Optional[date] = Query(None),
    date_to: Optional[date] = Query(None),
    view: SchedulingView = Query(SchedulingView.LIST),
    week_start: Optional[date] = Query(None),
    event_type: EventTypeFilter = Query("all"),
    page: int = Query(1, ge=1),
    page_size: int = Query(20, ge=1, le=100),
    current_user: User = Depends(require_min_role(UserRole.VIEW_ONLY)),
    tenant_id: str = Depends(require_tenant),
    db: AsyncSession = Depends(get_db),
):
    await _enforce_rate_limit(
        f"scheduling:list:rate:{tenant_id}:{current_user.id}",
        _LIST_RATE_LIMIT,
        _LIST_RATE_WINDOW,
    )
    service = SchedulingService(db)
    if view == SchedulingView.WEEK:
        if week_start is None:
            raise ValidationError(
                message="week_start is required for the week view.",
                errors=[{"field": "week_start", "reason": "required"}],
            )
        _validate_week_start(week_start)
        items = await service.list_week(tenant_id, week_start, event_type)
        shaped = [ServiceCalendarItem(**row).model_dump() for row in items]
        return success(
            data={
                "week_start": week_start,
                "week_end": week_start + timedelta(days=6),
                "items": shaped,
            }
        )

    if view == SchedulingView.KANBAN:
        buckets = await service.list(
            tenant_id,
            service_type=service_type,
            date_from=date_from,
            date_to=date_to,
            kanban=True,
        )
        # Wire through ServiceListItem so the response shape is schema-guaranteed.
        shaped = {
            key: [ServiceListItem(**row).model_dump() for row in rows]
            for key, rows in buckets.items()
        }
        return success(data=shaped)

    items, total = await service.list(
        tenant_id,
        status=status,
        service_type=service_type,
        date_from=date_from,
        date_to=date_to,
        page=page,
        page_size=page_size,
    )
    shaped = [ServiceListItem(**row).model_dump() for row in items]
    return paginated(items=shaped, total=total, page=page, page_size=page_size)


# ── GET week run-sheet PDF (INDL-36) ──────────────────────────────────────────────
# Declared BEFORE GET /{service_id} so the static path is not captured as a UUID.
@router.get("/week-pdf")
async def export_week_pdf(
    week_start: date = Query(...),
    event_type: EventTypeFilter = Query("all"),
    current_user: User = Depends(require_min_role(UserRole.STAFF)),
    tenant_id: str = Depends(require_tenant),
    db: AsyncSession = Depends(get_db),
):
    _validate_week_start(week_start)
    await _enforce_rate_limit(
        f"scheduling:weekpdf:rate:{tenant_id}",
        _WEEKPDF_RATE_LIMIT,
        _WEEKPDF_RATE_WINDOW,
    )
    service = SchedulingService(db)
    services = await service.fetch_week_for_pdf(tenant_id, week_start, event_type)
    pdf_bytes = await asyncio.to_thread(
        build_week_runsheet_pdf_bytes, services, week_start
    )
    service.log_week_pdf_export(
        tenant_id, current_user.id, week_start, len(services)
    )
    return Response(
        content=pdf_bytes,
        media_type="application/pdf",
        headers={
            "Content-Type": "application/pdf",
            "Content-Disposition": f'attachment; filename="schedule-{week_start}.pdf"',
            "Cache-Control": "no-store",
            "X-Content-Type-Options": "nosniff",
        },
    )


# ── POST create ─────────────────────────────────────────────────────────────────
@router.post("", response_model=dict, status_code=201)
async def create_service(
    body: ServiceCreate,
    current_user: User = Depends(require_min_role(UserRole.STAFF)),
    tenant_id: str = Depends(require_tenant),
    db: AsyncSession = Depends(get_db),
):
    service = SchedulingService(db)
    arq_redis = None
    try:
        if body.status == "awaiting_family_confirm" and (
            body.notify_family_confirmation or body.notify_family_reminder_24h
        ):
            arq_redis = await _get_arq_pool_or_none()
        detail = await service.create(tenant_id, body, current_user, arq_redis=arq_redis)
    finally:
        if arq_redis is not None:
            await arq_redis.aclose()
    return success(data=ServiceDetail(**detail).model_dump(), message="Service created")


# ── GET detail ──────────────────────────────────────────────────────────────────
@router.get("/{service_id}", response_model=dict)
async def get_service(
    service_id: UUID,
    current_user: User = Depends(require_min_role(UserRole.VIEW_ONLY)),
    tenant_id: str = Depends(require_tenant),
    db: AsyncSession = Depends(get_db),
):
    service = SchedulingService(db)
    detail = await service.get_by_id(tenant_id, service_id, current_user.id)
    return success(data=ServiceDetail(**detail).model_dump())


# ── GET service detail PDF (INDL-37) ─────────────────────────────────────────────
@router.get("/{service_id}/pdf")
async def export_service_pdf(
    service_id: UUID,
    current_user: User = Depends(require_min_role(UserRole.VIEW_ONLY)),
    tenant_id: str = Depends(require_tenant),
    db: AsyncSession = Depends(get_db),
):
    await _enforce_rate_limit(
        f"scheduling:pdf:rate:{tenant_id}:{current_user.id}",
        _SERVICEPDF_RATE_LIMIT,
        _SERVICEPDF_RATE_WINDOW,
    )
    service = SchedulingService(db)
    pdf_bytes, detail = await service.generate_pdf(tenant_id, service_id, current_user)
    filename = _safe_pdf_filename(
        detail.get("date").isoformat() if detail.get("date") else None,
        detail.get("service_type"),
    )
    return Response(
        content=pdf_bytes,
        media_type="application/pdf",
        headers={
            "Content-Disposition": f'attachment; filename="{filename}"',
            "Cache-Control": "no-store",
            "X-Content-Type-Options": "nosniff",
        },
    )


# ── PUT update ──────────────────────────────────────────────────────────────────
@router.put("/{service_id}", response_model=dict)
async def update_service(
    service_id: UUID,
    body: ServiceUpdate,
    current_user: User = Depends(require_min_role(UserRole.STAFF)),
    tenant_id: str = Depends(require_tenant),
    db: AsyncSession = Depends(get_db),
):
    service = SchedulingService(db)
    arq_redis = None
    try:
        arq_redis = await _get_arq_pool_or_none()
        detail = await service.update(tenant_id, service_id, body, current_user, arq_redis=arq_redis)
    finally:
        if arq_redis is not None:
            await arq_redis.aclose()
    return success(data=ServiceDetail(**detail).model_dump(), message="Service updated")


# ── PATCH status ────────────────────────────────────────────────────────────────
@router.patch("/{service_id}/status", response_model=dict)
async def update_service_status(
    service_id: UUID,
    body: StatusUpdateRequest,
    current_user: User = Depends(require_min_role(UserRole.STAFF)),
    tenant_id: str = Depends(require_tenant),
    db: AsyncSession = Depends(get_db),
):
    await _enforce_rate_limit(
        f"scheduling:status:rate:{current_user.id}",
        _STATUS_RATE_LIMIT,
        _STATUS_RATE_WINDOW,
    )
    service = SchedulingService(db)
    detail = await service.update_status(tenant_id, service_id, body.status, current_user)
    return success(data=ServiceDetail(**detail).model_dump(), message="Status updated")


# ── DELETE service ──────────────────────────────────────────────────────────────
@router.delete("/{service_id}", status_code=204)
async def delete_service(
    service_id: UUID,
    current_user: User = Depends(require_min_role(UserRole.MANAGER)),
    tenant_id: str = Depends(require_tenant),
    db: AsyncSession = Depends(get_db),
):
    service = SchedulingService(db)
    await service.soft_delete(tenant_id, service_id, current_user)


# ── POST document ───────────────────────────────────────────────────────────────
@router.post("/{service_id}/documents", response_model=dict, status_code=201)
async def upload_service_document(
    service_id: UUID,
    request: Request,
    file: UploadFile = File(...),
    current_user: User = Depends(require_min_role(UserRole.STAFF)),
    tenant_id: str = Depends(require_tenant),
    db: AsyncSession = Depends(get_db),
):
    await _enforce_rate_limit(
        f"scheduling:upload:rate:{service_id}:{current_user.id}",
        _UPLOAD_RATE_LIMIT,
        _UPLOAD_RATE_WINDOW,
    )
    content = await file.read()
    service = SchedulingService(db)
    doc = await service.upload_document(
        tenant_id,
        service_id,
        file_name=file.filename or "",
        declared_content_type=file.content_type,
        content=content,
        current_user=current_user,
    )
    return success(data=DocumentSchema(**doc).model_dump(), message="Document uploaded")


# ── DELETE document ─────────────────────────────────────────────────────────────
@router.delete("/{service_id}/documents/{doc_id}", status_code=204)
async def delete_service_document(
    service_id: UUID,
    doc_id: UUID,
    current_user: User = Depends(require_min_role(UserRole.STAFF)),
    tenant_id: str = Depends(require_tenant),
    db: AsyncSession = Depends(get_db),
):
    service = SchedulingService(db)
    await service.delete_document(tenant_id, service_id, doc_id, current_user)


# ── GET activity ────────────────────────────────────────────────────────────────
@router.get("/{service_id}/activity", response_model=dict)
async def get_service_activity(
    service_id: UUID,
    current_user: User = Depends(require_min_role(UserRole.VIEW_ONLY)),
    tenant_id: str = Depends(require_tenant),
    db: AsyncSession = Depends(get_db),
):
    service = SchedulingService(db)
    entries = await service.get_activity_log(tenant_id, service_id, current_user)
    shaped = [ActivityLogEntrySchema(**e).model_dump() for e in entries]
    return success(data=shaped)
