# (c) cavaliba.com - home - logformat.py

"""
Formatters for the three log streams (event, debug, system), referenced from
settings.LOGGING. No Django model import here: LOGGING is configured before the
app registry is ready.

    EcsJsonFormatter  one ECS JSON object per line
    TextFormatter     one human line per record
"""

import datetime
import json
import logging
import os
import socket

SERVICE_NAME = os.environ.get("CAVALIBA_TENANT", "cavaliba")
HOST_NAME = socket.gethostname()

# short column name -> ECS field name
ECS_FIELDS = {
    "level": "log.level",
    "action": "event.action",
    "outcome": "event.outcome",
    "reason": "event.reason",
    "domain": "event.module",
    "category": "event.category",
    "id": "event.id",
    "actor": "user.name",
    "actor_effective": "user.effective.name",
    "client_ip": "client.ip",
    "request_id": "trace.id",
    "message": "message",
}

# short column name -> labels.<name>
ECS_LABELS = (
    "channel",
    "origin",
    "actor_kind",
    "target_type",
    "target_id",
    "count",
    "duration_ms",
    "db_error",
)


def _label_value(value):
    """Return a collector-safe label value: scalars and lists of scalars as-is, anything
    nested as a JSON string (avoids keyword-vs-object mapping conflicts)."""

    scalars = (str, int, float, bool, type(None))
    if isinstance(value, scalars):
        return value
    if isinstance(value, list) and all(isinstance(v, scalars) for v in value):
        return value
    return json.dumps(value, ensure_ascii=False, default=str)


def stream_of(record):
    """Return the stream name of a record: event, debug or system."""

    if record.name.startswith("cavaliba.event"):
        return "event"
    if record.name.startswith("cavaliba.debug"):
        return "debug"
    return "system"


def _isotime(value):
    """Return an ISO-8601 UTC string for a datetime or an epoch float."""

    if isinstance(value, datetime.datetime):
        if value.tzinfo is None:
            value = value.replace(tzinfo=datetime.UTC)
        dt = value.astimezone(datetime.UTC)
    else:
        dt = datetime.datetime.fromtimestamp(value, tz=datetime.UTC)
    return dt.isoformat(timespec="milliseconds").replace("+00:00", "Z")


def _short(text, size=200):
    """Return text cut to size chars for one-line output."""

    text = str(text or "")
    return text if len(text) <= size else text[: size - 3] + "..."


class EcsJsonFormatter(logging.Formatter):
    """Format any record as one ECS JSON line; events get the full event.* set."""

    def format(self, record):
        """Return the JSON line for this record."""

        stream = stream_of(record)
        event = getattr(record, "event", None)
        ambient = getattr(record, "ambient", None) or {}

        doc = {
            "@timestamp": _isotime(event["created"] if event else record.created),
            "log.level": record.levelname.lower(),
            "log.logger": record.name,
            "message": record.getMessage(),
            "service.name": SERVICE_NAME,
            "host.name": HOST_NAME,
            "labels.stream": stream,
        }

        source = event if event else ambient
        for key, value in source.items():
            if value in (None, "", {}) or key in ("created", "level", "message", "extra"):
                continue
            if key in ECS_FIELDS:
                doc[ECS_FIELDS[key]] = value
            elif key in ECS_LABELS:
                doc[f"labels.{key}"] = value

        if event:
            doc["event.kind"] = "event"
            if event.get("duration_ms") is not None:
                doc["event.duration"] = int(event["duration_ms"]) * 1_000_000
            for key, value in (event.get("extra") or {}).items():
                doc[f"labels.extra.{key}"] = _label_value(value)
        else:
            doc["log.origin.function"] = record.funcName
            doc["log.origin.file.name"] = record.filename
            doc["log.origin.file.line"] = record.lineno

        if record.exc_info:
            doc["error.type"] = record.exc_info[0].__name__ if record.exc_info[0] else ""
            doc["error.stack_trace"] = self.formatException(record.exc_info)

        return json.dumps(doc, default=str, ensure_ascii=False)


class TextFormatter(logging.Formatter):
    """Format any record as one human-readable line."""

    def format(self, record):
        """Return the text line for this record."""

        event = getattr(record, "event", None)
        ambient = getattr(record, "ambient", None) or {}
        ts = _isotime(event["created"] if event else record.created)

        if event:
            actor = event.get("actor", "") or "-"
            if event.get("actor_effective"):
                actor = f"{actor}({event['actor_effective']})"
            target = ""
            if event.get("target_type") or event.get("target_id"):
                target = f"{event.get('target_type', '')}:{event.get('target_id', '')}"
            parts = [
                ts,
                record.levelname,
                "event",
                event.get("channel", "") or "-",
                actor,
                event.get("action", ""),
                event.get("outcome", ""),
                event.get("reason", ""),
                target,
                f"count={event['count']}" if event.get("count") is not None else "",
                f"{event['duration_ms']}ms" if event.get("duration_ms") is not None else "",
                _short(event.get("message", ""))
                if event.get("message") != event.get("action")
                else "",
            ]
        else:
            parts = [
                ts,
                record.levelname,
                stream_of(record),
                ambient.get("channel", ""),
                ambient.get("actor", ""),
                ambient.get("request_id", ""),
                f"{record.name}:{record.lineno}",
                record.getMessage(),
            ]

        line = " ".join(str(p) for p in parts if p not in ("", None))
        if record.exc_info:
            line += "\n" + self.formatException(record.exc_info)
        return line
