"""Shared helpers for Zuul MCP server."""

import asyncio
import concurrent.futures
import json
import logging
import re
import ssl
import time as _time
from dataclasses import dataclass, field
from typing import Any
from urllib.parse import quote, urlparse

import httpx
from mcp.server.fastmcp import Context

from .auth import kerberos_auth
from .config import Config

log = logging.getLogger("zuul-mcp")

_ANSI_RE = re.compile(r"\x1b\[[0-9;]*[a-zA-Z]")


@dataclass
class AppContext:
    """Shared application state injected via FastMCP lifespan."""

    client: httpx.AsyncClient
    log_client: httpx.AsyncClient
    config: Config
    _auth_lock: asyncio.Lock = field(default_factory=asyncio.Lock, repr=False)
    _auth_generation: int = field(default=0, repr=False)
    grep_executor: concurrent.futures.ThreadPoolExecutor | None = field(default=None, repr=False)


def app(ctx: Context) -> AppContext:
    """Extract AppContext from the MCP request context."""
    return ctx.request_context.lifespan_context


def tenant(ctx: Context, t: str) -> str:
    """Resolve tenant name, falling back to default."""
    resolved = t or app(ctx).config.default_tenant
    if not resolved:
        raise ValueError("tenant is required (no ZUUL_DEFAULT_TENANT set)")
    return resolved


def safepath(value: str) -> str:
    """Sanitize a user-supplied value for use in a URL path.

    Preserves slashes (needed for Zuul project names like org/repo)
    but rejects path traversal attempts.
    """
    if ".." in value.split("/"):
        raise ValueError(f"Invalid path segment: {value!r}")
    return quote(value, safe="/")


async def api(ctx: Context, path: str, params: dict | None = None) -> Any:
    """Make an authenticated GET request to the Zuul API.

    Retries once on 500/503 (transient server errors, LB hiccups) and
    re-authenticates via Kerberos on 401.
    """
    a = app(ctx)
    url = f"/api{path}"

    for attempt in range(2):
        resp = await a.client.get(url, params=params)

        # Re-authenticate if the session expired (Kerberos only).
        # Note: 302 is never seen here because client uses follow_redirects=True;
        # httpx follows the OIDC redirect chain and we see the final 401.
        if resp.status_code == 401 and a.config.use_kerberos:
            gen = a._auth_generation
            async with a._auth_lock:
                if a._auth_generation == gen:
                    log.info("Session expired, re-authenticating via Kerberos")
                    await kerberos_auth(a.client, a.config.base_url)
                    a._auth_generation += 1
            resp = await a.client.get(url, params=params)

        # Retry once on 500/503 (transient server errors, LB hiccups).
        if resp.status_code in (500, 503) and attempt == 0:
            log.info("API returned %d for %s, retrying in 2s", resp.status_code, path)
            await asyncio.sleep(2)
            continue

        break

    resp.raise_for_status()
    try:
        return resp.json()
    except json.JSONDecodeError as exc:
        ct = resp.headers.get("content-type", "")
        raise ValueError(f"API returned non-JSON response (content-type: {ct})") from exc


async def _api_mutate(ctx: Context, method: str, path: str, body: dict | None = None) -> Any:
    """Shared logic for POST/DELETE with Kerberos re-auth and 500/503 retry.

    All write tools declare idempotentHint=True, so transient 503 from
    load balancers can safely be retried (same pattern as api() for GET).
    """
    a = app(ctx)
    if a.config.read_only:
        raise ValueError("Write operations disabled (ZUUL_READ_ONLY=true)")
    url = f"/api{path}"

    for attempt in range(2):
        if method == "POST":
            resp = await a.client.post(url, json=body)
        else:
            resp = await a.client.delete(url)

        if resp.status_code == 401 and a.config.use_kerberos:
            gen = a._auth_generation
            async with a._auth_lock:
                if a._auth_generation == gen:
                    log.info("Session expired, re-authenticating via Kerberos")
                    await kerberos_auth(a.client, a.config.base_url)
                    a._auth_generation += 1
            if method == "POST":
                resp = await a.client.post(url, json=body)
            else:
                resp = await a.client.delete(url)

        if resp.status_code in (500, 503) and attempt == 0:
            log.info("API returned %d for %s %s, retrying in 2s", resp.status_code, method, path)
            await asyncio.sleep(2)
            continue
        break

    resp.raise_for_status()
    if not resp.text:
        return {}
    try:
        return resp.json()
    except json.JSONDecodeError as exc:
        ct = resp.headers.get("content-type", "")
        raise ValueError(f"API returned non-JSON response (content-type: {ct})") from exc


async def api_post(ctx: Context, path: str, body: dict) -> Any:
    """Make an authenticated POST request to the Zuul API."""
    return await _api_mutate(ctx, "POST", path, body)


async def api_delete(ctx: Context, path: str) -> Any:
    """Make an authenticated DELETE request to the Zuul API."""
    return await _api_mutate(ctx, "DELETE", path)


_MAX_LOG_BYTES = 10 * 1024 * 1024  # 10 MB
_MAX_FETCH_BYTES = 20 * 1024 * 1024  # 20 MB (for JSON log files)
_STREAM_DEADLINE_SECS = 300  # 5 minutes total streaming deadline


def _pick_client(a: AppContext, url: str) -> httpx.AsyncClient:
    """Pick the right HTTP client based on log host vs API host."""
    api_host = urlparse(a.config.base_url).hostname
    log_host = urlparse(url).hostname
    return a.client if log_host == api_host else a.log_client


async def _stream_response(
    a: AppContext,
    url: str,
    *,
    max_bytes: int,
    headers: dict[str, str] | None = None,
) -> tuple[httpx.Response, bool]:
    """Core streaming function with size limit and Kerberos re-auth.

    Single implementation backing both ``fetch_log_url`` (returns Response)
    and ``stream_log`` (returns bytes + truncated flag).

    Returns:
        Tuple of (response, truncated). Does not raise on 404 — callers
        should check ``response.status_code``.
    """
    scheme = urlparse(url).scheme
    if scheme not in ("http", "https"):
        raise ValueError(f"Invalid URL scheme: {scheme!r}")
    http = _pick_client(a, url)

    async def _fetch() -> tuple[httpx.Response, bool]:
        chunks: list[bytes] = []
        size = 0
        truncated = False
        deadline = _time.monotonic() + _STREAM_DEADLINE_SECS
        # Use a longer read timeout for streaming — the client-level timeout
        # (default 30s) is too short for large logs.  The deadline timer above
        # caps total transfer time independently.
        stream_timeout = httpx.Timeout(
            connect=30.0, read=_STREAM_DEADLINE_SECS, write=30.0, pool=30.0
        )
        async with http.stream(
            "GET", url, follow_redirects=True, headers=headers, timeout=stream_timeout
        ) as resp:
            status = resp.status_code
            hdrs = resp.headers
            req = resp.request
            if status != 404:
                async for chunk in resp.aiter_bytes():
                    if _time.monotonic() > deadline:
                        truncated = True
                        break
                    size += len(chunk)
                    if size > max_bytes:
                        overshoot = size - max_bytes
                        chunks.append(chunk[: len(chunk) - overshoot])
                        truncated = True
                        break
                    chunks.append(chunk)
        return httpx.Response(
            status_code=status, headers=hdrs, content=b"".join(chunks), request=req
        ), truncated

    resp, truncated = await _fetch()

    # Re-authenticate on 401 (Kerberos only, API client only)
    if resp.status_code == 401 and a.config.use_kerberos and http is a.client:
        gen = a._auth_generation
        async with a._auth_lock:
            if a._auth_generation == gen:
                log.info("Session expired, re-authenticating via Kerberos")
                await kerberos_auth(a.client, a.config.base_url)
                a._auth_generation += 1
        resp, truncated = await _fetch()

    return resp, truncated


async def fetch_log_url(a: AppContext, url: str) -> httpx.Response:
    """Fetch a log URL with streaming size limit and Kerberos re-auth.

    Downloads up to _MAX_FETCH_BYTES (20 MB) via streaming to prevent
    unbounded memory consumption from large log files.
    """
    try:
        resp, _ = await _stream_response(a, url, max_bytes=_MAX_FETCH_BYTES)
        return resp
    except httpx.DecodingError:
        # Corrupted gzip — retry without compression so the server
        # sends raw bytes instead of a broken Content-Encoding: gzip.
        log.info("DecodingError fetching %s, retrying without compression", url)
        try:
            resp, _ = await _stream_response(
                a, url, max_bytes=_MAX_FETCH_BYTES, headers={"Accept-Encoding": "identity"}
            )
            return resp
        except httpx.DecodingError:
            # Server ignored Accept-Encoding: identity and sent corrupt
            # Content-Encoding: gzip again.  Re-raise so callers can
            # fall back to text-based diagnosis.
            log.warning("DecodingError persists after identity retry for %s", url)
            raise


async def stream_log(a: AppContext, url: str) -> tuple[bytes, bool]:
    """Stream a log file with Kerberos re-auth, size-limited to 10 MB.

    On corrupted gzip (DecodingError), retries with Accept-Encoding: identity
    so the server sends raw bytes instead of broken Content-Encoding: gzip.

    Returns:
        Tuple of (content_bytes, truncated_bool).

    Raises:
        httpx.HTTPStatusError: on non-404 HTTP errors
        FileNotFoundError: when the log file returns 404
    """
    try:
        resp, truncated = await _stream_response(a, url, max_bytes=_MAX_LOG_BYTES)
    except httpx.DecodingError:
        log.info("DecodingError streaming %s, retrying without compression", url)
        try:
            resp, truncated = await _stream_response(
                a, url, max_bytes=_MAX_LOG_BYTES, headers={"Accept-Encoding": "identity"}
            )
        except httpx.DecodingError:
            log.warning("DecodingError persists after identity retry for %s", url)
            raise
    if resp.status_code == 404:
        raise FileNotFoundError(url)
    if resp.status_code >= 400:
        resp.raise_for_status()
    return resp.content, truncated


def error(msg: str) -> str:
    """Return a JSON-encoded error message."""
    return json.dumps({"error": msg})


def strip_ansi(text: str) -> str:
    """Remove ANSI escape codes from text."""
    return _ANSI_RE.sub("", text)


_ZUUL_URL_RE = re.compile(r"/t/([^/]+)/(build|buildset)/([^/?#]+)")
_ZUUL_CHANGE_URL_RE = re.compile(r"/t/([^/]+)/status/change/([^/?#]+)")
# Single-tenant URLs without /t/<tenant>/ prefix
_ZUUL_SINGLE_TENANT_RE = re.compile(r"/(build|buildset)/([^/?#]+)")


def parse_zuul_url(url: str) -> tuple[str, str, str] | None:
    """Parse a Zuul web URL into (tenant, resource_type, id).

    Supports build, buildset, and change status URLs, including
    single-tenant deployments without the ``/t/<tenant>/`` prefix
    (returns empty tenant, resolved via ZUUL_DEFAULT_TENANT).

    Examples::

        parse_zuul_url("https://zuul.example.com/t/tenant/build/abc123")
        # -> ("tenant", "build", "abc123")

        parse_zuul_url("https://zuul.example.com/zuul/t/t1/buildset/def456")
        # -> ("t1", "buildset", "def456")

        parse_zuul_url("https://zuul.example.com/t/t1/status/change/12345,abc")
        # -> ("t1", "change", "12345,abc")

        parse_zuul_url("https://zuul.example.com/build/abc123")
        # -> ("", "build", "abc123")  # tenant resolved from ZUUL_DEFAULT_TENANT
    """
    m = _ZUUL_URL_RE.search(url)
    if m:
        return m.group(1), m.group(2), m.group(3)
    m = _ZUUL_CHANGE_URL_RE.search(url)
    if m:
        return m.group(1), "change", m.group(2)
    # Single-tenant URLs (no /t/ prefix)
    m = _ZUUL_SINGLE_TENANT_RE.search(url)
    if m:
        return "", m.group(1), m.group(2)
    return None


def is_ssl_error(exc: httpx.ConnectError) -> bool:
    """Check if a ConnectError was caused by an SSL/TLS failure.

    Inspects the exception cause chain instead of string-matching the
    message, which avoids false positives (e.g. hostnames containing
    "ssl") and is stable across httpx/httpcore versions.

    The chain is: httpx.ConnectError -> httpcore.ConnectError -> ssl.SSLError.
    """
    cause = exc.__cause__
    if cause is None:
        return False
    inner = getattr(cause, "__context__", None) or getattr(cause, "__cause__", None)
    return isinstance(inner, ssl.SSLError)


def clean(d: dict) -> dict:
    """Remove None, empty string, and empty list values to save tokens."""
    return {k: v for k, v in d.items() if v is not None and v != "" and v != []}
