"""Shared auth core for multi-entry integrations."""
from __future__ import annotations

from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Literal, Sequence
from uuid import UUID
import os

import httpx

from security.auth.api_keys import ResolvedAPIKey

EntryType = Literal["mcp", "openclaw", "sdk"]
AuthDecision = Literal["allow", "deny"]

REASON_OK = "ok"
REASON_MISSING_API_KEY = "missing_api_key"
REASON_INVALID_API_KEY = "invalid_api_key"
REASON_INACTIVE_KEY = "inactive_key"
REASON_EXPIRED_KEY = "expired_key"
REASON_MISSING_SCOPE = "missing_scope"
REASON_AGENT_MISMATCH = "agent_mismatch"

SCOPE_WILDCARD = "*"


def _normbase_base_url() -> str:
    return os.getenv("NORMBASE_BASE_URL", "http://localhost:8001").rstrip("/")


@dataclass(frozen=True)
class AuthInput:
    """Normalized authorization input across all integration entries."""

    entry_type: EntryType
    api_key: str
    action: str
    required_scopes: list[str]
    resource_agent_id: str | None = None
    request_id: str | None = None
    caller_id: str | None = None


@dataclass(frozen=True)
class AuthContext:
    """Authorization decision and resolved identity context."""

    decision: AuthDecision
    reason: str
    agent_id: str
    key_id: str
    scopes: list[str]
    request_id: str | None
    caller_id: str | None
    action: str


def _normalize_scope_list(value: Sequence[str] | None) -> list[str]:
    scopes: list[str] = []
    for raw in value or []:
        text = str(raw).strip()
        if text and text not in scopes:
            scopes.append(text)
    return scopes


def _normalize_agent_id_or_empty(raw_agent_id: str | None) -> str:
    text = (raw_agent_id or "").strip()
    if not text:
        return ""
    try:
        return str(UUID(text))
    except ValueError:
        return ""


def _utc_now() -> datetime:
    return datetime.now(timezone.utc)


def _normalize_expires_at(value: object) -> datetime | None:
    if value is None:
        return None
    if isinstance(value, datetime):
        if value.tzinfo is None:
            return value.replace(tzinfo=timezone.utc)
        return value.astimezone(timezone.utc)
    if isinstance(value, str):
        raw = value.strip()
        if not raw:
            return None
        candidate = raw.replace("Z", "+00:00")
        try:
            parsed = datetime.fromisoformat(candidate)
        except ValueError:
            return None
        if parsed.tzinfo is None:
            return parsed.replace(tzinfo=timezone.utc)
        return parsed.astimezone(timezone.utc)
    return None


def _has_scope(scopes: Sequence[str], required_scope: str) -> bool:
    normalized = _normalize_scope_list(scopes)
    if SCOPE_WILDCARD in normalized:
        return True
    return required_scope in normalized


async def resolve_agent_api_key(api_key: str) -> ResolvedAPIKey | None:
    """Resolve per-agent API key via runtime introspection endpoint."""
    key = (api_key or "").strip()
    if not key:
        return None
    headers = {"X-API-Key": key}
    try:
        async with httpx.AsyncClient(timeout=20.0) as client:
            resp = await client.get(
                f"{_normbase_base_url()}/api/v1/keys/introspect",
                headers=headers,
            )
            if resp.status_code >= 400:
                return None
            data = resp.json() or {}
            return ResolvedAPIKey(
                key_id=str(data.get("key_id", "")).strip(),
                agent_id=str(data.get("agent_id", "")).strip(),
                scopes=_normalize_scope_list(data.get("scopes", [SCOPE_WILDCARD])),
                is_active=bool(data.get("is_active", True)),
                expires_at=_normalize_expires_at(data.get("expires_at")),
            )
    except Exception:
        return None


async def authorize(
    auth_input: AuthInput,
    *,
    resolver=resolve_agent_api_key,
) -> AuthContext:
    """Authorize one integration action with consistent decision semantics."""
    api_key = (auth_input.api_key or "").strip()
    if not api_key:
        return AuthContext(
            decision="deny",
            reason=REASON_MISSING_API_KEY,
            agent_id="",
            key_id="",
            scopes=[],
            request_id=auth_input.request_id,
            caller_id=auth_input.caller_id,
            action=auth_input.action,
        )

    resolved = await resolver(api_key)
    if resolved is None or not str(resolved.agent_id or "").strip():
        return AuthContext(
            decision="deny",
            reason=REASON_INVALID_API_KEY,
            agent_id="",
            key_id="",
            scopes=[],
            request_id=auth_input.request_id,
            caller_id=auth_input.caller_id,
            action=auth_input.action,
        )
    if not resolved.is_active:
        return AuthContext(
            decision="deny",
            reason=REASON_INACTIVE_KEY,
            agent_id="",
            key_id=resolved.key_id,
            scopes=_normalize_scope_list(resolved.scopes),
            request_id=auth_input.request_id,
            caller_id=auth_input.caller_id,
            action=auth_input.action,
        )

    expires_at = _normalize_expires_at(resolved.expires_at)
    if expires_at is not None and expires_at <= _utc_now():
        return AuthContext(
            decision="deny",
            reason=REASON_EXPIRED_KEY,
            agent_id="",
            key_id=resolved.key_id,
            scopes=_normalize_scope_list(resolved.scopes),
            request_id=auth_input.request_id,
            caller_id=auth_input.caller_id,
            action=auth_input.action,
        )

    required_scopes = _normalize_scope_list(auth_input.required_scopes)
    principal_scopes = _normalize_scope_list(resolved.scopes)
    for scope in required_scopes:
        if _has_scope(principal_scopes, scope):
            continue
        return AuthContext(
            decision="deny",
            reason=f"{REASON_MISSING_SCOPE}:{scope}",
            agent_id=_normalize_agent_id_or_empty(resolved.agent_id),
            key_id=resolved.key_id,
            scopes=principal_scopes,
            request_id=auth_input.request_id,
            caller_id=auth_input.caller_id,
            action=auth_input.action,
        )

    resolved_agent_id = _normalize_agent_id_or_empty(resolved.agent_id)
    resource_agent_id = _normalize_agent_id_or_empty(auth_input.resource_agent_id)
    if resource_agent_id and resolved_agent_id != resource_agent_id:
        return AuthContext(
            decision="deny",
            reason=REASON_AGENT_MISMATCH,
            agent_id=resolved_agent_id,
            key_id=resolved.key_id,
            scopes=principal_scopes,
            request_id=auth_input.request_id,
            caller_id=auth_input.caller_id,
            action=auth_input.action,
        )

    return AuthContext(
        decision="allow",
        reason=REASON_OK,
        agent_id=resolved_agent_id,
        key_id=resolved.key_id,
        scopes=principal_scopes,
        request_id=auth_input.request_id,
        caller_id=auth_input.caller_id,
        action=auth_input.action,
    )
