"""
What the Mac sends Dealer OS so nobody ever needs this Mac's screen to see
why something went wrong (Steven 2026-09-05: the Mac Studio stays behind
the office router, and if Dealer OS needs to see something, the Mac sends
it). One document, POSTed to /api/bidbrain/diagnostics:

  {at, bidbrain_version, logs: [{name, size, modified_at, lines}],
   run_status, site_health, progress, process}

`logs` is the tail of every log file under data/logs (the last LINES lines,
each cut at LINE_CHARS, newest file first, at most FILES of them).
`run_status` and `site_health` are the full records the heartbeat only
carries the first line of. `progress` is the live progress file.

`fingerprint()` is what decides whether anything has changed since the
last send: the size and stamp of every log, plus every run's own "at". The
server sends when that differs (every few minutes at most), when a run has
just finished, and at once when Dealer OS asks (diagnostics.pull).

Pure file reads, no browser, importable by serve.py and the tests.
"""

import datetime
import json
import os
import sys
import time

LINES = 500
LINE_CHARS = 400
FILES = 8
# A log is read from its end, at most this many bytes, so a 2 GB serve.log
# never gets read whole to find its last N lines. Raised from 256KB to
# 512KB (2026-09-16, the costs screen survey's own real pictures: a
# handful of small base64 encoded screenshots, each a few hundred lines
# on their own, pushed the survey's earlier text and pictures out of a
# 300 line / 256KB window).
TAIL_BYTES = 512 * 1024


def _iso(ts):
    return datetime.datetime.fromtimestamp(ts, datetime.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")


def log_files(logs_dir):
    """Every plain file under the logs folder (no recursion), newest
    modified first, at most FILES. Missing folder reads as no logs."""
    try:
        names = os.listdir(logs_dir)
    except OSError:
        return []
    rows = []
    for name in names:
        path = os.path.join(logs_dir, name)
        if name.startswith(".") or not os.path.isfile(path):
            continue
        try:
            st = os.stat(path)
        except OSError:
            continue
        rows.append((st.st_mtime, name, path, st.st_size))
    rows.sort(reverse=True)
    return [(name, path, size, mtime) for mtime, name, path, size in rows[:FILES]]


def tail_lines(path, lines=LINES, line_chars=LINE_CHARS, tail_bytes=TAIL_BYTES):
    """The last `lines` lines of a file, each cut to `line_chars`, read from
    the end only. Bytes that are not UTF-8 are replaced, never raised on."""
    try:
        with open(path, "rb") as f:
            f.seek(0, os.SEEK_END)
            size = f.tell()
            f.seek(max(0, size - tail_bytes))
            raw = f.read()
    except OSError:
        return []
    text = raw.decode("utf-8", "replace")
    rows = text.splitlines()
    if size > tail_bytes and rows:
        rows = rows[1:]  # the first row is a partial line from mid file
    rows = rows[-lines:]
    # A NUL byte in a log (a screen read on 2026-09-08 carried one) made
    # Dealer OS's database refuse the whole document, and the Mac then
    # tried again every few seconds. Control characters other than a tab
    # are dropped from every line here, so what is sent is always text.
    rows = [_printable(r) for r in rows]
    return [r if len(r) <= line_chars else r[:line_chars] + " ..." for r in rows]


def _printable(s):
    """The line with every control character bar a tab removed (pure, tested)."""
    return "".join(ch for ch in s if ch == "\t" or ord(ch) >= 32)


def fingerprint(logs_dir, run_status):
    """Small, hashable, changes when a log grows or a run finishes."""
    logs = tuple((name, size, int(mtime)) for name, _p, size, mtime in log_files(logs_dir))
    runs = tuple(sorted((k, (v or {}).get("at") or "") for k, v in (run_status or {}).items()))
    return logs, runs


def collect(here, bidbrain_version, run_status, site_health, progress_path, started_at=None):
    """The whole document. Never raises: a log that cannot be read is
    simply not in the list, a missing progress file is null."""
    logs_dir = os.path.join(here, "data", "logs")
    logs = []
    for name, path, size, mtime in log_files(logs_dir):
        logs.append({"name": name, "size": size, "modified_at": _iso(mtime), "lines": tail_lines(path)})
    progress = None
    try:
        with open(progress_path, encoding="utf-8") as f:
            progress = json.load(f)
    except (OSError, ValueError):
        pass
    now = time.time()
    return {
        "at": _iso(now),
        "bidbrain_version": bidbrain_version,
        "logs": logs,
        "run_status": dict(run_status or {}),
        "site_health": dict(site_health or {}),
        "progress": progress,
        "process": {
            "pid": os.getpid(),
            "python": sys.version.split()[0],
            "started_at": _iso(started_at) if started_at else None,
            "uptime_seconds": int(now - started_at) if started_at else None,
        },
    }
