from __future__ import annotations

"""OTEL-aligned telemetry profiler for Highlighter.

Emits spans, metrics, and logs in OTLP-compatible shapes. Export is OTLP-only;
when OTLP output is not configured, records remain in-memory for testing.
"""

import logging
import os
import socket
import threading
import time
import uuid
from collections import deque
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple

from .metrics import (
    ELEMENT_TIME_US,
    INFERENCE_TIME_US,
    PIPELINE_MEMORY_DELTA_BYTES,
    PIPELINE_TIME_US,
    PROCESS_CPU_SECONDS_TOTAL,
    PROCESS_CPU_UTILIZATION,
    PROCESS_RSS_BYTES,
    PROCESS_RUNTIME_GC_COLLECTED,
    PROCESS_RUNTIME_GC_COLLECTIONS,
    PROCESS_RUNTIME_GC_UNCOLLECTABLE,
    PROCESS_THREAD_COUNT,
    normalize_frame_metrics,
)
from .model import (
    SpanContext,
    attributes_from_dict,
    gauge_metric,
    histogram_metric,
    instrumentation_scope,
    new_span_id,
    new_trace_id,
    now_unix_nano,
    sum_metric,
)
from .sampling import Sampler

LOGGER = logging.getLogger(__name__)

_DEFAULT_MAX_BUFFER_SIZE = 8192

if TYPE_CHECKING:
    from .otel_exporter import OtelExporter

_VALUE_KEYS = {
    "bool": "bool_value",
    "int": "int_value",
    "double": "double_value",
    "bytes": "bytes_value",
    "string": "string_value",
    "array": "array_value",
    "kvlist": "kvlist_value",
}


def _hashable_any_value(value: Dict[str, Any]) -> Tuple[str, Any]:
    if not isinstance(value, dict):
        return ("unknown", value)
    value_type = value.get("type") or "unknown"
    if value_type == "array":
        array_values = value.get("array_value") or []
        return (value_type, tuple(_hashable_any_value(item) for item in array_values))
    if value_type == "kvlist":
        kv_values = value.get("kvlist_value") or []
        items = []
        for entry in kv_values:
            key = entry.get("key")
            if key is None:
                continue
            items.append((key, _hashable_any_value(entry.get("value", {}))))
        return (value_type, tuple(sorted(items, key=lambda item: item[0])))
    value_key = _VALUE_KEYS.get(value_type)
    return (value_type, value.get(value_key) if value_key else value)


def _attribute_key(attributes: Optional[Dict[str, Any]]) -> Tuple[Tuple[str, Tuple[str, Any]], ...]:
    if not attributes:
        return ()
    items = []
    for entry in attributes_from_dict(attributes):
        key = entry.get("key")
        if key is None:
            continue
        items.append((key, _hashable_any_value(entry.get("value", {}))))
    return tuple(sorted(items, key=lambda item: item[0]))


class Profiler:
    """Collects OTEL-style spans, metrics, and logs for Highlighter runtime/agent."""

    def __init__(
        self,
        run_id: str,
        sample_rate: int = 1,
        otlp_endpoint: Optional[str] = None,
        otlp_headers: Optional[Dict[str, str]] = None,
        otlp_protocol: str = "http",
        resource_attributes: Optional[Dict[str, str]] = None,
        scope_name: str = "highlighter_telemetry",
        scope_version: Optional[str] = None,
        max_buffer_size: int = _DEFAULT_MAX_BUFFER_SIZE,
    ) -> None:
        self._run_id = run_id
        self._max_buffer_size = max_buffer_size
        self._sampler = Sampler(sample_rate)
        self._scope = instrumentation_scope(scope_name, scope_version)
        self._resource_attributes = resource_attributes or {}
        self._resource_attributes.setdefault("highlighter.run_id", run_id)
        self._resource_attributes.setdefault("service.instance.id", str(uuid.uuid4()))
        self._host_name = socket.gethostname()
        self._process_pid = os.getpid()
        self._otel_exporter: Optional["OtelExporter"] = None
        if otlp_endpoint:
            try:
                from .otel_exporter import OtelExporter

                self._otel_exporter = OtelExporter(
                    endpoint=otlp_endpoint,
                    headers=otlp_headers,
                    protocol=otlp_protocol,
                    resource_attributes=self._resource_attributes_for_otel(),
                    scope=self._scope,
                )
            except ImportError as exc:
                LOGGER.error("OTLP exporter unavailable: %s", exc)

        self._lock = threading.Lock()
        self._spans: deque[dict] = deque(maxlen=max_buffer_size)
        self._metrics: deque[dict] = deque(maxlen=max_buffer_size)
        self._logs: deque[dict] = deque(maxlen=max_buffer_size)
        self._thread_local = threading.local()
        self._last_rss_by_stream: Dict[str, int] = {}
        self._counters: Dict[tuple, int | float] = {}
        self._prev_cpu_time: Optional[float] = None
        self._prev_cpu_wall: Optional[float] = None

    def _append_bounded(self, buffer: deque, item: dict, label: str) -> None:
        """Append *item* to *buffer*; deque(maxlen) drops the oldest entry at capacity."""
        if len(buffer) >= self._max_buffer_size:
            LOGGER.warning(
                "Telemetry %s buffer full (%d); oldest entry dropped", label, self._max_buffer_size
            )
        buffer.append(item)

    def _requeue_bounded(self, failed: list, buf: deque, label: str) -> None:
        """Merge *failed* export items back into *buf*, respecting max buffer size."""
        merged = failed + list(buf)
        overflow = len(merged) - self._max_buffer_size
        if overflow > 0:
            merged = merged[overflow:]
            LOGGER.warning("Telemetry %s buffer overflow; dropped %d oldest entries", label, overflow)
        buf.clear()
        buf.extend(merged)

    def set_baggage(self, items: Dict[str, str]) -> None:
        self._resource_attributes.update(items)

    def get_baggage(self) -> Dict[str, str]:
        return dict(self._resource_attributes)

    def start_span(
        self,
        name: str,
        kind: str = "INTERNAL",
        attributes: Optional[Dict[str, Any]] = None,
        parent: Optional[SpanContext] = None,
        links: Optional[List[dict]] = None,
    ) -> SpanContext:
        """Start a span and set it as the thread-local active span."""
        trace_id = parent.trace_id if parent else new_trace_id()
        span = SpanContext(
            trace_id=trace_id,
            span_id=new_span_id(),
            parent_span_id=parent.span_id if parent else None,
            name=name,
            kind=kind,
            start_time_unix_nano=now_unix_nano(),
            attributes=attributes_from_dict(attributes),
            events=[],
            links=links or [],
            status=None,
        )
        if not hasattr(self._thread_local, "span_stack"):
            self._thread_local.span_stack = []
        self._thread_local.span_stack.append(span)
        self._thread_local.active_span = span
        return span

    def end_span(self, span: SpanContext, status: Optional[dict] = None) -> None:
        """End a span and append it to the pending export buffer."""
        if span is None:
            return
        record = {
            "trace_id": span.trace_id,
            "span_id": span.span_id,
            "parent_span_id": span.parent_span_id,
            "name": span.name,
            "kind": span.kind,
            "start_time_unix_nano": span.start_time_unix_nano,
            "end_time_unix_nano": now_unix_nano(),
            "attributes": span.attributes,
            "events": span.events,
            "links": span.links,
            "status": status or {"code": "UNSET", "message": None},
        }
        with self._lock:
            self._append_bounded(self._spans, record, "span")
        stack = getattr(self._thread_local, "span_stack", None)
        if stack:
            if stack[-1] is span:
                stack.pop()
            else:
                for idx in range(len(stack) - 1, -1, -1):
                    if stack[idx] is span:
                        del stack[idx]
                        break
            self._thread_local.active_span = stack[-1] if stack else None
        elif getattr(self._thread_local, "active_span", None) is span:
            self._thread_local.active_span = None

    def add_event(self, span: SpanContext, name: str, attributes: Optional[Dict[str, Any]] = None) -> None:
        if span is None:
            return
        span.events.append(
            {
                "time_unix_nano": now_unix_nano(),
                "name": name,
                "attributes": attributes_from_dict(attributes),
            }
        )

    def add_link(self, span: SpanContext, link: dict) -> None:
        if span is None:
            return
        span.links.append(link)

    def start_frame_span(self, stream_id: str, frame_id: int) -> Optional[SpanContext]:
        """Start a per-frame span when sampling allows."""
        if not self._sampler.should_sample(stream_id, "trace"):
            self._thread_local.frame_span = None
            return None
        attrs = {"highlighter.stream_id": stream_id, "highlighter.frame_id": frame_id}
        span = self.start_span("hl.frame", attributes=attrs)
        self._thread_local.frame_span = span
        return span

    def end_frame_span(self, span: SpanContext, **_kwargs: Any) -> None:
        """End a frame span."""
        if span is None:
            return
        self.end_span(span)
        if getattr(self._thread_local, "frame_span", None) is span:
            self._thread_local.frame_span = None

    def start_element_span(self, stream_id: str, frame_id: int, element_name: str) -> Optional[SpanContext]:
        """Start a child span for a pipeline element within the active frame span."""
        parent = getattr(self._thread_local, "frame_span", None)
        if parent is None:
            return None
        attrs = {
            "highlighter.stream_id": stream_id,
            "highlighter.frame_id": frame_id,
            "highlighter.element_name": element_name,
        }
        return self.start_span(f"hl.element.{element_name}", attributes=attrs, parent=parent)

    def end_element_span(self, span: SpanContext, **_kwargs: Any) -> None:
        """End an element span."""
        if span is None:
            return
        self.end_span(span)

    def record_io(self, **_kwargs: Any) -> None:
        # Stub for MVP
        return

    def metric(
        self,
        name: str,
        value: int | float,
        kind: str,
        attributes: Optional[Dict[str, Any]] = None,
        unit: Optional[str] = None,
        aggregation_temporality: Optional[str] = None,
        is_monotonic: Optional[bool] = None,
    ) -> None:
        """Record a metric data point (gauge, sum, or histogram)."""
        if kind == "gauge":
            metric = gauge_metric(name, value, unit, now_unix_nano())
        elif kind == "sum":
            metric = sum_metric(
                name,
                value,
                unit,
                now_unix_nano(),
                aggregation_temporality or "CUMULATIVE",
                True if is_monotonic is None else is_monotonic,
            )
        elif kind == "histogram":
            metric = histogram_metric(
                name,
                value,
                unit,
                now_unix_nano(),
                aggregation_temporality or "DELTA",
            )
        else:
            return
        if attributes:
            data = metric["data"][1]
            for point in data.get("data_points", []):
                point["attributes"].extend(attributes_from_dict(attributes))
        with self._lock:
            self._append_bounded(self._metrics, metric, "metric")

    def count(
        self, name: str, value: int = 1, attributes: Optional[Dict[str, Any]] = None, unit: str = "count"
    ) -> None:
        """Increment a monotonic counter and emit the updated value."""
        key = (name, _attribute_key(attributes))
        with self._lock:
            current = self._counters.get(key, 0)
            current += value
            self._counters[key] = current
        self.metric(
            name,
            current,
            "sum",
            attributes=attributes,
            unit=unit,
            aggregation_temporality="CUMULATIVE",
            is_monotonic=True,
        )

    def start_timer(self, name: str, attributes: Optional[Dict[str, Any]] = None) -> None:
        """Start a thread-local timer for histogram metrics."""
        if not hasattr(self._thread_local, "timers"):
            self._thread_local.timers = {}
        self._thread_local.timers[(name, _attribute_key(attributes))] = time.perf_counter()

    def stop_timer(self, name: str, attributes: Optional[Dict[str, Any]] = None) -> None:
        """Stop a thread-local timer and emit the duration in microseconds."""
        timers = getattr(self._thread_local, "timers", {})
        key = (name, _attribute_key(attributes))
        start = timers.pop(key, None)
        if start is None:
            return
        duration_s = time.perf_counter() - start
        self.metric(
            name,
            duration_s * 1_000_000,
            "histogram",
            attributes=attributes,
            unit="us",
            aggregation_temporality="DELTA",
        )

    def log(
        self,
        severity: str,
        body: str | Dict[str, Any],
        attributes: Optional[Dict[str, Any]] = None,
        span: Optional[SpanContext] = None,
    ) -> None:
        """Record an OTEL log record with optional span context."""
        log_span = span or getattr(self._thread_local, "active_span", None)
        record = {
            "time_unix_nano": now_unix_nano(),
            "observed_time_unix_nano": None,
            "severity_number": self._severity_number(severity),
            "severity_text": severity.upper(),
            "body": None if body is None else {"type": "string", "string_value": str(body)},
            "attributes": attributes_from_dict(attributes),
            "trace_id": log_span.trace_id if log_span else None,
            "span_id": log_span.span_id if log_span else None,
        }
        with self._lock:
            self._append_bounded(self._logs, record, "log")

    def stream_attributes(self, stream_id: Optional[str]) -> Dict[str, Any]:
        """Return standard stream attributes for metric/log attribution."""
        return self._common_attrs(stream_id)

    def record_process_metrics(
        self,
        rss_bytes: Optional[int] = None,
        cpu_percent: Optional[float] = None,
        cpu_seconds: Optional[float] = None,
        thread_count: Optional[int] = None,
    ) -> None:
        """Record RSS/CPU/thread metrics, using live snapshots when values are omitted."""
        if rss_bytes is None:
            rss_bytes = self._current_rss_bytes()
        if cpu_percent is None or cpu_seconds is None:
            current_cpu_percent, current_cpu_seconds = self._current_cpu_stats()
            if cpu_percent is None:
                cpu_percent = current_cpu_percent
            if cpu_seconds is None:
                cpu_seconds = current_cpu_seconds
        if thread_count is None:
            thread_count = self._current_thread_count()

        attrs = self._common_attrs()
        if rss_bytes is not None:
            self.metric(
                PROCESS_RSS_BYTES,
                int(rss_bytes),
                "gauge",
                unit="bytes",
                attributes=attrs,
            )
        if cpu_percent is not None:
            self.metric(
                PROCESS_CPU_UTILIZATION,
                float(cpu_percent),
                "gauge",
                unit="percent",
                attributes=attrs,
            )
        if cpu_seconds is not None:
            self.metric(
                PROCESS_CPU_SECONDS_TOTAL,
                float(cpu_seconds),
                "sum",
                unit="s",
                attributes=attrs,
                aggregation_temporality="CUMULATIVE",
                is_monotonic=True,
            )
        if thread_count is not None:
            self.metric(
                PROCESS_THREAD_COUNT,
                int(thread_count),
                "gauge",
                unit="count",
                attributes=attrs,
            )

    def record_gc_stats(self, stream_id: Optional[str] = None, frame_id: Optional[int] = None) -> None:
        """Record aggregated GC stats as monotonic counters."""
        _ = frame_id
        try:
            import gc

            stats = gc.get_stats()
        except Exception:
            LOGGER.exception("Failed to collect GC stats")
            return

        base_attrs = self._common_attrs(stream_id)
        totals = {
            PROCESS_RUNTIME_GC_COLLECTIONS: 0,
            PROCESS_RUNTIME_GC_COLLECTED: 0,
            PROCESS_RUNTIME_GC_UNCOLLECTABLE: 0,
        }
        for generation, entry in enumerate(stats):
            collections = int(entry.get("collections", 0))
            collected = int(entry.get("collected", 0))
            uncollectable = int(entry.get("uncollectable", 0))
            totals[PROCESS_RUNTIME_GC_COLLECTIONS] += collections
            totals[PROCESS_RUNTIME_GC_COLLECTED] += collected
            totals[PROCESS_RUNTIME_GC_UNCOLLECTABLE] += uncollectable
            attrs = dict(base_attrs)
            attrs["gc_generation"] = generation
            legend = attrs.get("highlighter.legend")
            if legend:
                attrs["highlighter.legend"] = f"gen{generation},{legend}"
            self.metric(
                PROCESS_RUNTIME_GC_COLLECTIONS,
                collections,
                "sum",
                attributes=attrs,
                unit="count",
                aggregation_temporality="CUMULATIVE",
                is_monotonic=True,
            )
            self.metric(
                PROCESS_RUNTIME_GC_COLLECTED,
                collected,
                "sum",
                attributes=attrs,
                unit="count",
                aggregation_temporality="CUMULATIVE",
                is_monotonic=True,
            )
            self.metric(
                PROCESS_RUNTIME_GC_UNCOLLECTABLE,
                uncollectable,
                "sum",
                attributes=attrs,
                unit="count",
                aggregation_temporality="CUMULATIVE",
                is_monotonic=True,
            )

        self.metric(
            PROCESS_RUNTIME_GC_COLLECTIONS,
            totals[PROCESS_RUNTIME_GC_COLLECTIONS],
            "sum",
            attributes=base_attrs,
            unit="count",
            aggregation_temporality="CUMULATIVE",
            is_monotonic=True,
        )
        self.metric(
            PROCESS_RUNTIME_GC_COLLECTED,
            totals[PROCESS_RUNTIME_GC_COLLECTED],
            "sum",
            attributes=base_attrs,
            unit="count",
            aggregation_temporality="CUMULATIVE",
            is_monotonic=True,
        )
        self.metric(
            PROCESS_RUNTIME_GC_UNCOLLECTABLE,
            totals[PROCESS_RUNTIME_GC_UNCOLLECTABLE],
            "sum",
            attributes=base_attrs,
            unit="count",
            aggregation_temporality="CUMULATIVE",
            is_monotonic=True,
        )

    def record_aiko_frame_stats(self, stream: Any) -> None:
        """Record per-frame pipeline metrics and element spans from Aiko metrics."""
        if stream is None:
            return
        frame = getattr(stream, "frames", {}).get(stream.frame_id)
        metrics = frame.metrics if frame and hasattr(frame, "metrics") else {}
        if metrics is None:
            metrics = {}
        elements = metrics.get("elements", {}) if metrics else {}

        frame_span = self.start_frame_span(str(stream.stream_id), stream.frame_id)
        trace_id = frame_span.trace_id if frame_span else None
        frame_span_id = frame_span.span_id if frame_span else None

        stream_attrs = self._common_attrs(str(stream.stream_id))
        normalized = normalize_frame_metrics(metrics)

        pipeline_memory_delta_bytes = normalized.get("pipeline_memory_delta_bytes")
        if pipeline_memory_delta_bytes is None:
            process_rss_bytes = normalized.get("process_rss_bytes")
            if process_rss_bytes is None:
                process_rss_bytes = self._current_rss_bytes()
            if process_rss_bytes is not None:
                pipeline_memory_delta_bytes = self._rss_delta(str(stream.stream_id), int(process_rss_bytes))
        if pipeline_memory_delta_bytes is not None:
            self.metric(
                PIPELINE_MEMORY_DELTA_BYTES,
                int(pipeline_memory_delta_bytes),
                "gauge",
                unit="bytes",
                attributes=stream_attrs,
            )

        pipeline_time_us = normalized.get("pipeline_time_us")
        if pipeline_time_us is not None:
            self.metric(
                PIPELINE_TIME_US,
                pipeline_time_us,
                "histogram",
                unit="us",
                attributes=stream_attrs,
                aggregation_temporality="DELTA",
            )

        inference_time_us = normalized.get("inference_time_us")
        if inference_time_us is not None:
            self.metric(
                INFERENCE_TIME_US,
                inference_time_us,
                "histogram",
                unit="us",
                attributes=stream_attrs,
                aggregation_temporality="DELTA",
            )

        for metric_name, metric_value in elements.items():
            if "_" not in metric_name:
                continue
            element_name, metric_type = metric_name.rsplit("_", 1)
            if metric_type != "time":
                continue
            duration_s = float(metric_value)
            attrs = {
                "highlighter.stream_id": str(stream.stream_id),
                "highlighter.frame_id": stream.frame_id,
                "highlighter.element_name": element_name,
            }
            self.metric(
                ELEMENT_TIME_US,
                duration_s * 1_000_000,
                "histogram",
                attributes=attrs,
                unit="us",
                aggregation_temporality="DELTA",
            )
            if frame_span_id and trace_id:
                end_ns = now_unix_nano()
                start_ns = end_ns - int(duration_s * 1_000_000_000)
                span_record = {
                    "trace_id": trace_id,
                    "span_id": new_span_id(),
                    "parent_span_id": frame_span_id,
                    "name": f"hl.element.{element_name}",
                    "kind": "INTERNAL",
                    "start_time_unix_nano": start_ns,
                    "end_time_unix_nano": end_ns,
                    "attributes": attributes_from_dict(attrs),
                    "events": [],
                    "links": [],
                    "status": {"code": "UNSET", "message": None},
                }
                with self._lock:
                    self._append_bounded(self._spans, span_record, "span")

        if frame_span:
            self.end_frame_span(frame_span)

    def _current_rss_bytes(self) -> Optional[int]:
        try:
            import psutil

            return psutil.Process().memory_info().rss
        except Exception:
            LOGGER.exception("Failed to collect process RSS")
            return None

    def _current_cpu_stats(self) -> tuple[Optional[float], Optional[float]]:
        try:
            import psutil

            proc = psutil.Process()
            cpu_times = proc.cpu_times()
            cpu_seconds = float(cpu_times.user) + float(cpu_times.system)
            now = time.monotonic()
            cpu_percent = None
            if self._prev_cpu_time is not None and self._prev_cpu_wall is not None:
                delta_cpu = cpu_seconds - self._prev_cpu_time
                delta_wall = now - self._prev_cpu_wall
                if delta_wall > 0:
                    cpu_percent = (delta_cpu / delta_wall) * 100.0
            self._prev_cpu_time = cpu_seconds
            self._prev_cpu_wall = now
            return cpu_percent, cpu_seconds
        except Exception:
            LOGGER.exception("Failed to collect process CPU stats")
            return None, None

    def _current_thread_count(self) -> Optional[int]:
        try:
            import psutil

            return psutil.Process().num_threads()
        except Exception:
            LOGGER.exception("Failed to collect process thread count")
            return None

    def _rss_delta(self, stream_id: str, current_rss: int) -> Optional[int]:
        with self._lock:
            previous = self._last_rss_by_stream.get(stream_id)
            self._last_rss_by_stream[stream_id] = current_rss
        if previous is None:
            return None
        return current_rss - previous

    def _legend(self, stream_id: Optional[str] = None) -> str:
        if stream_id is not None:
            return f"stream={stream_id},pid={self._process_pid},host={self._host_name}"
        return f"pid={self._process_pid},host={self._host_name}"

    def _common_attrs(self, stream_id: Optional[str] = None) -> Dict[str, Any]:
        attrs: Dict[str, Any] = {
            "host.name": self._host_name,
            "process.pid": self._process_pid,
            "highlighter.legend": self._legend(stream_id),
        }
        if stream_id is not None:
            attrs["highlighter.stream_id"] = stream_id
        return attrs

    def flush(self) -> None:
        """Export buffered spans/metrics/logs via OTLP."""
        with self._lock:
            if not self._spans and not self._metrics and not self._logs:
                return
            spans = list(self._spans)
            metrics = list(self._metrics)
            logs = list(self._logs)
            self._spans.clear()
            self._metrics.clear()
            self._logs.clear()
        if self._otel_exporter:
            spans_ok = True
            metrics_ok = True
            logs_ok = True
            if spans:
                try:
                    self._otel_exporter.export_spans(spans)
                except Exception:
                    LOGGER.exception("Failed to export spans; will retry")
                    spans_ok = False
            if metrics:
                try:
                    self._otel_exporter.export_metrics(metrics)
                except Exception:
                    LOGGER.exception("Failed to export metrics; will retry")
                    metrics_ok = False
            if logs:
                try:
                    self._otel_exporter.export_logs(logs)
                except Exception:
                    LOGGER.exception("Failed to export logs; will retry")
                    logs_ok = False
            if not (spans_ok and metrics_ok and logs_ok):
                with self._lock:
                    if not spans_ok:
                        self._requeue_bounded(spans, self._spans, "span")
                    if not metrics_ok:
                        self._requeue_bounded(metrics, self._metrics, "metric")
                    if not logs_ok:
                        self._requeue_bounded(logs, self._logs, "log")

    def close(self) -> None:
        """Flush and shutdown the OTLP exporter."""
        self.flush()
        if self._otel_exporter:
            self._otel_exporter.shutdown()

    def __enter__(self) -> "Profiler":
        return self

    def __exit__(self, exc_type, exc_val, exc_tb) -> None:
        self.close()

    def _resource_attributes_for_otel(self) -> Dict[str, Any]:
        attrs = {
            "service.name": "highlighter-agent",
            "service.instance.id": self._resource_attributes.get("service.instance.id"),
        }
        attrs["process.pid"] = self._process_pid
        attrs["host.name"] = self._host_name
        attrs.update(self._resource_attributes)
        return attrs

    @staticmethod
    def _severity_number(severity: str) -> int:
        mapping = {
            "TRACE": 1,
            "DEBUG": 5,
            "INFO": 9,
            "WARN": 13,
            "WARNING": 13,
            "ERROR": 17,
            "FATAL": 21,
            "CRITICAL": 21,
        }
        return mapping.get(severity.upper(), 9)
