"""Tamper-evident audit log — append-only JSONL with SHA-256 hash chain.

Every Census operation (scan, compare, verify, annotate, …) is logged
to a local JSONL file.  Each entry includes the SHA-256 hash of the
previous entry, forming a chain.  If an entry is modified or deleted,
the chain breaks and ``verify_chain()`` detects it.

This gives Census a lightweight compliance audit trail (SOC2, ISO 27001)
without any external dependency.

File location (default):
    ~/.local/share/certisigma-census/audit.jsonl

Override via config key ``audit_log_path`` or ``--audit-log`` CLI flag.
"""

from __future__ import annotations

import hashlib
import json
import logging
import time
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any, Iterator, Optional

logger = logging.getLogger(__name__)

_HASH_GENESIS = "0" * 64


@dataclass
class AuditEntry:
    timestamp: str
    action: str
    params: dict[str, Any] = field(default_factory=dict)
    result: dict[str, Any] = field(default_factory=dict)
    prev_hash: str = _HASH_GENESIS
    entry_hash: str = ""

    def compute_hash(self) -> str:
        """SHA-256 of the entry content (excluding entry_hash itself)."""
        payload = {
            "timestamp": self.timestamp,
            "action": self.action,
            "params": self.params,
            "result": self.result,
            "prev_hash": self.prev_hash,
        }
        canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"))
        return hashlib.sha256(canonical.encode("utf-8")).hexdigest()


def _default_log_path() -> Path:
    """Default audit log location respecting XDG on Linux."""
    base = Path.home() / ".local" / "share" / "certisigma-census"
    return base / "audit.jsonl"


def _resolve_log_path(override: str | Path | None = None) -> Path:
    if override:
        return Path(override).resolve()
    from .config import load_config
    cfg = load_config()
    configured = cfg.get("audit_log_path", "")
    if configured:
        return Path(configured).resolve()
    return _default_log_path()


def _last_hash(log_path: Path) -> str:
    """Read the hash of the last entry in the log, or genesis if empty.

    Reads only the tail of the file (up to 8 KiB) to avoid scanning
    multi-megabyte logs line by line.
    """
    if not log_path.exists() or log_path.stat().st_size == 0:
        return _HASH_GENESIS

    tail_size = min(log_path.stat().st_size, 8192)
    with log_path.open("rb") as f:
        f.seek(-tail_size, 2)
        tail = f.read().decode("utf-8", errors="replace")

    last_line = ""
    for line in tail.splitlines():
        stripped = line.strip()
        if stripped:
            last_line = stripped

    if not last_line:
        return _HASH_GENESIS
    try:
        entry = json.loads(last_line)
        if not isinstance(entry, dict):
            logger.warning("Last audit entry is not a JSON object — chaining from genesis")
            return _HASH_GENESIS
        h = entry.get("entry_hash", "")
        if isinstance(h, str) and h:
            return h
        logger.warning("Last audit entry missing/invalid entry_hash — chaining from genesis")
        return _HASH_GENESIS
    except json.JSONDecodeError:
        logger.warning("Last audit entry is corrupt JSON — chaining from genesis")
        return _HASH_GENESIS


def append_entry(
    action: str,
    params: dict[str, Any] | None = None,
    result: dict[str, Any] | None = None,
    log_path: Path | str | None = None,
) -> AuditEntry:
    """Append a new entry to the audit log with hash chain."""
    resolved = _resolve_log_path(log_path)
    resolved.parent.mkdir(parents=True, exist_ok=True)

    prev = _last_hash(resolved)
    entry = AuditEntry(
        timestamp=time.strftime("%Y-%m-%dT%H:%M:%S%z"),
        action=action,
        params=params or {},
        result=result or {},
        prev_hash=prev,
    )
    entry.entry_hash = entry.compute_hash()

    line = json.dumps(asdict(entry), separators=(",", ":"), sort_keys=True)
    with resolved.open("a", encoding="utf-8") as f:
        f.write(line + "\n")

    logger.debug("Audit log: %s → %s", action, entry.entry_hash[:12])
    return entry


def iter_entries(log_path: Path | str | None = None) -> Iterator[AuditEntry]:
    """Iterate over all entries in the audit log.

    Corrupt or unparseable lines are silently skipped with a warning log.
    """
    resolved = _resolve_log_path(log_path)
    if not resolved.exists():
        return
    with resolved.open("r", encoding="utf-8") as f:
        for lineno, line in enumerate(f, 1):
            stripped = line.strip()
            if not stripped:
                continue
            try:
                d = json.loads(stripped)
                yield AuditEntry(**d)
            except (json.JSONDecodeError, TypeError, KeyError) as exc:
                logger.warning("Skipping corrupt audit entry at line %d: %s", lineno, exc)


@dataclass
class ChainVerification:
    total: int = 0
    valid: int = 0
    broken_at: Optional[int] = None
    error: Optional[str] = None

    @property
    def intact(self) -> bool:
        return self.broken_at is None and self.error is None


def verify_chain(log_path: Path | str | None = None) -> ChainVerification:
    """Verify the hash chain integrity of the audit log.

    Uses raw line parsing instead of ``iter_entries()`` to detect
    corrupt lines as chain breaks rather than silently skipping them.
    """
    resolved = _resolve_log_path(log_path)
    if not resolved.exists():
        return ChainVerification(total=0, valid=0)

    result = ChainVerification()
    expected_prev = _HASH_GENESIS

    with resolved.open("r", encoding="utf-8") as f:
        for lineno, line in enumerate(f, 1):
            stripped = line.strip()
            if not stripped:
                continue

            try:
                d = json.loads(stripped)
                entry = AuditEntry(**d)
            except (json.JSONDecodeError, TypeError, KeyError) as exc:
                result.total += 1
                result.broken_at = lineno
                result.error = f"Corrupt entry at line {lineno}: {exc}"
                return result

            result.total += 1

            if entry.prev_hash != expected_prev:
                result.broken_at = result.total
                result.error = (
                    f"Chain broken at entry {result.total}: "
                    f"expected prev_hash {expected_prev[:16]}…, "
                    f"got {entry.prev_hash[:16]}…"
                )
                return result

            recomputed = entry.compute_hash()
            if entry.entry_hash != recomputed:
                result.broken_at = result.total
                result.error = (
                    f"Hash mismatch at entry {result.total}: "
                    f"stored {entry.entry_hash[:16]}…, "
                    f"computed {recomputed[:16]}…"
                )
                return result

            result.valid += 1
            expected_prev = entry.entry_hash

    return result
