"""FastAPI dependencies for device-authenticated and run-grant-authenticated endpoints."""
from __future__ import annotations

import hmac
from typing import Annotated, Optional
from uuid import UUID

import structlog
from fastapi import Depends, Header, Path
from sqlalchemy.ext.asyncio import AsyncSession

from src.config import get_settings
from src.core.database import get_db
from src.core.exceptions import ForbiddenError, NotFoundError, UnauthorizedError
from src.core.security.hashing import verify_token_hash
from src.core.security.jwt import (
    decode_codegen_action_grant_token,
    decode_run_grant_token,
)
from src.domain.devices.models import Device, DeviceStatus
from src.domain.devices.repository import DeviceRepository

log = structlog.get_logger()


async def require_active_device(
    device_id: Annotated[UUID, Path(...)],
    authorization: Annotated[Optional[str], Header()] = None,
    db: AsyncSession = Depends(get_db),
) -> Device:
    """Bearer auth via the durable ``device_secret`` — a live revocation check on every call.

    No separate expiry timer: revoking the device in Settings > Devices takes effect on the
    device's very next request (see plan: "Token/grant design").
    """
    if not authorization or not authorization.startswith("Bearer "):
        raise UnauthorizedError("Missing device bearer token")
    supplied = authorization[len("Bearer "):].strip()
    if not supplied:
        raise UnauthorizedError("Missing device bearer token")

    device = await DeviceRepository(db).get_by_id(device_id)
    if not device or not device.device_secret_hash:
        raise NotFoundError("Device not found")
    if not verify_token_hash(supplied, device.device_secret_hash):
        raise UnauthorizedError("Invalid device credentials")
    if device.status == DeviceStatus.revoked.value:
        raise ForbiddenError("Device has been revoked")
    return device


def _normalize_actions_callback_token(value: Optional[str]) -> str:
    """Return the raw callback token from either a header or configured value.

    Deployments have historically supplied the setting both as a raw secret and,
    occasionally, as ``Bearer <secret>``.  GitHub sends the value from the Actions
    secret verbatim, while the API receives it in an Authorization header.  Keep
    both sides canonical so a harmless prefix/whitespace difference cannot turn
    every Actions completion into a 403.
    """
    token = (value or "").strip()
    if token[:7].lower() == "bearer ":
        token = token[7:].strip()
    return token


def _validate_actions_callback_token(authorization: Optional[str]) -> bool:
    """Legacy static-token check (GitHub Actions). Returns True/False instead of raising so the
    caller can fall through to the run-grant JWT check."""
    expected = _normalize_actions_callback_token(
        get_settings().codegen_actions_callback_token
    )
    if not expected:
        return False
    supplied = _normalize_actions_callback_token(authorization)
    return bool(supplied) and hmac.compare_digest(supplied, expected)


async def require_run_grant_or_actions_token(
    run: Annotated[UUID, Path(...)],
    authorization: Annotated[Optional[str], Header()] = None,
    db: AsyncSession = Depends(get_db),
) -> None:
    """Authorize the generalized internal codegen callback (progress/complete).

    Accepts EITHER:
      - the legacy static ``codegen_actions_callback_token`` bearer token (GitHub Actions,
        unchanged), OR
      - a valid run-grant JWT (``type: "run_grant"``) whose ``run_id`` matches the path's
        ``run`` param and whose ``device_id`` (``sub``) refers to a still-active device — a
        revoked device's outstanding grants stop working immediately, matching the durable
        device_secret's "revoke -> next call fails closed" model.
    """
    if _validate_actions_callback_token(authorization):
        return

    supplied = _normalize_actions_callback_token(authorization)
    if not supplied:
        log.info(
            "codegen_callback_denied",
            run_id=str(run),
            reason="no_bearer_token",
            static_token_configured=bool(get_settings().codegen_actions_callback_token),
        )
        raise ForbiddenError("Invalid codegen callback token")

    # P2HS-managed GitHub Actions runs carry a Core-signed, run-scoped grant in
    # the signed payload. This avoids depending on the mutable shared repository
    # secret for callbacks while keeping the legacy static token supported.
    action_grant = decode_codegen_action_grant_token(supplied)
    if action_grant and action_grant.run_id == str(run):
        return
    if action_grant and action_grant.run_id != str(run):
        # Never seen in practice for a correctly-dispatched run (each run gets its
        # own grant); logged in case this is actually the failure mode -- distinct
        # from "grant didn't decode at all" below, which usually means TTL/secret.
        log.info(
            "codegen_callback_denied",
            run_id=str(run),
            reason="action_grant_run_id_mismatch",
            grant_run_id=action_grant.run_id,
        )

    grant = decode_run_grant_token(supplied)
    if not grant or grant.run_id != str(run):
        log.info(
            "codegen_callback_denied",
            run_id=str(run),
            reason=(
                "static_token_mismatch_and_no_decodable_grant"
                if not action_grant and not grant
                else "run_grant_run_id_mismatch"
            ),
            static_token_configured=bool(get_settings().codegen_actions_callback_token),
            supplied_looks_like_jwt=supplied.count(".") == 2,
            run_grant_run_id=grant.run_id if grant else None,
        )
        raise ForbiddenError("Invalid codegen callback token")

    try:
        device_id = UUID(grant.sub)
    except ValueError:
        log.info("codegen_callback_denied", run_id=str(run), reason="run_grant_sub_not_a_uuid")
        raise ForbiddenError("Invalid codegen callback token")

    device = await DeviceRepository(db).get_by_id(device_id)
    if not device or device.status == DeviceStatus.revoked.value:
        log.info(
            "codegen_callback_denied",
            run_id=str(run),
            reason="device_missing_or_revoked",
            device_id=str(device_id),
        )
        raise ForbiddenError("Device is not authorized for this run")
