"""Opt-in crash reports.

The plan named support burden as the biggest product risk, and the shape of
that risk is specific: a background worker swallows an exception, the app
looks idle rather than broken, and the user's report is "it stopped
working". problems.py fixed the visibility half — this fixes the other
half, which is that a problem the user can see is still a problem I cannot.

Three rules, because a crash reporter is a trust liability if it is casual
about any of them:

  * OFF until asked. No first-run nag, no pre-ticked box.
  * Nothing about what you record. Paths, filenames, recording folder,
    tokens and email addresses are stripped from the traceback before it
    is queued — a stack frame's file path can name a game, a project, or a
    person, and a report has no business carrying that.
  * Queued locally and reviewable. Reports sit in a file you can read (and
    delete) before anything is sent, and sending happens on a timer, never
    in the middle of whatever just failed.
"""
import json
import os
import platform
import re
import threading
import time
import urllib.request
from pathlib import Path

from fastapi import APIRouter, HTTPException, Request

from clipshelf import net, __version__, config
from clipshelf.config import DATA

router = APIRouter(tags=["crashreport"])

QUEUE = DATA / "crashreports.json"
MAX_QUEUED = 40
SEND_EVERY_S = 30 * 60
DEFAULT_ENDPOINT = "https://clipshelf.gg/v1/crash"
_lock = threading.Lock()

# Anything that could name a person, a game, a project or a machine. The
# home directory goes first: on Windows it contains the account name, which
# is often a real name.
_SCRUB = [
    (re.compile(re.escape(str(Path.home())), re.I), "~"),
    (re.compile(r"[A-Za-z]:\\\\Users\\\\[^\\\\/:*?\"<>|\r\n]+", re.I), r"C:\\Users\\~"),
    (re.compile(r"/home/[^/\s\"']+"), "/home/~"),
    (re.compile(r"/Users/[^/\s\"']+"), "/Users/~"),
    (re.compile(r"[\w.+-]+@[\w-]+\.[\w.]+"), "<email>"),
    (re.compile(r"\b[0-9a-f]{24,}\b", re.I), "<token>"),
]


def enabled() -> bool:
    return bool(config.CFG.get("crash_reports", False))


def endpoint() -> str:
    return str(config.CFG.get("crash_endpoint") or DEFAULT_ENDPOINT).strip()


def scrub(text: str) -> str:
    """Strip anything identifying from a traceback.

    Source frames are trimmed to their last two segments —
    'workers/transcribe_worker.py' is the whole useful content of a frame
    path — and everything else that looks like a file is replaced outright.
    A recording's path carries the folder, usually the game, and sometimes a
    person: '/mnt/footage/OBS_Raw/2026-08-13 Deadlock ranked.mp4' has no
    business in a bug report.

    The trimmed frames are parked behind placeholders while the broader
    rules run. Without that, each later rule eats the output of the one
    before it and the result is a traceback with no frames in it — which is
    both useless and, worse, looks fine.
    """
    s = str(text or "")
    kept: list = []

    def park(text_):
        kept.append(text_)
        return f"\x00F{len(kept) - 1}\x00"

    def frame(m):
        path = m.group(1)
        if path.lower().endswith(".py"):
            parts = [p for p in re.split(r"[\\/]", path) if p]
            return 'File "' + park("…/" + "/".join(parts[-2:])) + '"'
        return 'File "<path>"'

    s = re.sub(r'File "([^"]+)"', frame, s)

    # every other quoted string holding a separator is a real filename
    s = re.sub(r'"([^"]*[\\/][^"]*)"', '"<path>"', s)
    s = re.sub(r"'([^']*[\\/][^']*)'", "'<path>'", s)
    # the user's own folders, named outright
    for d in (getattr(config, "VOD_DIR", ""), str(DATA), str(Path.home())):
        if d and len(str(d)) > 3:
            s = re.sub(re.escape(str(d)) + r'[^\s"\']*', "<path>", s, flags=re.I)
    # bare absolute paths, then any media filename still standing
    s = re.sub(r'(?:[A-Za-z]:)?[\\/](?:[^\\/\s"\'<>|]+[\\/])+[^\s"\'<>|]*', "<path>", s)
    s = re.sub(r"'[^']*\.(?:mp4|mkv|mov|webm|mp3|wav|flac|jpg|jpeg|png|gif|npz|npy)'",
               "'<file>'", s, flags=re.I)
    s = re.sub(r'\S*\.(?:mp4|mkv|mov|webm|mp3|wav|flac|jpg|jpeg|png|gif|npz|npy)\b',
               "<file>", s, flags=re.I)

    for pat, rep in _SCRUB:                            # home dirs, tokens, emails
        s = pat.sub(rep, s)

    for i, text_ in enumerate(kept):                   # put the frames back
        s = s.replace(f"\x00F{i}\x00", text_)
    return s[:8000]


def _queue_read() -> list:
    try:
        return json.loads(QUEUE.read_text(encoding="utf-8"))
    except Exception:
        return []


def _queue_write(items: list):
    tmp = QUEUE.with_suffix(".json.tmp")
    tmp.write_text(json.dumps(items[-MAX_QUEUED:], indent=1), encoding="utf-8")
    tmp.replace(QUEUE)


def record(source: str, exc, note: str = ""):
    """Queue one report. Cheap, never raises, never sends inline."""
    if not enabled():
        return
    import traceback as tb
    try:
        detail = "".join(tb.format_exception(type(exc), exc, exc.__traceback__)) \
            if isinstance(exc, BaseException) else str(exc)
        item = {
            "at": int(time.time()),
            "app": __version__,
            "os": f"{platform.system()} {platform.release()}",
            "python": platform.python_version(),
            "source": str(source)[:60],
            "error": scrub(f"{type(exc).__name__}: {exc}" if isinstance(exc, BaseException)
                           else str(exc))[:300],
            "trace": scrub(detail),
            "note": scrub(str(note))[:200],
            "sent": False,
        }
        with _lock:
            items = _queue_read()
            # the same crash in a loop must not fill the queue
            for old in items:
                if old.get("error") == item["error"] and not old.get("sent"):
                    old["count"] = old.get("count", 1) + 1
                    old["at"] = item["at"]
                    _queue_write(items)
                    return
            item["count"] = 1
            items.append(item)
            _queue_write(items)
    except Exception:
        pass                    # a crash reporter that crashes is worse than none


def pending() -> list:
    return [i for i in _queue_read() if not i.get("sent")]


def send_pending(limit: int = 10) -> dict:
    if not enabled():
        return {"sent": 0, "reason": "disabled"}
    url = endpoint()
    if not url:
        return {"sent": 0, "reason": "no endpoint"}
    sent = 0
    with _lock:
        items = _queue_read()
        for item in items:
            if item.get("sent") or sent >= limit:
                continue
            try:
                body = json.dumps({k: v for k, v in item.items() if k != "sent"}).encode()
                req = urllib.request.Request(
                    url, data=body,
                    headers={"Content-Type": "application/json",
                             "User-Agent": f"Clipshelf/{__version__}"},
                    method="POST")
                with urllib.request.urlopen(req, timeout=20, context=net.context()) as r:
                    if r.status < 300:
                        item["sent"] = True
                        sent += 1
            except Exception:
                break           # offline: keep them, try again later
        _queue_write(items)
    return {"sent": sent, "queued": len(pending())}


def _loop():
    time.sleep(120)
    while True:
        try:
            if enabled():
                send_pending()
        except Exception:
            pass
        time.sleep(SEND_EVERY_S)


def start():
    threading.Thread(target=_loop, daemon=True).start()


# ------------------------------------------------------------------ routes
@router.get("/api/crashreports")
def crash_state():
    """What would be sent, so it can be read before it is."""
    items = _queue_read()
    return {"enabled": enabled(), "endpoint": endpoint(),
            "queued": len([i for i in items if not i.get("sent")]),
            "sent": len([i for i in items if i.get("sent")]),
            "reports": [{k: v for k, v in i.items() if k != "trace"} for i in items[-10:]],
            "sample_trace": (items[-1]["trace"] if items else "")}


@router.post("/api/crashreports/settings")
async def crash_settings(request: Request):
    body = await request.json()
    if "enabled" in body:
        config.CFG["crash_reports"] = bool(body["enabled"])
    if "endpoint" in body:
        config.CFG["crash_endpoint"] = str(body["endpoint"]).strip()[:300]
    config.save(config.CFG)
    return crash_state()


@router.post("/api/crashreports/send")
def crash_send():
    if not enabled():
        raise HTTPException(400, "crash reports are off")
    return {**send_pending(), **crash_state()}


@router.delete("/api/crashreports")
def crash_clear():
    with _lock:
        _queue_write([])
    return crash_state()
