"""Signed, short-lived single-operator sessions for the dashboard boundary."""

import asyncio
import base64
import hashlib
import hmac
import ipaddress
import json
import os
import secrets
import time
from dataclasses import dataclass
from urllib.parse import urlsplit

from fastapi import Request

from src.session_store import SessionStore, SessionStoreError

SESSION_COOKIE = "stackchain_session"
CSRF_COOKIE = "stackchain_csrf"
DEFAULT_TTL_SECONDS = 8 * 60 * 60
DEFAULT_IDLE_TIMEOUT_SECONDS = 15 * 60
STEP_UP_TTL_SECONDS = 90
MIN_SECRET_LENGTH = 24
OPERATOR_MODE = "operator"
INSECURE_LOCAL_MODE = "insecure-local"


@dataclass(frozen=True)
class Session:
    session_id: str
    csrf: str
    expires_at: int
    idle_expires_at: int | None = None


@dataclass(frozen=True)
class SessionVerification:
    session: Session | None
    reason: str | None = None


def access_token() -> str:
    return os.getenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "")


def mode() -> str:
    return os.getenv("STACKCHAIN_DASHBOARD_AUTH_MODE", OPERATOR_MODE).strip().lower()


def public_origin() -> str | None:
    """Return the canonical HTTPS browser origin, or None when it is invalid."""
    configured = os.getenv("STACKCHAIN_DASHBOARD_PUBLIC_ORIGIN", "").strip()
    if not configured:
        return None
    try:
        parsed = urlsplit(configured)
        port = parsed.port
    except ValueError:
        return None
    if (
        parsed.scheme != "https"
        or not parsed.hostname
        or parsed.username is not None
        or parsed.password is not None
        or parsed.path not in {"", "/"}
        or parsed.query
        or parsed.fragment
    ):
        return None
    hostname = parsed.hostname.lower()
    authority = f"[{hostname}]" if ":" in hostname else hostname
    if port is not None and port != 443:
        authority += f":{port}"
    return f"https://{authority}"


def configuration_error() -> str | None:
    configured_mode = mode()
    if configured_mode == INSECURE_LOCAL_MODE:
        return None
    if configured_mode != OPERATOR_MODE:
        return "unsupported authentication mode"
    operator_secret = access_token()
    signing_secret = os.getenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "")
    if len(operator_secret) < MIN_SECRET_LENGTH or len(signing_secret) < MIN_SECRET_LENGTH:
        return "operator mode requires two sufficiently long secrets"
    if hmac.compare_digest(operator_secret, signing_secret):
        return "operator and session secrets must be independent"
    if public_origin() is None:
        return "operator mode requires a canonical HTTPS public origin"
    return None


def enabled() -> bool:
    return mode() == OPERATOR_MODE and configuration_error() is None


def is_loopback_request(request: Request) -> bool:
    host = request.client.host if request.client else ""
    try:
        return ipaddress.ip_address(host).is_loopback
    except ValueError:
        return host == "localhost"


def _secret() -> bytes:
    configured = os.getenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "")
    if configuration_error() is not None or mode() != OPERATOR_MODE:
        raise RuntimeError("Dashboard authentication is not configured")
    return configured.encode()


def _encode(raw: bytes) -> str:
    return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()


def _decode(value: str) -> bytes:
    return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))


def _session_store(now: int | None = None) -> SessionStore:
    database = os.getenv(
        "STACKCHAIN_SESSION_DB",
        os.path.join(os.getenv("STACKCHAIN_STATE_DIR", ".stackchain-state"), "sessions.sqlite3"),
    )
    current = time.time if now is None else lambda: now
    return SessionStore(database, clock=current)


def idle_timeout_seconds() -> int:
    return max(
        1,
        int(
            os.getenv(
                "STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS",
                str(DEFAULT_IDLE_TIMEOUT_SECONDS),
            )
        ),
    )


def issue_session(
    now: int | None = None,
    *,
    device_label: str = "This device",
    management_id: str | None = None,
) -> tuple[str, Session]:
    issued_at = int(time.time() if now is None else now)
    ttl = int(os.getenv("STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS", str(DEFAULT_TTL_SECONDS)))
    session = Session(
        session_id=secrets.token_urlsafe(32),
        csrf=secrets.token_urlsafe(24),
        expires_at=issued_at + max(1, ttl),
    )
    payload = json.dumps(
        {"csrf": session.csrf, "exp": session.expires_at, "sid": session.session_id},
        separators=(",", ":"),
        sort_keys=True,
    ).encode()
    encoded = _encode(payload)
    signature = _encode(hmac.new(_secret(), encoded.encode(), hashlib.sha256).digest())
    _session_store(now).activate(
        session.session_id,
        session.expires_at,
        device_label=device_label,
        management_id=management_id,
    )
    return f"{encoded}.{signature}", session


def verify_session_with_reason(
    value: str | None, now: int | None = None
) -> SessionVerification:
    if not value or "." not in value or not enabled():
        return SessionVerification(None)
    encoded, supplied_signature = value.rsplit(".", 1)
    expected = _encode(hmac.new(_secret(), encoded.encode(), hashlib.sha256).digest())
    if not hmac.compare_digest(supplied_signature, expected):
        return SessionVerification(None)
    try:
        payload = json.loads(_decode(encoded))
        session = Session(
            session_id=payload["sid"],
            csrf=payload["csrf"],
            expires_at=int(payload["exp"]),
        )
    except (ValueError, TypeError, KeyError, json.JSONDecodeError):
        return SessionVerification(None)
    current = int(time.time() if now is None else now)
    if (
        session.expires_at <= current
        or not isinstance(session.session_id, str)
        or not session.session_id
        or not isinstance(session.csrf, str)
        or not session.csrf
    ):
        return SessionVerification(None)
    status = _session_store(now).status(
        session.session_id,
        session.expires_at,
        idle_timeout_seconds=idle_timeout_seconds(),
    )
    if status == "idle":
        return SessionVerification(None, "session_idle")
    if status != "active":
        return SessionVerification(None, "session_revoked")
    return SessionVerification(
        Session(
            session_id=session.session_id,
            csrf=session.csrf,
            expires_at=session.expires_at,
            idle_expires_at=getattr(status, "idle_expires_at", None),
        )
    )


def verify_session(value: str | None, now: int | None = None) -> Session | None:
    return verify_session_with_reason(value, now).session


def revoke_session(session: Session) -> None:
    _session_store().revoke(session.session_id)


async def revoke_all_sessions() -> None:
    await asyncio.to_thread(_session_store().revoke_all)


async def active_devices(session: Session):
    return await asyncio.to_thread(_session_store().list_active, session.session_id)


async def session_management_id(session: Session) -> str:
    return await asyncio.to_thread(_session_store().management_id, session.session_id)


async def managed_session_active(management_id: str) -> bool:
    status = await asyncio.to_thread(
        _session_store().managed_status,
        management_id,
        idle_timeout_seconds=idle_timeout_seconds(),
    )
    return status == "active"


async def managed_session_statuses(management_ids) -> dict[str, str]:
    return await asyncio.to_thread(
        _session_store().managed_statuses,
        management_ids,
        idle_timeout_seconds=idle_timeout_seconds(),
    )


async def touch_session(session: Session) -> bool:
    return await asyncio.to_thread(
        _session_store().touch,
        session.session_id,
        session.expires_at,
        idle_timeout_seconds=idle_timeout_seconds(),
    )


async def revoke_managed_session(management_id: str) -> bool:
    return await asyncio.to_thread(_session_store().revoke_managed, management_id)


async def issue_step_up(session: Session, *, action: str, target: str) -> str:
    return await asyncio.to_thread(
        _session_store().mint_step_up,
        session.session_id,
        action=action,
        target=target,
        ttl_seconds=STEP_UP_TTL_SECONDS,
    )


async def consume_step_up(
    grant: str, session: Session, *, action: str, target: str
) -> bool:
    return await asyncio.to_thread(
        _session_store().consume_step_up,
        grant,
        session.session_id,
        action=action,
        target=target,
    )


async def request_session(request: Request) -> Session | None:
    return await asyncio.to_thread(verify_session, request.cookies.get(SESSION_COOKIE))


async def request_session_verification(request: Request) -> SessionVerification:
    return await asyncio.to_thread(
        verify_session_with_reason, request.cookies.get(SESSION_COOKIE)
    )


def cookie_path(request: Request) -> str:
    root_path = request.scope.get("root_path", "").rstrip("/")
    return root_path or "/"


def application_path(request: Request) -> str:
    path = request.url.path
    root_path = request.scope.get("root_path", "").rstrip("/")
    if root_path and (path == root_path or path.startswith(root_path + "/")):
        return path[len(root_path):] or "/"
    return path


def same_origin(request: Request) -> bool:
    origin = request.headers.get("origin", "")
    expected = public_origin()
    return bool(origin and expected) and hmac.compare_digest(origin.rstrip("/"), expected)
