import logging
import re
from datetime import datetime, timezone
from enum import Enum
from typing import Any, Dict, List, Optional, Tuple


class LogLevel(str, Enum):
    """Log level enum. Inherits from str so comparisons like level == "debug" work."""
    DEBUG = "debug"
    INFO = "info"
    WARNING = "warning"
    ERROR = "error"


class Operation(str, Enum):
    """CLI operation steps for structured log entries."""
    SETUP = "setup"
    AUTHENTICATE = "authenticate"
    VALIDATE_ENV = "validate-env"
    LOAD_PROMPTS = "load-prompts"
    FETCH_AGENTS = "fetch-agents"
    SEND_PROMPT = "send-prompt"
    EVALUATE = "evaluate"
    WRITE_OUTPUT = "write-output"
    THIRD_PARTY = "third-party"


ALLOWED_LOG_LEVELS = tuple(level.value for level in LogLevel)
LOG_LEVEL_MAP = {
    LogLevel.DEBUG: logging.DEBUG,
    LogLevel.INFO: logging.INFO,
    LogLevel.WARNING: logging.WARNING,
    LogLevel.ERROR: logging.ERROR,
}

STRUCTURED_LOG_FIELDS = (
    "timestamp",
    "level",
    "operation",
    "request-id",
    "conversation-id",
    "message-id",
    "response-timestamp",
    "logger",
    "message",
    "is-redacted",
)


def normalize_log_level(value: Optional[str]) -> Optional[str]:
    if value is None:
        return None
    return value.strip().lower()


def resolve_log_level(
    log_level_values: Optional[List[str]],
) -> Tuple[Optional[str], Optional[str]]:
    values = log_level_values or []
    if not values:
        return "info", None

    # Use the last value provided (aligns with Node.js wrapper behavior).
    last = normalize_log_level(values[-1])
    if last not in ALLOWED_LOG_LEVELS:
        return (
            None,
            "Invalid value for --log-level. Supported values are: "
            "debug, info, warning, error.",
        )

    return last, None


def utc_iso_timestamp() -> str:
    return datetime.now(timezone.utc).isoformat()


def build_run_context(
    operation: str = "evaluate",
    request_id: Optional[str] = None,
    conversation_id: Optional[str] = None,
    message_id: Optional[str] = None,
    response_timestamp: Optional[str] = None,
) -> Dict[str, Optional[str]]:
    return {
        "request-id": request_id,
        "conversation-id": conversation_id,
        "message-id": message_id,
        "response-timestamp": response_timestamp,
        "operation": operation,
    }


_SECRET_PATTERNS = [
    re.compile(r"(?i)(api[_-]?key\s*[:=]\s*)([^\s,;]+)"),
    re.compile(r"(?i)(token\s*[:=]\s*)([^\s,;]+)"),
    re.compile(r"(?i)(authorization\s*[:=]\s*bearer\s+)([^\s,;]+)"),
    re.compile(r"(?i)(password\s*[:=]\s*)([^\s,;]+)"),
]

# Each entry is a fail-open promise: matching strings are assumed safe and skip
# fallback redaction. Prefer fixing noisy log call sites over adding patterns here.
_ALLOWLIST_PATTERNS = [
    # Leading [A-Za-z0-9_]* absorbs agent-ID prefixes like "T_" so the full token matches.
    re.compile(
        r"[A-Za-z0-9_]*[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}"
        r"-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"
    ),
    # Path only — query/fragment excluded so secrets in ?sig=, ?code=, ?auth= still get scrutiny.
    re.compile(r"https?://[^\s?#]+"),
]

_FALLBACK_CREDENTIAL_RE = re.compile(
    r"(?=[A-Za-z0-9_\-]*[A-Z])(?=[A-Za-z0-9_\-]*[a-z])"
    r"(?=[A-Za-z0-9_\-]*[0-9])[A-Za-z0-9_\-]{32,}"
)


def _is_match_allowlisted(message: str, match: re.Match) -> bool:
    match_start, match_end = match.start(), match.end()
    for pattern in _ALLOWLIST_PATTERNS:
        for m in pattern.finditer(message):
            if m.start() <= match_start and m.end() >= match_end:
                return True
    return False


def redact_sensitive_content(message: Optional[str]) -> Tuple[str, bool]:
    if message is None:
        return "", False

    redacted = message
    changed = False
    for pattern in _SECRET_PATTERNS:
        updated = pattern.sub(r"\1[REDACTED]", redacted)
        if updated != redacted:
            changed = True
            redacted = updated

    # Fallback: match strings 32+ chars containing mixed case and digits
    # (likely a credential/token) that weren't already caught above.
    if "[REDACTED]" not in redacted:
        def _redact_unless_allowlisted(m: re.Match) -> str:
            return m.group(0) if _is_match_allowlisted(redacted, m) else "[REDACTED]"
        updated = _FALLBACK_CREDENTIAL_RE.sub(_redact_unless_allowlisted, redacted)
        if updated != redacted:
            return updated, True

    return redacted, changed


def format_structured_log_entry(
    level: str,
    message: str,
    logger_name: str,
    run_context: Dict[str, Optional[str]],
) -> Dict[str, Any]:
    safe_message, is_redacted = redact_sensitive_content(message)
    return {
        "level": normalize_log_level(level) or "info",
        "message": safe_message,
        "logger": logger_name,
        "timestamp": utc_iso_timestamp(),
        "request-id": run_context.get("request-id"),
        "conversation-id": run_context.get("conversation-id"),
        "message-id": run_context.get("message-id"),
        "response-timestamp": run_context.get("response-timestamp"),
        "operation": run_context.get("operation"),
        "is-redacted": is_redacted,
    }
