"""``mgf-fed logs`` — locate, read, and live-tail an app's logs.

Wave 2 of the unified-logging design
(``docs/inprogress/UNIFIED_LOGGING_DESIGN.md``) — the command-line half of
"access any Magogi app's logs at runtime and afterward":

  - ``logs info``   — where the logs live + what components/levels appear.
  - ``logs tail``   — print the last N records (at-rest read).
  - ``logs follow`` — stream new records live (real-time).

It reads the rotating-file sink, which always emits JSON (one
``LogRecord``-shaped object per line). Filters by **level threshold**
(``--level WARNING`` shows WARNING and above) and **component**
(``--component app.db``). ``--json`` emits the raw lines; otherwise a
compact pretty form.

The log file is resolved from ``--file`` (explicit), else ``--app NAME``
(the app's default ``<state>/<app>/logs/<app>.log``), else the current
default. Note: this reads the on-disk **artifact** — live in-process
handler state is :func:`mgf.common.observability.describe_logging` (and
the per-app web ``/logs/info`` endpoint, Wave 3).
"""

from __future__ import annotations

import json
import sys
import time
from collections import Counter, deque
from pathlib import Path
from typing import TYPE_CHECKING, Any

from mgf.common.cli import EXIT_CONFIG_ERROR, EXIT_SUCCESS

if TYPE_CHECKING:
    import argparse
    from collections.abc import Iterator

_LEVEL_NO = {
    "DEBUG": 10,
    "INFO": 20,
    "WARNING": 30,
    "WARN": 30,
    "ERROR": 40,
    "CRITICAL": 50,
    "FATAL": 50,
}
_FOLLOW_POLL_S = 0.5


def add_logs_args(parser: argparse.ArgumentParser) -> None:
    """Register ``mgf-fed logs`` arguments."""
    parser.add_argument(
        "action",
        nargs="?",
        choices=("info", "tail", "follow"),
        default="info",
        help="info (where + what logs; default) · tail (last N) · follow (live).",
    )
    parser.add_argument("--file", default=None, help="Explicit log-file path.")
    parser.add_argument(
        "--app",
        default=None,
        help="App name — resolve its default <state>/<app>/logs/<app>.log.",
    )
    parser.add_argument(
        "-n",
        "--lines",
        type=int,
        default=50,
        help="Number of records for `tail` (default 50) / initial `follow` window.",
    )
    parser.add_argument(
        "--level",
        default=None,
        help="Minimum level (DEBUG/INFO/WARNING/ERROR/CRITICAL) — threshold filter.",
    )
    parser.add_argument(
        "--component",
        default=None,
        help="Only records whose logger name contains this (e.g. app.db).",
    )
    parser.add_argument(
        "--json",
        action="store_true",
        help="Emit raw JSONL instead of pretty lines.",
    )


def run_logs(args: argparse.Namespace) -> int:
    path = _resolve_log_file(args)
    min_no = _level_no(args.level) if args.level else 0
    if args.level and min_no == 0:
        print(f"error: unknown level {args.level!r}", file=sys.stderr)
        return EXIT_CONFIG_ERROR
    component = args.component

    if args.action == "info":
        return _run_info(path, min_no, component, args.json)
    if args.action == "tail":
        return _run_tail(path, args.lines, min_no, component, args.json)
    return _run_follow(path, args.lines, min_no, component, args.json)


# --------------------------------------------------------------------------- #
# Resolution + parsing helpers
# --------------------------------------------------------------------------- #


def _resolve_log_file(args: argparse.Namespace) -> Path:
    from mgf.common.observability.paths import default_log_path

    if args.file:
        return Path(args.file)
    if args.app:
        from mgf.common import set_identity

        set_identity(args.app, "0.0.0")
    return default_log_path()


def _level_no(name: str | None) -> int:
    return _LEVEL_NO.get(str(name).upper(), 0) if name else 0


def _matches(rec: dict[str, Any], min_no: int, component: str | None) -> bool:
    if min_no and _level_no(rec.get("level")) < min_no:
        return False
    if component:
        return component.lower() in str(rec.get("logger", "")).lower()
    return True


def _iter_records(path: Path) -> Iterator[dict[str, Any]]:
    """Yield parsed JSON log records; silently skip malformed lines."""
    with path.open(encoding="utf-8", errors="replace") as fh:
        for line in fh:
            line = line.strip()
            if not line:
                continue
            try:
                obj = json.loads(line)
            except ValueError:
                continue
            if isinstance(obj, dict):
                yield obj


def _format(rec: dict[str, Any], as_json: bool) -> str:
    if as_json:
        return json.dumps(rec, separators=(",", ":"))
    ts = rec.get("ts", "")
    level = str(rec.get("level", "?"))
    logger = rec.get("logger", "?")
    msg = rec.get("msg", "")
    out = f"{ts}  {level:<8} {logger}  {msg}"
    exc = rec.get("exception")
    if exc:
        out += f"\n{exc}"
    return out


# --------------------------------------------------------------------------- #
# Actions
# --------------------------------------------------------------------------- #


def _run_info(path: Path, min_no: int, component: str | None, as_json: bool) -> int:
    from mgf.common.observability.paths import default_crash_dir, default_state_dir

    exists = path.is_file()
    size = path.stat().st_size if exists else 0
    levels: Counter[str] = Counter()
    components: Counter[str] = Counter()
    total = 0
    if exists:
        # Bounded tail-scan so `info` stays fast on big logs.
        for rec in deque(_iter_records(path), maxlen=20_000):
            if not _matches(rec, min_no, component):
                continue
            total += 1
            levels[str(rec.get("level", "?"))] += 1
            components[str(rec.get("logger", "?"))] += 1

    payload = {
        "log_file": str(path),
        "exists": exists,
        "size_bytes": size,
        "crash_dir": str(default_crash_dir()),
        "state_dir": str(default_state_dir()),
        "scanned_records": total,
        "levels": dict(levels),
        "components": dict(components.most_common()),
    }
    if as_json:
        print(json.dumps(payload, indent=2))
        return EXIT_SUCCESS

    print(f"log file   : {path}  ({'exists' if exists else 'MISSING'}, {size} bytes)")
    print(f"crash dir  : {payload['crash_dir']}")
    print(f"state dir  : {payload['state_dir']}")
    if not exists:
        print("\n(no log file yet — run the app, or pass --file / --app)")
        return EXIT_SUCCESS
    print(f"\nscanned {total} records (last {min(total, 20_000)})")
    if levels:
        print("by level   : " + ", ".join(f"{k} {v}" for k, v in sorted(levels.items())))
    if components:
        print("components :")
        for name, count in components.most_common():
            print(f"  {count:>6}  {name}")
    return EXIT_SUCCESS


def _run_tail(path: Path, n: int, min_no: int, component: str | None, as_json: bool) -> int:
    if not path.is_file():
        print(f"error: log file not found: {path}", file=sys.stderr)
        return EXIT_CONFIG_ERROR
    window: deque[dict[str, Any]] = deque(
        (r for r in _iter_records(path) if _matches(r, min_no, component)),
        maxlen=max(1, n),
    )
    for rec in window:
        print(_format(rec, as_json))
    return EXIT_SUCCESS


def _run_follow(path: Path, n: int, min_no: int, component: str | None, as_json: bool) -> int:
    # Print the initial window, then poll for appends (tail -f).
    _run_tail(path, n, min_no, component, as_json) if path.is_file() else print(
        f"(waiting for {path} …)"
    )
    pos = path.stat().st_size if path.is_file() else 0
    try:
        while True:
            time.sleep(_FOLLOW_POLL_S)
            if not path.is_file():
                pos = 0
                continue
            size = path.stat().st_size
            if size < pos:  # rotated / truncated — restart from the top
                pos = 0
            if size == pos:
                continue
            with path.open(encoding="utf-8", errors="replace") as fh:
                fh.seek(pos)
                for line in fh:
                    line = line.strip()
                    if not line:
                        continue
                    try:
                        rec = json.loads(line)
                    except ValueError:
                        continue
                    if isinstance(rec, dict) and _matches(rec, min_no, component):
                        print(_format(rec, as_json), flush=True)
                pos = fh.tell()
    except KeyboardInterrupt:
        return EXIT_SUCCESS


__all__ = ["add_logs_args", "run_logs"]
