"""Log sender for shipping agent logs to Nucleus."""

from __future__ import annotations

import asyncio
import calendar
import json
import re
import warnings
from collections import defaultdict
from datetime import datetime, timezone
from enum import Enum
from typing import Any, ClassVar

import httpx
from ulid import ULID

from terminaluse.lib.utils.logging import make_logger

logger = make_logger(__name__)


# =============================================================================
# Timezone Awareness Helpers
# =============================================================================


def _ensure_utc(dt: datetime) -> datetime:
    """Ensure datetime is timezone-aware and in UTC.

    Args:
        dt: A datetime object, either naive or timezone-aware.

    Returns:
        A timezone-aware datetime in UTC.

    Warnings:
        Emits UserWarning if a naive datetime is encountered.
    """
    if dt.tzinfo is None:
        # Naive datetime - assume it's already UTC but warn
        warnings.warn(
            "Naive datetime encountered, assuming UTC. " "Use timezone-aware datetimes for accuracy.",
            UserWarning,
            stacklevel=3,
        )
        return dt.replace(tzinfo=timezone.utc)
    # Convert to UTC if in different timezone
    return dt.astimezone(timezone.utc)


def _parse_iso_timestamp_to_dt(iso_str: str) -> datetime:
    """Parse ISO timestamp ensuring UTC timezone.

    Handles various ISO format variations:
    - Z suffix: "2026-01-27T09:18:49.330Z"
    - Timezone offset: "2026-01-27T09:18:49.330+00:00"
    - Naive (no timezone): "2026-01-27T09:18:49.330"

    Args:
        iso_str: ISO format timestamp string.

    Returns:
        A timezone-aware datetime in UTC.

    Raises:
        ValueError: If the string cannot be parsed as ISO format.
    """
    # Handle 'Z' suffix (UTC indicator)
    if iso_str.endswith("Z"):
        iso_str = iso_str[:-1] + "+00:00"

    dt = datetime.fromisoformat(iso_str)

    if dt.tzinfo is None:
        # No timezone specified - assume UTC (with warning)
        return _ensure_utc(dt)
    else:
        # Convert to UTC
        return dt.astimezone(timezone.utc)


# =============================================================================
# OTLP Severity Mapping
# =============================================================================

_SEVERITY_NUMBER = {
    "DEBUG": 5,
    "INFO": 9,
    "WARNING": 13,
    "ERROR": 17,
    "CRITICAL": 21,
}


# =============================================================================
# OTLP Helpers
# =============================================================================


def _attr_value_to_otlp(value: Any) -> dict[str, Any]:
    """Convert a Python attribute value to OTLP JSON format."""
    if isinstance(value, bool):
        return {"boolValue": value}
    if isinstance(value, int):
        return {"intValue": str(value)}
    if isinstance(value, float):
        return {"doubleValue": value}
    if isinstance(value, str):
        return {"stringValue": value}
    if isinstance(value, bytes):
        return {"bytesValue": value.decode("utf-8", errors="replace")}
    if isinstance(value, (list, tuple)):
        return {"arrayValue": {"values": [_attr_value_to_otlp(v) for v in value]}}
    return {"stringValue": str(value)}


def _iso_to_nano(iso_timestamp: str) -> str:
    """Convert ISO timestamp string to nanoseconds since epoch as string.

    Uses integer arithmetic to avoid floating-point precision loss.
    Ensures all timestamps are interpreted as UTC.

    Args:
        iso_timestamp: ISO format timestamp (e.g., "2026-01-27T09:18:49.330000+00:00")

    Returns:
        Nanoseconds since epoch as string (e.g., "1706454627000000000")
    """
    try:
        # Parse and ensure UTC timezone
        dt = _parse_iso_timestamp_to_dt(iso_timestamp)

        # Use pure integer arithmetic to avoid float precision issues
        # calendar.timegm uses integer arithmetic internally (no float conversion)
        seconds = calendar.timegm(dt.utctimetuple())

        # Get microseconds from the datetime (0-999999)
        microseconds = dt.microsecond

        # Convert to nanoseconds using integer math:
        # seconds * 1_000_000_000 + microseconds * 1_000
        nanos = seconds * 1_000_000_000 + microseconds * 1_000

        return str(nanos)
    except (ValueError, AttributeError, OverflowError, OSError):
        # Fallback to current time if parsing fails
        # Note: We catch multiple exception types because:
        # - ValueError: Invalid ISO format string
        # - AttributeError: None or invalid datetime object
        # - OverflowError: Extreme dates that can't be represented as timestamps
        # - OSError: Timezone conversion failures on some platforms
        now = datetime.now(timezone.utc)
        seconds = calendar.timegm(now.utctimetuple())
        microseconds = now.microsecond
        nanos = seconds * 1_000_000_000 + microseconds * 1_000
        return str(nanos)


# =============================================================================
# Log Timestamp Parsing
# =============================================================================

# ISO timestamp patterns - these use fromisoformat() for parsing
_ISO_TIMESTAMP_PATTERN = re.compile(r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?)")

# Python logging patterns - these use strptime
_PYTHON_LOG_TIMESTAMP_PATTERNS: list[tuple[re.Pattern[str], str]] = [
    # Python logging with comma milliseconds: "2026-01-27 09:06:19,123 INFO"
    (
        re.compile(r"^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}),(\d+)"),
        None,  # Special handling for comma format
    ),
    # Python logging with dot milliseconds: "2026-01-27 09:06:19.123 INFO"
    (
        re.compile(r"^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\.(\d+)"),
        None,  # Special handling for fractional seconds
    ),
    # Python logging without milliseconds: "2026-01-27 09:06:19 INFO"
    (
        re.compile(r"^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})(?:\s|$)"),
        "%Y-%m-%d %H:%M:%S",
    ),
]


def _parse_log_timestamp(line: str, default_timestamp: str) -> str:
    """Parse timestamp from a log line.

    Attempts to extract the timestamp from various common formats:
    - ISO format: "2026-01-27T09:18:49.330Z"
    - Python logging: "2026-01-27 09:06:19 INFO"
    - Python with ms: "2026-01-27 09:06:19,123 INFO"

    All timestamps are ensured to be timezone-aware and in UTC.

    Args:
        line: The log line to parse
        default_timestamp: Timestamp to return if none is detected (ISO format)

    Returns:
        Parsed timestamp as ISO format string in UTC, or default_timestamp
    """
    # Try ISO format first (most common in modern logging)
    match = _ISO_TIMESTAMP_PATTERN.search(line)
    if match:
        timestamp_str = match.group(1)
        try:
            # Use timezone-aware parsing
            dt = _parse_iso_timestamp_to_dt(timestamp_str)
            return dt.isoformat()
        except ValueError:
            pass

    # Try Python logging formats
    for pattern, fmt in _PYTHON_LOG_TIMESTAMP_PATTERNS:
        match = pattern.search(line)
        if match:
            try:
                if fmt is None:
                    # Handle formats with fractional seconds
                    base_time = match.group(1)
                    frac = match.group(2) if len(match.groups()) > 1 else "0"
                    # Pad fractional seconds to 6 digits
                    frac = frac.ljust(6, "0")[:6]
                    dt = datetime.strptime(base_time, "%Y-%m-%d %H:%M:%S")
                    dt = dt.replace(microsecond=int(frac), tzinfo=timezone.utc)
                else:
                    dt = datetime.strptime(match.group(1), fmt)
                    dt = dt.replace(tzinfo=timezone.utc)
                return dt.isoformat()
            except ValueError:
                continue

    return default_timestamp


# =============================================================================
# Log Level Parsing
# =============================================================================

# Patterns for extracting log levels from various formats
_LOG_LEVEL_PATTERNS: list[tuple[re.Pattern[str], int]] = [
    # Claude Code / Node.js format: "2026-01-27T09:18:49.330Z [DEBUG] ..."
    (re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z?\s*\[(\w+)\]"), 1),
    # Python logging format: "2026-01-27 09:06:19 INFO [module] ..."
    # Also handles: "2026-01-27 09:06:19,123 INFO ..."
    (
        re.compile(
            r"^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}[,.]?\d*\s+(DEBUG|INFO|WARNING|WARN|ERROR|CRITICAL)\b",
            re.IGNORECASE,
        ),
        1,
    ),
    # nsjail/bubblewrap format: "[I][timestamp] ..." where I=INFO, E=ERROR, W=WARNING, D=DEBUG
    (re.compile(r"^\[([IDEFW])\]\["), 1),
    # Generic bracket format at start: "[INFO] ...", "[ERROR] ..."
    (re.compile(r"^\[(DEBUG|INFO|WARNING|WARN|ERROR|CRITICAL)\]", re.IGNORECASE), 1),
    # Uvicorn/ASGI format: "INFO:     message" or "ERROR:    message"
    (re.compile(r"^(DEBUG|INFO|WARNING|WARN|ERROR|CRITICAL):\s+", re.IGNORECASE), 1),
]

# Map single-letter codes (nsjail/bubblewrap) to standard levels
_SINGLE_LETTER_LEVEL_MAP = {
    "I": "INFO",
    "D": "DEBUG",
    "E": "ERROR",
    "W": "WARNING",
    "F": "CRITICAL",  # Fatal
}


def _parse_log_level(line: str, default_level: str) -> str:
    """Parse log level from a log line.

    Attempts to extract the log level from various common formats:
    - Claude Code: "2026-01-27T09:18:49.330Z [DEBUG] message"
    - Python logging: "2026-01-27 09:06:19 INFO [module] message"
    - nsjail/bwrap: "[I][timestamp] message"
    - Generic: "[INFO] message"
    - Uvicorn: "INFO:     message"

    Args:
        line: The log line to parse
        default_level: Level to return if no level is detected

    Returns:
        Detected log level as uppercase string, or default_level
    """
    for pattern, group_idx in _LOG_LEVEL_PATTERNS:
        match = pattern.search(line)
        if match:
            level_str = match.group(group_idx).upper()

            # Handle single-letter codes (nsjail/bubblewrap)
            if level_str in _SINGLE_LETTER_LEVEL_MAP:
                level_str = _SINGLE_LETTER_LEVEL_MAP[level_str]

            # Normalize WARN to WARNING
            if level_str == "WARN":
                level_str = "WARNING"

            # Validate it's a known level
            if level_str in ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"):
                return level_str

    return default_level


# =============================================================================
# JSON Log Parsing
# =============================================================================


def _try_parse_json_log(line: str) -> dict | None:
    """Try to parse a line as a structured JSON log.

    JSON logs from the sandbox have the format:
    {"ts":"2026-01-28T15:37:07Z","level":"INFO","logger":"httpx","msg":"HTTP Request..."}

    Returns:
        Parsed dict with keys: ts, level, logger, msg
        None if not a valid JSON log
    """
    if not line.startswith("{"):
        return None
    try:
        data = json.loads(line)
        # Validate it has our expected structure (at minimum msg and level)
        if "msg" in data and "level" in data:
            return data
        # Has JSON structure but not our log format - treat as plain text
        return None
    except json.JSONDecodeError as e:
        # Log parse failures for lines that look like JSON (starts with '{')
        # This helps debug malformed JSON from the formatter
        logger.debug(
            f"Failed to parse JSON log line: {e}",
            extra={"line_preview": line[:100] if len(line) > 100 else line},
        )
        return None


# =============================================================================
# Log Entry Building
# =============================================================================


def _build_single_log_entry(
    line: str,
    source: str,
    default_level: str,
    batch_timestamp: str,
    task_id: str | None,
    method: str,
    trace_id: str | None,
    span_id: str | None,
) -> dict:
    """Build a single log entry from a line, trying JSON parsing first.

    Args:
        line: The raw log line to parse
        source: Log source ('stdout', 'stderr', 'server')
        default_level: Default level if not detected ('INFO', 'WARNING', etc.)
        batch_timestamp: Fallback timestamp if not found in line
        task_id: Task ID for correlation
        method: RPC method that was called
        trace_id: OTEL trace ID for distributed tracing
        span_id: OTEL span ID for distributed tracing

    Returns:
        Dict with log entry fields ready for API submission
    """
    parsed = _try_parse_json_log(line)

    if parsed:
        # JSON log - extract structured fields directly
        return {
            "log_id": str(ULID()),
            "timestamp": parsed.get("ts", batch_timestamp),
            "task_id": task_id,
            "method": method,
            "trace_id": trace_id,
            "span_id": span_id,
            "source": source,
            "level": parsed.get("level", default_level),
            "logger": parsed.get("logger"),
            "message": parsed.get("msg", ""),
        }

    # Plain text log - parse with regex, keep original message
    return {
        "log_id": str(ULID()),
        "timestamp": _parse_log_timestamp(line, batch_timestamp),
        "task_id": task_id,
        "method": method,
        "trace_id": trace_id,
        "span_id": span_id,
        "source": source,
        "level": _parse_log_level(line, default_level),
        "logger": None,
        "message": line,
    }


# =============================================================================
# OTLP Conversion
# =============================================================================


def _logs_to_otlp(logs: list[dict], task_id: str | None, method: str) -> dict[str, Any]:
    """Convert internal log entries to OTLP resourceLogs format.

    Args:
        logs: List of log dicts from _build_single_log_entry
        task_id: Task ID for resource attributes
        method: RPC method for resource attributes

    Returns:
        OTLP-formatted dict with resourceLogs structure
    """
    if not logs:
        return {"resourceLogs": []}

    # Build resource attributes
    resource_attrs = [
        {"key": "service.name", "value": {"stringValue": "agent-sdk"}},
    ]
    if task_id:
        resource_attrs.append({"key": "agent.task_id", "value": {"stringValue": task_id}})
    if method:
        resource_attrs.append({"key": "agent.method", "value": {"stringValue": method}})

    # Group logs by logger name for scopeLogs
    logs_by_logger: dict[str, list[dict]] = defaultdict(list)
    for log in logs:
        logger_name = log.get("logger") or "unknown"
        logs_by_logger[logger_name].append(log)

    # Build scopeLogs
    scope_logs = []
    for logger_name, logger_logs in logs_by_logger.items():
        log_records = []
        for log in logger_logs:
            level = log.get("level", "INFO")
            record: dict[str, Any] = {
                "timeUnixNano": _iso_to_nano(log.get("timestamp", "")),
                "severityNumber": _SEVERITY_NUMBER.get(level, 9),
                "severityText": level,
                "body": {"stringValue": log.get("message", "")},
                "attributes": [
                    {"key": "log.source", "value": {"stringValue": log.get("source", "unknown")}},
                    {"key": "log.id", "value": {"stringValue": log.get("log_id", "")}},
                ],
            }

            # Add trace context if available
            trace_id = log.get("trace_id")
            span_id = log.get("span_id")
            if trace_id:
                # Ensure trace_id is 32 hex chars (pad with zeros if needed)
                record["traceId"] = trace_id.zfill(32)[:32]
            if span_id:
                # Ensure span_id is 16 hex chars (pad with zeros if needed)
                record["spanId"] = span_id.zfill(16)[:16]

            log_records.append(record)

        scope_logs.append(
            {
                "scope": {
                    "name": logger_name,
                    "version": "",
                },
                "logRecords": log_records,
            }
        )

    return {
        "resourceLogs": [
            {
                "resource": {
                    "attributes": resource_attrs,
                },
                "scopeLogs": scope_logs,
            }
        ]
    }


from opentelemetry import trace as otel_trace


class LogSource(str, Enum):
    """Source of the log entry."""

    STDOUT = "stdout"
    STDERR = "stderr"
    SERVER = "server"


class LogLevel(str, Enum):
    """Log level for entries."""

    DEBUG = "DEBUG"
    INFO = "INFO"
    WARNING = "WARNING"
    ERROR = "ERROR"
    CRITICAL = "CRITICAL"


class LogSender:
    """
    Sends agent logs to Nucleus for ingestion into ClickHouse.

    This class is responsible for capturing stdout/stderr from sandbox
    execution and shipping them to the Nucleus /logs/otlp endpoint in OTLP format.
    """

    # Class-level cached client with lock for thread safety
    _client: ClassVar[httpx.AsyncClient | None] = None
    _client_lock: ClassVar[asyncio.Lock | None] = None

    def __init__(
        self,
        nucleus_url: str,
        agent_api_key: str | None,
    ):
        """
        Initialize the log sender.

        Args:
            nucleus_url: Base URL for Nucleus API (e.g., "https://api.terminaluse.com")
            agent_api_key: API key for agent authentication

        Raises:
            ValueError: If nucleus_url doesn't use HTTPS (except localhost for development)
        """
        # Enforce HTTPS to protect API keys in transit
        # Allow exceptions for:
        # - localhost/127.0.0.1 for local development
        # - .svc.cluster.local for internal Kubernetes cluster communication
        is_localhost = "://localhost" in nucleus_url or "://127.0.0.1" in nucleus_url
        is_internal_k8s = ".svc.cluster.local" in nucleus_url
        if not nucleus_url.startswith("https://") and not is_localhost and not is_internal_k8s:
            raise ValueError(
                "nucleus_url must use HTTPS to protect API keys. " "Use https:// or localhost for development."
            )
        self.nucleus_url = nucleus_url.rstrip("/")
        self.agent_api_key = agent_api_key

    @classmethod
    async def _get_client(cls) -> httpx.AsyncClient:
        """Get or create the shared HTTP client.

        Uses a lock to prevent race conditions during client initialization.
        """
        if cls._client is not None:
            return cls._client

        # Initialize lock if needed (safe because GIL protects this)
        if cls._client_lock is None:
            cls._client_lock = asyncio.Lock()

        async with cls._client_lock:
            # Double-check after acquiring lock
            if cls._client is None:
                cls._client = httpx.AsyncClient(
                    timeout=httpx.Timeout(
                        connect=10.0,
                        read=30.0,
                        write=30.0,
                        pool=10.0,
                    ),
                )
        return cls._client

    @classmethod
    async def close_client(cls) -> None:
        """Close the shared HTTP client."""
        if cls._client:
            await cls._client.aclose()
            cls._client = None

    def is_configured(self) -> bool:
        """Check if the log sender is properly configured."""
        return bool(self.agent_api_key)

    def _get_otel_context(self) -> tuple[str | None, str | None]:
        """Extract trace_id and span_id from current OTEL context.

        Returns:
            Tuple of (trace_id, span_id) as hex strings, or (None, None)
            if no active span exists.
        """
        try:
            span = otel_trace.get_current_span()
            if span is not None and span.is_recording():
                ctx = span.get_span_context()
                if ctx is not None and ctx.is_valid:
                    # Format as lowercase hex strings
                    # trace_id is 128-bit (32 hex chars), span_id is 64-bit (16 hex chars)
                    trace_id = format(ctx.trace_id, "032x")
                    span_id = format(ctx.span_id, "016x")
                    return trace_id, span_id
        except Exception as e:
            # Don't let OTEL errors break logging
            logger.debug(f"Failed to get OTEL context: {e}")

        return None, None

    async def send_logs(
        self,
        method: str,
        stdout: str,
        stderr: str,
        task_id: str | None = None,
    ) -> None:
        """
        Send captured stdout/stderr to Nucleus.

        Args:
            method: The RPC method that was called
            stdout: Captured stdout from sandbox execution
            stderr: Captured stderr from sandbox execution
            task_id: Optional task ID for correlation
        """
        if not self.is_configured():
            logger.debug("Log sender not configured, skipping log ingestion")
            return

        logs = self._build_log_entries(method, stdout, stderr, task_id)
        if not logs:
            return

        await self._send_to_nucleus(logs, task_id=task_id, method=method)

    def _build_log_entries(
        self,
        method: str,
        stdout: str,
        stderr: str,
        task_id: str | None,
    ) -> list[dict]:
        """Build log entries from stdout/stderr.

        Each line gets:
        - Its own ULID for unique identification
        - Timestamp parsed from JSON or log message (falls back to batch ingestion time)
        - OTEL trace context if available (for distributed tracing correlation)
        - Log level parsed from JSON or message content, with sensible defaults
        - Logger name extracted from JSON logs (None for plain text logs)
        - Clean message without redundant timestamp/level prefix
        """
        logs: list[dict] = []
        batch_timestamp = datetime.now(timezone.utc).isoformat()
        trace_id, span_id = self._get_otel_context()

        # Process stdout - default to INFO
        for line in stdout.splitlines():
            if line.strip():
                logs.append(
                    _build_single_log_entry(
                        line=line,
                        source=LogSource.STDOUT.value,
                        default_level=LogLevel.INFO.value,
                        batch_timestamp=batch_timestamp,
                        task_id=task_id,
                        method=method,
                        trace_id=trace_id,
                        span_id=span_id,
                    )
                )

        # Process stderr - default to WARNING (not ERROR)
        # Most stderr output is debug/info logs that happen to go to stderr (e.g., Claude Code)
        for line in stderr.splitlines():
            if line.strip():
                logs.append(
                    _build_single_log_entry(
                        line=line,
                        source=LogSource.STDERR.value,
                        default_level=LogLevel.WARNING.value,
                        batch_timestamp=batch_timestamp,
                        task_id=task_id,
                        method=method,
                        trace_id=trace_id,
                        span_id=span_id,
                    )
                )

        return logs

    async def _send_to_nucleus(
        self,
        logs: list[dict],
        task_id: str | None,
        method: str,
    ) -> None:
        """Send logs to Nucleus API in OTLP resourceLogs format."""
        if not self.agent_api_key:
            logger.debug("Cannot send logs: agent_api_key is not set")
            return

        client = await self._get_client()
        url = f"{self.nucleus_url}/logs/otlp"

        # Convert to OTLP format
        otlp_payload = _logs_to_otlp(logs, task_id=task_id, method=method)

        try:
            response = await client.post(
                url,
                json=otlp_payload,
                headers={
                    "x-agent-api-key": self.agent_api_key,
                    "Content-Type": "application/json",
                },
            )
            response.raise_for_status()
            logger.debug(f"Sent {len(logs)} log entries to Nucleus (OTLP format)")
        except httpx.HTTPStatusError as e:
            logger.warning(f"Failed to send logs to Nucleus: {e.response.status_code}")
        except Exception as e:
            logger.warning(f"Error sending logs to Nucleus: {e}")


# Global log sender instance
_log_sender: LogSender | None = None


def get_log_sender() -> LogSender | None:
    """
    Get the global log sender instance.

    Returns None if logging is not configured.
    """
    global _log_sender
    if _log_sender is None:
        from terminaluse.lib.environment_variables import EnvironmentVariables

        env = EnvironmentVariables.refresh()
        if env.TERMINALUSE_BASE_URL and env.TERMINALUSE_AGENT_API_KEY:
            _log_sender = LogSender(
                nucleus_url=env.TERMINALUSE_BASE_URL,
                agent_api_key=env.TERMINALUSE_AGENT_API_KEY,
            )
        else:
            logger.debug("Log sender not configured: missing TERMINALUSE_BASE_URL or TERMINALUSE_AGENT_API_KEY")
    return _log_sender


def reset_log_sender() -> None:
    """Reset the global log sender (primarily for testing)."""
    global _log_sender
    _log_sender = None
