"""Aenderungsprotokoll mit Hash-Kette.

Ein Audit-Log, das der Administrator selbst manipulieren kann, ist kein
Audit-Log. Der Vorgaenger schrieb einfache Zeilen in eine Tabelle -- wer
Schreibzugriff auf die SQLite-Datei hatte (auf dem Zielsystem: jeder root, und
die App lief als root), konnte Eintraege spurlos entfernen.

Hier bindet jede Zeile den Hash ihrer Vorgaengerin ein:

    hash_n = sha256(hash_(n-1) || kanonische Felder von Zeile n)

Eine geloeschte oder geaenderte Zeile bricht die Kette an genau dieser Stelle.
``verify_chain()`` findet den Bruch; die Diagnoseseite zeigt ihn an. Das
verhindert Manipulation nicht, macht sie aber nachweisbar -- und genau darum
geht es bei einem Protokoll.

Archivierung: ab ``archive_threshold`` Zeilen werden die aeltesten vollstaendigen
Kalendertage in Dateien ``audit-JJJJ-MM-TT.jsonl`` ausgelagert, inklusive ihrer
Hashes, sodass die Kette ueber die Archivgrenze hinweg pruefbar bleibt.
"""

from __future__ import annotations

import hashlib
import json
import logging
import sqlite3
from collections import defaultdict
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable

from .db import Database

logger = logging.getLogger(__name__)

GENESIS = "0" * 64
ARCHIVE_THRESHOLD = 20000
ARCHIVE_TARGET = 15000

SEVERITIES = ("info", "notice", "warning", "critical")


def _canonical(row: dict[str, Any]) -> bytes:
    payload = {
        "ts": row.get("ts", ""),
        "actor": row.get("actor", ""),
        "actor_id": row.get("actor_id"),
        "action": row.get("action", ""),
        "target": row.get("target", ""),
        "detail": row.get("detail", ""),
        "ip": row.get("ip", ""),
        "request_id": row.get("request_id", ""),
        "severity": row.get("severity", "info"),
    }
    return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")


def compute_hash(prev_hash: str, row: dict[str, Any]) -> str:
    return hashlib.sha256((prev_hash or GENESIS).encode("ascii") + _canonical(row)).hexdigest()


def log(
    conn: sqlite3.Connection,
    *,
    actor: str,
    action: str,
    target: str = "",
    detail: str = "",
    actor_id: int | None = None,
    ip: str = "",
    request_id: str = "",
    severity: str = "info",
    ts: datetime | None = None,
) -> int:
    """Schreibt einen Protokolleintrag auf der uebergebenen Verbindung.

    Wichtig: **immer** die bestehende Verbindung nutzen. Eine Protokollfunktion,
    die selbst eine Verbindung oeffnet, blockiert die laufende Transaktion --
    das war im Vorgaenger eine reale Deadlock-Ursache.
    """
    if severity not in SEVERITIES:
        severity = "info"
    row = {
        "ts": (ts or datetime.now(timezone.utc)).isoformat(timespec="seconds"),
        "actor": actor or "system",
        "actor_id": actor_id,
        "action": action,
        "target": target,
        "detail": detail,
        "ip": ip,
        "request_id": request_id,
        "severity": severity,
    }
    prev_hash = (
        Database.value(conn, "SELECT hash FROM audit_log ORDER BY id DESC LIMIT 1") or GENESIS
    )
    row_hash = compute_hash(str(prev_hash), row)
    cursor = conn.execute(
        "INSERT INTO audit_log (ts, actor, actor_id, action, target, detail, ip, request_id, "
        "severity, prev_hash, hash) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
        (
            row["ts"],
            row["actor"],
            row["actor_id"],
            row["action"],
            row["target"],
            row["detail"],
            row["ip"],
            row["request_id"],
            row["severity"],
            prev_hash,
            row_hash,
        ),
    )
    return int(cursor.lastrowid or 0)


def verify_chain(conn: sqlite3.Connection, *, limit: int | None = None) -> dict[str, Any]:
    """Prueft die Hash-Kette. Rueckgabe: ``{"ok", "checked", "broken_at", "reason"}``."""
    sql = "SELECT * FROM audit_log ORDER BY id"
    params: tuple[Any, ...] = ()
    if limit:
        sql = "SELECT * FROM (SELECT * FROM audit_log ORDER BY id DESC LIMIT ?) ORDER BY id"
        params = (limit,)

    checked = 0
    expected_prev: str | None = None
    for row in conn.execute(sql, params):
        data = {key: row[key] for key in row.keys()}
        if expected_prev is not None and data["prev_hash"] != expected_prev:
            return {
                "ok": False,
                "checked": checked,
                "broken_at": data["id"],
                "reason": "Vorgaenger-Hash passt nicht -- eine Zeile wurde entfernt oder eingefuegt.",
            }
        if compute_hash(str(data["prev_hash"]), data) != data["hash"]:
            return {
                "ok": False,
                "checked": checked,
                "broken_at": data["id"],
                "reason": "Zeileninhalt passt nicht zum gespeicherten Hash -- Eintrag geaendert.",
            }
        expected_prev = str(data["hash"])
        checked += 1
    return {"ok": True, "checked": checked, "broken_at": None, "reason": ""}


def search(
    conn: sqlite3.Connection,
    *,
    query: str = "",
    action: str = "",
    actor: str = "",
    severity: str = "",
    limit: int = 200,
    offset: int = 0,
) -> list[dict[str, Any]]:
    clauses: list[str] = []
    params: list[Any] = []
    if query:
        clauses.append("(target LIKE ? OR detail LIKE ? OR action LIKE ? OR actor LIKE ?)")
        params.extend([f"%{query}%"] * 4)
    if action:
        clauses.append("action = ?")
        params.append(action)
    if actor:
        clauses.append("actor = ?")
        params.append(actor)
    if severity:
        clauses.append("severity = ?")
        params.append(severity)
    where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
    params.extend([limit, offset])
    return Database.all(
        conn,
        f"SELECT * FROM audit_log {where} ORDER BY id DESC LIMIT ? OFFSET ?",
        tuple(params),
    )


def count(conn: sqlite3.Connection) -> int:
    return int(Database.value(conn, "SELECT COUNT(*) FROM audit_log", default=0) or 0)


def distinct_actions(conn: sqlite3.Connection) -> list[str]:
    return [
        str(row["action"])
        for row in Database.all(conn, "SELECT DISTINCT action FROM audit_log ORDER BY action")
    ]


def archive_old_rows(
    conn: sqlite3.Connection,
    archive_dir: Path,
    *,
    threshold: int = ARCHIVE_THRESHOLD,
    target: int = ARCHIVE_TARGET,
) -> list[Path]:
    """Laagert ganze Kalendertage aus, bis die Tabelle unter ``target`` liegt.

    Nur *vollstaendige* Tage werden ausgelagert -- der laufende Tag bleibt immer
    in der Datenbank, damit die Auditseite nie halbe Tage zeigt.
    """
    total = count(conn)
    if total <= threshold:
        return []

    archive_dir.mkdir(parents=True, exist_ok=True)
    today = datetime.now(timezone.utc).date().isoformat()
    written: list[Path] = []

    while total > target:
        # Nach Zeitstempel, nicht nach id: Eintraege koennen mit einem
        # nachgereichten ts geschrieben worden sein (Import, Migration).
        oldest_day = Database.value(conn, "SELECT MIN(substr(ts,1,10)) FROM audit_log")
        if not oldest_day or str(oldest_day) >= today:
            break
        rows = Database.all(
            conn,
            "SELECT * FROM audit_log WHERE substr(ts,1,10)=? ORDER BY id",
            (str(oldest_day),),
        )
        if not rows:
            break
        path = archive_dir / f"audit-{oldest_day}.jsonl"
        with path.open("a", encoding="utf-8", newline="\n") as stream:
            for row in rows:
                stream.write(json.dumps(row, ensure_ascii=False, sort_keys=True) + "\n")
        conn.execute("DELETE FROM audit_log WHERE substr(ts,1,10)=?", (str(oldest_day),))
        written.append(path)
        total -= len(rows)
        logger.info("Auditlog: %s Zeilen von %s nach %s ausgelagert", len(rows), oldest_day, path)

    return written


def archived_days(archive_dir: Path) -> list[dict[str, Any]]:
    """Liste der Archivdateien fuer die Verlaufsseite."""
    if not archive_dir.exists():
        return []
    entries: list[dict[str, Any]] = []
    for path in sorted(archive_dir.glob("audit-*.jsonl"), reverse=True):
        try:
            stat = path.stat()
        except OSError:
            continue
        entries.append(
            {
                "day": path.stem.removeprefix("audit-"),
                "path": path,
                "size": stat.st_size,
                "modified": datetime.fromtimestamp(stat.st_mtime, timezone.utc).isoformat(
                    timespec="seconds"
                ),
            }
        )
    return entries


def read_archived_day(archive_dir: Path, day: str) -> list[dict[str, Any]]:
    path = archive_dir / f"audit-{day}.jsonl"
    if not path.is_file():
        return []
    rows: list[dict[str, Any]] = []
    with path.open("r", encoding="utf-8") as stream:
        for line in stream:
            line = line.strip()
            if not line:
                continue
            try:
                rows.append(json.loads(line))
            except ValueError:
                continue
    return rows


def summarize_by_day(rows: Iterable[dict[str, Any]]) -> list[tuple[str, int]]:
    buckets: dict[str, int] = defaultdict(int)
    for row in rows:
        buckets[str(row.get("ts", ""))[:10]] += 1
    return sorted(buckets.items(), reverse=True)
