"""FilesMixin — file records, scan findings, associations, and timeline.

Extracted from core.py as part of the module architecture split.
All methods access ``self.conn``, ``self.get_issue()``, etc. via
Python's MRO when composed into ``FiligreeDB``.
"""

from __future__ import annotations

import hashlib
import json
import logging
import os
import sqlite3
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING, Any, ClassVar, get_args

from filigree.db_base import DBMixinProtocol, _escape_like, _escape_like_chars, _now_iso, _safe_json_loads
from filigree.models import FileRecord, ScanFinding
from filigree.types.core import AssocType, FindingStatus, Severity
from filigree.types.files import ScanIngestResult

if TYPE_CHECKING:
    from filigree.types.core import ObservationDict, PaginatedResult, ScanFindingDict
    from filigree.types.files import (
        CleanStaleResult,
        EnrichedFileItem,
        FileAssociation,
        FileDetail,
        FileHotspot,
        FindingsSummary,
        GlobalFindingsStats,
        IssueFileAssociation,
        ScanRunRecord,
        TimelineEntry,
    )

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Constants for file-domain validation
# ---------------------------------------------------------------------------

VALID_SEVERITIES: frozenset[str] = frozenset(get_args(Severity))
VALID_FINDING_STATUSES: frozenset[str] = frozenset(get_args(FindingStatus))
TERMINAL_FINDING_STATUSES: frozenset[str] = frozenset({"fixed", "false_positive"})
# Safety: these values are interpolated into SQL string literals below.
# Verify none contain characters that could break the SQL.
if not all(s.isalpha() or s.replace("_", "").isalpha() for s in TERMINAL_FINDING_STATUSES):
    raise ValueError(f"TERMINAL_FINDING_STATUSES values must be simple identifiers, got: {TERMINAL_FINDING_STATUSES}")
VALID_ASSOC_TYPES: frozenset[str] = frozenset(get_args(AssocType))


def _normalize_scan_path(path: str) -> str:
    """Normalize scanner-provided paths for stable file identity."""
    normalized = os.path.normpath(path.replace("\\", "/"))
    return "" if normalized == "." else normalized


class FilesMixin(DBMixinProtocol):
    """File records, scan findings, associations, and timeline.

    Inherits ``DBMixinProtocol`` for type-safe access to shared attributes.
    Actual implementations provided by ``FiligreeDB`` at composition time via MRO.
    """

    # SQL fragment for filtering open (non-terminal) findings — derived from TERMINAL_FINDING_STATUSES.
    _OPEN_FINDINGS_FILTER = "status NOT IN ({})".format(", ".join(f"'{s}'" for s in sorted(TERMINAL_FINDING_STATUSES)))
    _OPEN_FINDINGS_FILTER_SF = "sf.status NOT IN ({})".format(", ".join(f"'{s}'" for s in sorted(TERMINAL_FINDING_STATUSES)))

    # Severity ordering for SQL sort: lower number = more severe.
    _SEVERITY_ORDER_SQL = (
        "CASE severity WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 WHEN 'info' THEN 4 ELSE 5 END"
    )

    _VALID_FILE_SORTS = frozenset({"updated_at", "first_seen", "path", "language"})
    _VALID_FINDING_SORTS = frozenset({"updated_at", "severity"})

    # -- Build helpers -------------------------------------------------------

    @staticmethod
    def _parse_metadata(raw: str | None, context_id: str) -> dict[str, Any]:
        """Parse a JSON metadata column, returning ``{_metadata_error: True}`` on corrupt data."""
        return _safe_json_loads(raw, context_id)

    def _build_file_record(self, row: sqlite3.Row) -> FileRecord:
        """Build a FileRecord from a database row."""
        return FileRecord(
            id=row["id"],
            path=row["path"],
            language=row["language"] or "",
            file_type=row["file_type"] or "",
            first_seen=row["first_seen"],
            updated_at=row["updated_at"],
            metadata=self._parse_metadata(row["metadata"], f"file_record:{row['id']}"),
        )

    def _build_scan_finding(self, row: sqlite3.Row) -> ScanFinding:
        """Build a ScanFinding from a database row."""
        return ScanFinding(
            id=row["id"],
            file_id=row["file_id"],
            severity=row["severity"],
            status=row["status"],
            scan_source=row["scan_source"] or "",
            rule_id=row["rule_id"] or "",
            message=row["message"] or "",
            suggestion=row["suggestion"] or "",
            scan_run_id=row["scan_run_id"] or "",
            line_start=row["line_start"],
            line_end=row["line_end"],
            issue_id=row["issue_id"],
            seen_count=row["seen_count"] or 1,
            first_seen=row["first_seen"],
            updated_at=row["updated_at"],
            last_seen_at=row["last_seen_at"],
            metadata=self._parse_metadata(row["metadata"], f"scan_finding:{row['file_id']}"),
        )

    # -- File registration ---------------------------------------------------

    def register_file(
        self,
        path: str,
        *,
        language: str = "",
        file_type: str = "",
        metadata: dict[str, Any] | None = None,
    ) -> FileRecord:
        """Register a file or update it if already registered (upsert by path).

        Path is normalized via ``_normalize_scan_path`` to ensure consistent
        identity regardless of caller (MCP tool, scan ingestion, etc.).

        Returns the FileRecord (created or updated).
        """
        path = _normalize_scan_path(path)
        if not path:
            raise ValueError("File path cannot be empty after normalization")
        now = _now_iso()
        existing = self.conn.execute("SELECT * FROM file_records WHERE path = ?", (path,)).fetchone()

        if existing is not None:
            updates: list[str] = []
            params: list[Any] = []
            # Detect field changes and emit events
            changes: list[tuple[str, str, str]] = []  # (field, old, new)
            if language and language != (existing["language"] or ""):
                updates.append("language = ?")
                params.append(language)
                changes.append(("language", existing["language"] or "", language))
            if file_type and file_type != (existing["file_type"] or ""):
                updates.append("file_type = ?")
                params.append(file_type)
                changes.append(("file_type", existing["file_type"] or "", file_type))
            if metadata:
                old_meta_raw = existing["metadata"] or "{}"
                try:
                    old_meta_parsed = json.loads(old_meta_raw)
                except (json.JSONDecodeError, TypeError):
                    logger.warning(
                        "Corrupt metadata for file %s (id=%s), treating as empty",
                        existing["path"],
                        existing["id"],
                    )
                    old_meta_parsed = {}
                if old_meta_parsed != metadata:
                    new_meta = json.dumps(metadata)
                    updates.append("metadata = ?")
                    params.append(new_meta)
                    changes.append(("metadata", old_meta_raw, new_meta))
            if not updates:
                # No actual changes — return existing record without phantom update
                return self.get_file(existing["id"])
            updates.append("updated_at = ?")
            params.append(now)
            params.append(existing["id"])
            try:
                self.conn.execute(
                    f"UPDATE file_records SET {', '.join(updates)} WHERE id = ?",
                    params,
                )
                for field, old_val, new_val in changes:
                    self.conn.execute(
                        "INSERT INTO file_events "
                        "(file_id, event_type, field, old_value, new_value, created_at) "
                        "VALUES (?, 'file_metadata_update', ?, ?, ?, ?)",
                        (existing["id"], field, old_val, new_val, now),
                    )
                self.conn.commit()
            except Exception:
                self.conn.rollback()
                raise
            return self.get_file(existing["id"])

        file_id = self._generate_unique_id("file_records", "f")
        try:
            self.conn.execute(
                "INSERT INTO file_records (id, path, language, file_type, first_seen, updated_at, metadata) VALUES (?, ?, ?, ?, ?, ?, ?)",
                (file_id, path, language, file_type, now, now, json.dumps(metadata or {})),
            )
            self.conn.commit()
        except Exception:
            self.conn.rollback()
            raise
        return self.get_file(file_id)

    def get_file(self, file_id: str) -> FileRecord:
        """Get a file record by ID. Raises KeyError if not found."""
        row = self.conn.execute("SELECT * FROM file_records WHERE id = ?", (file_id,)).fetchone()
        if row is None:
            raise KeyError(file_id)
        return self._build_file_record(row)

    def get_file_by_path(self, path: str) -> FileRecord | None:
        """Get a file record by path. Returns None if not found."""
        path = _normalize_scan_path(path)
        row = self.conn.execute("SELECT * FROM file_records WHERE path = ?", (path,)).fetchone()
        if row is None:
            return None
        return self._build_file_record(row)

    def list_files(
        self,
        *,
        limit: int = 100,
        offset: int = 0,
        language: str | None = None,
        path_prefix: str | None = None,
        sort: str = "updated_at",
    ) -> list[FileRecord]:
        """List file records with optional filtering and sorting."""
        clauses: list[str] = []
        params: list[Any] = []

        if language is not None:
            clauses.append("language = ?")
            params.append(language)
        if path_prefix is not None:
            clauses.append("path LIKE ? ESCAPE '\\'")
            params.append(_escape_like(path_prefix))

        where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
        if sort not in self._VALID_FILE_SORTS:
            valid = ", ".join(sorted(self._VALID_FILE_SORTS))
            raise ValueError(f'Invalid sort field "{sort}". Must be one of: {valid}')
        order = "ASC" if sort == "path" else "DESC"

        rows = self.conn.execute(
            f"SELECT * FROM file_records{where} ORDER BY {sort} {order} LIMIT ? OFFSET ?",
            [*params, limit, offset],
        ).fetchall()
        return [self._build_file_record(r) for r in rows]

    def list_files_paginated(
        self,
        *,
        limit: int = 100,
        offset: int = 0,
        language: str | None = None,
        path_prefix: str | None = None,
        min_findings: int | None = None,
        has_severity: str | None = None,
        scan_source: str | None = None,
        sort: str = "updated_at",
        direction: str | None = None,
    ) -> PaginatedResult[EnrichedFileItem]:
        """List file records with pagination metadata.

        Returns ``{results, total, limit, offset, has_more}``.

        When *min_findings* is provided, only files with at least that many
        open findings are returned (uses a correlated subquery).

        When *has_severity* is provided (e.g. ``"critical"``), only files
        with at least one open finding of that severity are returned.
        """
        # Use "fr" alias throughout so the same WHERE works in both the COUNT
        # and enriched queries without string replacement.
        clauses: list[str] = []
        params: list[Any] = []

        if language is not None:
            clauses.append("fr.language = ?")
            params.append(language)
        if path_prefix is not None:
            escaped = _escape_like_chars(path_prefix)
            clauses.append("fr.path LIKE ? ESCAPE '\\'")
            params.append(f"%{escaped}%")
        if min_findings is not None and min_findings > 0:
            clauses.append(f"(SELECT COUNT(*) FROM scan_findings sf WHERE sf.file_id = fr.id AND {self._OPEN_FINDINGS_FILTER_SF}) >= ?")
            params.append(min_findings)
        if has_severity is not None:
            if has_severity not in VALID_SEVERITIES:
                valid = ", ".join(sorted(VALID_SEVERITIES))
                raise ValueError(f'Invalid severity filter "{has_severity}". Must be one of: {valid}')
            clauses.append(
                "(SELECT COUNT(*) FROM scan_findings sf"
                " WHERE sf.file_id = fr.id"
                f" AND {self._OPEN_FINDINGS_FILTER_SF}"
                " AND sf.severity = ?) > 0"
            )
            params.append(has_severity)
        if scan_source:
            clauses.append("EXISTS (SELECT 1 FROM scan_findings sf WHERE sf.file_id = fr.id AND sf.scan_source = ?)")
            params.append(scan_source)

        where = f" WHERE {' AND '.join(clauses)}" if clauses else ""

        total: int = self.conn.execute(
            f"SELECT COUNT(*) FROM file_records fr{where}",
            params,
        ).fetchone()[0]

        if sort not in self._VALID_FILE_SORTS:
            valid = ", ".join(sorted(self._VALID_FILE_SORTS))
            raise ValueError(f'Invalid sort field "{sort}". Must be one of: {valid}')
        default_order = "ASC" if sort == "path" else "DESC"
        order = direction.upper() if direction and direction.upper() in ("ASC", "DESC") else default_order

        _open = self._OPEN_FINDINGS_FILTER_SF
        _sev_cols = " ".join(
            f"(SELECT COUNT(*) FROM scan_findings sf WHERE sf.file_id = fr.id AND {_open} AND sf.severity='{s}') AS cnt_{s},"
            for s in ("critical", "high", "medium", "low", "info")
        )
        enriched_sql = (
            f"SELECT fr.*, "
            f"(SELECT COUNT(*) FROM scan_findings sf"
            f" WHERE sf.file_id = fr.id AND {_open}"
            f") AS open_findings, "
            f"(SELECT COUNT(*) FROM scan_findings sf"
            f" WHERE sf.file_id = fr.id"
            f") AS total_findings, "
            f"{_sev_cols} "
            f"(SELECT COUNT(*) FROM file_associations fa"
            f" WHERE fa.file_id = fr.id"
            f") AS associations_count, "
            f"(SELECT COUNT(*) FROM observations o"
            f" WHERE o.file_id = fr.id AND o.expires_at > ?"
            f") AS observation_count"
            f" FROM file_records fr{where}"
            f" ORDER BY {sort} {order}"
            f" LIMIT ? OFFSET ?"
        )
        now_iso = _now_iso()
        rows = self.conn.execute(enriched_sql, [now_iso, *params, limit, offset]).fetchall()

        results: list[EnrichedFileItem] = []
        for r in rows:
            d: dict[str, Any] = dict(self._build_file_record(r).to_dict())
            d["summary"] = {
                "total_findings": r["total_findings"],
                "open_findings": r["open_findings"],
                "critical": r["cnt_critical"],
                "high": r["cnt_high"],
                "medium": r["cnt_medium"],
                "low": r["cnt_low"],
                "info": r["cnt_info"],
            }
            d["associations_count"] = r["associations_count"]
            d["observation_count"] = r["observation_count"]
            results.append(d)  # type: ignore[arg-type]  # dict built incrementally
        return {
            "results": results,
            "total": total,
            "limit": limit,
            "offset": offset,
            "has_more": (offset + limit) < total,
        }

    # -- Scan ingestion ------------------------------------------------------

    @staticmethod
    def _require_str(f: dict[str, Any], key: str, idx: int, *, non_empty: bool = False) -> str:
        """Validate that finding[key] exists and is a string. Raises ValueError on failure."""
        if key not in f:
            raise ValueError(f"findings[{idx}] is missing required key '{key}'")
        val = f[key]
        if not isinstance(val, str):
            raise ValueError(f"findings[{idx}] {key} must be a string, got {type(val).__name__}")
        if non_empty and not val.strip():
            raise ValueError(f"findings[{idx}] {key} must be a non-empty string")
        return val

    @staticmethod
    def _validate_scan_findings(findings: list[dict[str, Any]], scan_source: str) -> list[str]:
        """Validate and normalize all findings upfront before any writes.

        Mutates findings in-place (normalizes paths and severities).
        Returns a list of warning messages for unknown severities.
        """
        _req = FilesMixin._require_str
        warnings: list[str] = []
        for i, f in enumerate(findings):
            if not isinstance(f, dict):
                raise ValueError(f"findings[{i}] must be a dict, got {type(f).__name__}")
            _req(f, "path", i, non_empty=True)
            f["path"] = _normalize_scan_path(f["path"])
            _req(f, "rule_id", i, non_empty=True)
            _req(f, "message", i, non_empty=True)
            severity = f.get("severity", "info")
            if not isinstance(severity, str):
                raise ValueError(f"findings[{i}] severity must be a string, got {type(severity).__name__}")
            for ln_field in ("line_start", "line_end"):
                ln_val = f.get(ln_field)
                if ln_val is not None and not isinstance(ln_val, int):
                    raise ValueError(f"findings[{i}] {ln_field} must be an integer or null, got {type(ln_val).__name__}")
            suggestion = f.get("suggestion")
            if suggestion is not None and not isinstance(suggestion, str):
                raise ValueError(f"findings[{i}] suggestion must be a string, got {type(suggestion).__name__}")
            # Normalize severity
            normalized = severity.strip().lower()
            if normalized in VALID_SEVERITIES:
                f["severity"] = normalized
            else:
                path = f["path"]
                rule_id = f.get("rule_id", "")
                warn_msg = f"Unknown severity {severity!r} for finding at {path} (rule_id={rule_id!r}), mapped to 'info'"
                warnings.append(warn_msg)
                logger.warning(
                    "Severity fallback: %r → 'info' for %s (rule_id=%s, scan_source=%s)",
                    severity,
                    path,
                    rule_id,
                    scan_source,
                )
                f["severity"] = "info"
        return warnings

    def _upsert_file_record(self, *, path: str, language: str, now: str, stats: ScanIngestResult) -> str:
        """Create or update a file record, returning its id."""
        existing_file = self.conn.execute("SELECT id FROM file_records WHERE path = ?", (path,)).fetchone()
        if existing_file is not None:
            file_id: str = existing_file["id"]
            update_parts = ["updated_at = ?"]
            update_params: list[Any] = [now]
            if language:
                update_parts.append("language = ?")
                update_params.append(language)
            update_params.append(file_id)
            self.conn.execute(
                f"UPDATE file_records SET {', '.join(update_parts)} WHERE id = ?",
                update_params,
            )
            stats["files_updated"] += 1
        else:
            file_id = self._generate_unique_id("file_records", "f")
            self.conn.execute(
                "INSERT INTO file_records (id, path, language, first_seen, updated_at) VALUES (?, ?, ?, ?, ?)",
                (file_id, path, language, now, now),
            )
            stats["files_created"] += 1
        return file_id

    def _upsert_finding(
        self,
        *,
        f: dict[str, Any],
        file_id: str,
        scan_source: str,
        scan_run_id: str,
        now: str,
        stats: ScanIngestResult,
        seen_finding_ids: dict[str, list[str]],
        create_observations: bool,
    ) -> None:
        """Upsert a single finding (dedup on file_id + scan_source + rule_id + line_start)."""
        severity = f.get("severity", "info")
        path = f["path"]
        rule_id = f.get("rule_id", "")
        line_start = f.get("line_start")
        dedup_line = line_start if line_start is not None else -1

        suggestion = f.get("suggestion", "")
        if len(suggestion) > 10_000:
            logger.warning(
                "Suggestion truncated for %s (rule_id=%s): %d chars → 10000",
                path,
                rule_id,
                len(suggestion),
            )
            suggestion = suggestion[:10_000] + "\n[truncated]"

        existing_finding = self.conn.execute(
            "SELECT id, seen_count, scan_run_id, issue_id FROM scan_findings "
            "WHERE file_id = ? AND scan_source = ? AND rule_id = ? AND coalesce(line_start, -1) = ?",
            (file_id, scan_source, rule_id, dedup_line),
        ).fetchone()

        if existing_finding is not None:
            self._update_existing_finding(
                existing_finding=existing_finding,
                f=f,
                severity=severity,
                suggestion=suggestion,
                scan_run_id=scan_run_id,
                now=now,
                stats=stats,
            )
            seen_finding_ids.setdefault(file_id, []).append(existing_finding["id"])
        else:
            finding_id = self._generate_unique_id("scan_findings", "sf")
            self.conn.execute(
                "INSERT INTO scan_findings "
                "(id, file_id, scan_source, rule_id, severity, status, message, "
                "suggestion, scan_run_id, "
                "line_start, line_end, first_seen, updated_at, last_seen_at, metadata) "
                "VALUES (?, ?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?)",
                (
                    finding_id,
                    file_id,
                    scan_source,
                    rule_id,
                    severity,
                    f.get("message", ""),
                    suggestion,
                    scan_run_id,
                    line_start,
                    f.get("line_end"),
                    now,
                    now,
                    now,
                    json.dumps(f.get("metadata") or {}),
                ),
            )
            stats["findings_created"] += 1
            stats["new_finding_ids"].append(finding_id)
            seen_finding_ids.setdefault(file_id, []).append(finding_id)
            if create_observations:
                first_line = f.get("message", "").strip().splitlines()[0] if f.get("message", "").strip() else "Scanner finding"
                obs_summary = f"[{scan_source}] {path}:{f.get('line_start', '?')} -- {first_line}"
                obs_detail = f.get("message", "")
                if f.get("suggestion"):
                    obs_detail += f"\n\nSuggested fix:\n{f['suggestion']}"
                try:
                    self.create_observation(
                        obs_summary,
                        detail=obs_detail,
                        file_path=path,
                        line=f.get("line_start"),
                        priority=self._SEVERITY_TO_PRIORITY.get(f.get("severity", "info"), 3),
                        actor=f"scanner:{scan_source}",
                        auto_commit=False,
                    )
                    stats["observations_created"] += 1
                except (sqlite3.Error, ValueError) as obs_exc:
                    logger.warning(
                        "Failed to create observation for finding %s in %s: %s",
                        finding_id,
                        path,
                        obs_exc,
                    )
                    stats["observations_failed"] += 1
                    msg = f"Observation failed for {finding_id}: {obs_exc}"
                    if msg not in stats["warnings"]:
                        stats["warnings"].append(msg)

    def _update_existing_finding(
        self,
        *,
        existing_finding: Any,
        f: dict[str, Any],
        severity: str,
        suggestion: str,
        scan_run_id: str,
        now: str,
        stats: ScanIngestResult,
    ) -> None:
        """Update an already-existing finding with new scan data."""
        existing_run_id = existing_finding["scan_run_id"] or ""
        run_id_update = existing_run_id
        if scan_run_id and not existing_run_id:  # first-attribution-wins
            run_id_update = scan_run_id

        self.conn.execute(
            "UPDATE scan_findings SET message = ?, severity = ?, line_end = ?, "
            "suggestion = ?, scan_run_id = ?, metadata = ?, "
            "seen_count = seen_count + 1, updated_at = ?, last_seen_at = ?, "
            "status = CASE WHEN status IN ('fixed', 'unseen_in_latest') THEN 'open' ELSE status END "
            "WHERE id = ?",
            (
                f.get("message", ""),
                severity,
                f.get("line_end"),
                suggestion,
                run_id_update,
                json.dumps(f.get("metadata") or {}),
                now,
                now,
                existing_finding["id"],
            ),
        )
        stats["findings_updated"] += 1

    @staticmethod
    def _mark_unseen_findings(
        conn: Any,
        *,
        scan_source: str,
        seen_finding_ids: dict[str, list[str]],
        now: str,
    ) -> None:
        """Mark findings not in current batch as unseen_in_latest."""
        terminal = tuple(TERMINAL_FINDING_STATUSES)
        terminal_ph = ",".join("?" * len(terminal))
        for fid, fids in seen_finding_ids.items():
            placeholders = ",".join("?" * len(fids))
            conn.execute(
                f"UPDATE scan_findings SET status = 'unseen_in_latest', updated_at = ? "
                f"WHERE file_id = ? AND scan_source = ? "
                f"AND status NOT IN ({terminal_ph}) "
                f"AND id NOT IN ({placeholders})",
                [now, fid, scan_source, *terminal, *fids],
            )

    def process_scan_results(
        self,
        *,
        scan_source: str,
        findings: list[dict[str, Any]],
        scan_run_id: str = "",
        mark_unseen: bool = False,
        create_observations: bool = False,
        complete_scan_run: bool = True,
    ) -> ScanIngestResult:
        """Ingest scan results: create/update file records and findings.

        Each finding dict must have at minimum: path, rule_id, message.
        Optional: severity (default: 'info'), language, line_start, line_end, suggestion, metadata.

        When *mark_unseen* is ``True``, findings in the same (file, scan_source)
        that are NOT in this batch are set to ``unseen_in_latest`` status.
        Only findings with a non-terminal status are affected (``fixed`` and
        ``false_positive`` are left alone).

        When *create_observations* is ``True``, each new finding is promoted to
        an observation for triage tracking.

        When *complete_scan_run* is ``False`` and a *scan_run_id* is provided,
        the scan run status is NOT transitioned to ``completed``.  Use this for
        batch scans where multiple callers share one scan_run_id — the
        orchestrator should send a final call with ``complete_scan_run=True``
        after all workers finish.

        Returns summary stats including ``new_finding_ids``.
        """
        warnings = self._validate_scan_findings(findings, scan_source)

        now = _now_iso()
        stats = ScanIngestResult(
            files_created=0,
            files_updated=0,
            findings_created=0,
            findings_updated=0,
            new_finding_ids=[],
            observations_created=0,
            observations_failed=0,
            warnings=warnings,
        )

        seen_finding_ids: dict[str, list[str]] = {}

        try:
            for f in findings:
                file_id = self._upsert_file_record(
                    path=f["path"],
                    language=f.get("language", ""),
                    now=now,
                    stats=stats,
                )
                self._upsert_finding(
                    f=f,
                    file_id=file_id,
                    scan_source=scan_source,
                    scan_run_id=scan_run_id,
                    now=now,
                    stats=stats,
                    seen_finding_ids=seen_finding_ids,
                    create_observations=create_observations,
                )

            if mark_unseen:
                self._mark_unseen_findings(
                    self.conn,
                    scan_source=scan_source,
                    seen_finding_ids=seen_finding_ids,
                    now=now,
                )

            self.conn.commit()
        except Exception:
            if self.conn.in_transaction:
                self.conn.rollback()
            raise

        if scan_run_id and complete_scan_run:
            try:
                self.update_scan_run_status(
                    scan_run_id,
                    "completed",
                    findings_count=stats["findings_created"] + stats["findings_updated"],
                )
            except (KeyError, ValueError, sqlite3.Error) as exc:
                # Check if the scan run is already in a terminal state by
                # querying directly, rather than relying on error message text.
                try:
                    row = self.conn.execute("SELECT status FROM scan_runs WHERE id = ?", (scan_run_id,)).fetchone()
                    is_terminal = row is not None and row["status"] in ("completed", "failed")
                except sqlite3.Error:
                    is_terminal = False
                if is_terminal:
                    logger.info(
                        "Scan run %r already in terminal state, skipping completion: %s",
                        scan_run_id,
                        exc,
                    )
                else:
                    logger.warning(
                        "Failed to mark scan run %r as completed (findings were ingested successfully): %s",
                        scan_run_id,
                        exc,
                    )
                stats["warnings"].append(f"Scan run {scan_run_id} status not updated to 'completed': {exc}")

        return stats

    def get_scan_runs(self, *, limit: int = 10) -> list[ScanRunRecord]:
        """Query scan run history from scan_findings grouped by scan_run_id.

        Returns a list of scan run summaries, ordered by most recent activity.
        Findings with empty scan_run_id are excluded.
        """
        rows = self.conn.execute(
            "SELECT scan_run_id, scan_source, "
            "MIN(first_seen) AS started_at, "
            "MAX(updated_at) AS completed_at, "
            "COUNT(*) AS total_findings, "
            "COUNT(DISTINCT file_id) AS files_scanned "
            "FROM scan_findings "
            "WHERE scan_run_id != '' "
            "GROUP BY scan_run_id, scan_source "
            "ORDER BY MAX(updated_at) DESC "
            "LIMIT ?",
            (limit,),
        ).fetchall()
        return [
            {
                "scan_run_id": row["scan_run_id"],
                "scan_source": row["scan_source"],
                "started_at": row["started_at"],
                "completed_at": row["completed_at"],
                "total_findings": row["total_findings"],
                "files_scanned": row["files_scanned"],
            }
            for row in rows
        ]

    def update_finding(
        self,
        finding_id: str,
        *,
        file_id: str | None = None,
        status: FindingStatus | None = None,
        issue_id: str | None = None,
        dismiss_reason: str | None = None,
    ) -> ScanFindingDict:
        """Update finding status and/or linked issue.

        *file_id* is optional — when omitted, it is looked up from the
        finding record.  This allows callers that only have a finding ID
        (e.g. MCP tool handlers) to update findings without knowing
        which file they belong to.
        """
        if file_id is not None:
            row = self.conn.execute(
                "SELECT id, file_id FROM scan_findings WHERE id = ? AND file_id = ?",
                (finding_id, file_id),
            ).fetchone()
        else:
            row = self.conn.execute(
                "SELECT id, file_id FROM scan_findings WHERE id = ?",
                (finding_id,),
            ).fetchone()
        if row is None:
            msg = f"Finding not found: {finding_id}"
            raise KeyError(msg)
        file_id = row["file_id"]

        updates: list[str] = []
        params: list[Any] = []

        if status is not None:
            if status not in VALID_FINDING_STATUSES:
                valid = ", ".join(sorted(VALID_FINDING_STATUSES))
                msg = f'Invalid finding status "{status}". Must be one of: {valid}'
                raise ValueError(msg)
            updates.append("status = ?")
            params.append(status)

        normalized_issue_id: str | None = None
        if issue_id is not None:
            if not isinstance(issue_id, str):
                msg = "issue_id must be a string when provided"
                raise ValueError(msg)
            normalized_issue_id = issue_id.strip()
            if not normalized_issue_id:
                msg = "issue_id cannot be empty when provided"
                raise ValueError(msg)
            issue = self.conn.execute("SELECT id FROM issues WHERE id = ?", (normalized_issue_id,)).fetchone()
            if issue is None:
                msg = f'Issue not found: "{normalized_issue_id}". Verify the issue exists before linking.'
                raise ValueError(msg)
            updates.append("issue_id = ?")
            params.append(normalized_issue_id)

        if dismiss_reason is not None:
            if status is None:
                msg = "dismiss_reason requires status to also be provided"
                raise ValueError(msg)
            old_meta_raw = self.conn.execute("SELECT metadata FROM scan_findings WHERE id = ?", (finding_id,)).fetchone()
            try:
                old_meta = json.loads(old_meta_raw["metadata"]) if old_meta_raw and old_meta_raw["metadata"] else {}
            except (json.JSONDecodeError, TypeError):
                logger.warning("Corrupt metadata JSON in finding %s, resetting to empty", finding_id)
                old_meta = {}
            old_meta["dismiss_reason"] = dismiss_reason
            updates.append("metadata = ?")
            params.append(json.dumps(old_meta))

        if not updates:
            msg = "At least one of status or issue_id must be provided"
            raise ValueError(msg)

        now = _now_iso()
        updates.append("updated_at = ?")
        params.append(now)
        params.extend([finding_id, file_id])

        try:
            self.conn.execute(
                f"UPDATE scan_findings SET {', '.join(updates)} WHERE id = ? AND file_id = ?",
                params,
            )

            if normalized_issue_id:
                self.conn.execute(
                    "INSERT OR IGNORE INTO file_associations (file_id, issue_id, assoc_type, created_at) VALUES (?, ?, 'bug_in', ?)",
                    (file_id, normalized_issue_id, now),
                )

            self.conn.commit()
        except Exception:
            self.conn.rollback()
            raise
        updated = self.conn.execute("SELECT * FROM scan_findings WHERE id = ?", (finding_id,)).fetchone()
        if updated is None:
            msg = f"Finding not found after update: {finding_id}"
            raise KeyError(msg)
        return self._build_scan_finding(updated).to_dict()

    def clean_stale_findings(
        self,
        *,
        days: int = 30,
        scan_source: str | None = None,
        actor: str = "",
    ) -> CleanStaleResult:
        """Move ``unseen_in_latest`` findings older than *days* to ``fixed``.

        Only affects findings whose ``last_seen_at`` (or ``updated_at`` as
        fallback) is older than the cutoff.  Returns stats about what changed.
        """
        cutoff = (datetime.now(UTC) - timedelta(days=days)).isoformat()

        clauses = [
            "status = 'unseen_in_latest'",
            "coalesce(last_seen_at, updated_at) < ?",
        ]
        params: list[Any] = [cutoff]

        if scan_source is not None:
            clauses.append("scan_source = ?")
            params.append(scan_source)

        now = _now_iso()
        where = " AND ".join(clauses)
        try:
            cursor = self.conn.execute(
                f"UPDATE scan_findings SET status = 'fixed', updated_at = ? WHERE {where}",
                [now, *params],
            )
            self.conn.commit()
        except Exception:
            self.conn.rollback()
            raise
        return {"findings_fixed": cursor.rowcount}

    @staticmethod
    def _severity_bucket_sql(open_filter: str) -> str:
        """Build ``SUM(CASE WHEN severity=... AND <open_filter> ...)`` columns for all severities."""
        parts = " ".join(
            f"SUM(CASE WHEN severity='{s}' AND {open_filter} THEN 1 ELSE 0 END) AS {s}," for s in ("critical", "high", "medium", "low")
        )
        return f"{parts} SUM(CASE WHEN severity='info' AND {open_filter} THEN 1 ELSE 0 END) AS info"

    def _findings_where(
        self,
        file_id: str,
        *,
        severity: Severity | None = None,
        status: FindingStatus | None = None,
        sort: str = "updated_at",
    ) -> tuple[str, list[Any], str]:
        """Build WHERE clause, params, and ORDER clause for findings queries.

        Returns ``(where, params, order_clause)`` — shared by
        ``get_findings`` and ``get_findings_paginated``.
        """
        if sort not in self._VALID_FINDING_SORTS:
            valid = ", ".join(sorted(self._VALID_FINDING_SORTS))
            raise ValueError(f'Invalid sort field "{sort}". Must be one of: {valid}')

        clauses = ["file_id = ?"]
        params: list[Any] = [file_id]

        if severity is not None:
            clauses.append("severity = ?")
            params.append(severity)
        if status is not None:
            clauses.append("status = ?")
            params.append(status)

        where = " AND ".join(clauses)
        order_clause = f"{self._SEVERITY_ORDER_SQL} ASC, updated_at DESC" if sort == "severity" else "updated_at DESC"
        return where, params, order_clause

    def get_findings(
        self,
        file_id: str,
        *,
        severity: Severity | None = None,
        status: FindingStatus | None = None,
        sort: str = "updated_at",
        limit: int = 100,
        offset: int = 0,
    ) -> list[ScanFinding]:
        """Get scan findings for a file with optional filters."""
        where, params, order_clause = self._findings_where(file_id, severity=severity, status=status, sort=sort)
        rows = self.conn.execute(
            f"SELECT * FROM scan_findings WHERE {where} ORDER BY {order_clause} LIMIT ? OFFSET ?",
            [*params, limit, offset],
        ).fetchall()
        return [self._build_scan_finding(r) for r in rows]

    def get_findings_paginated(
        self,
        file_id: str,
        *,
        severity: Severity | None = None,
        status: FindingStatus | None = None,
        sort: str = "updated_at",
        limit: int = 100,
        offset: int = 0,
    ) -> PaginatedResult[ScanFindingDict]:
        """Get scan findings with pagination metadata.

        Returns ``{results, total, limit, offset, has_more}``.
        """
        where, params, _order = self._findings_where(file_id, severity=severity, status=status, sort=sort)

        total: int = self.conn.execute(
            f"SELECT COUNT(*) FROM scan_findings WHERE {where}",
            params,
        ).fetchone()[0]

        findings = self.get_findings(file_id, severity=severity, status=status, sort=sort, limit=limit, offset=offset)
        results: list[ScanFindingDict] = [f.to_dict() for f in findings]
        return {
            "results": results,
            "total": total,
            "limit": limit,
            "offset": offset,
            "has_more": (offset + limit) < total,
        }

    # ------------------------------------------------------------------
    # Finding triage methods
    # ------------------------------------------------------------------

    _SEVERITY_TO_PRIORITY: ClassVar[dict[str, int]] = {
        "critical": 0,
        "high": 1,
        "medium": 2,
        "low": 3,
        "info": 3,
    }

    def get_finding(self, finding_id: str) -> ScanFindingDict:
        """Get a single finding by ID.  Raises *KeyError* if not found."""
        row = self.conn.execute(
            "SELECT * FROM scan_findings WHERE id = ?",
            (finding_id,),
        ).fetchone()
        if row is None:
            msg = f"Finding not found: {finding_id}"
            raise KeyError(msg)
        return self._build_scan_finding(row).to_dict()

    def list_findings_global(
        self,
        *,
        severity: str | None = None,
        status: str | None = None,
        scan_source: str | None = None,
        scan_run_id: str | None = None,
        file_id: str | None = None,
        issue_id: str | None = None,
        limit: int = 100,
        offset: int = 0,
    ) -> dict[str, Any]:
        """Project-wide finding query with optional filters.

        Returns ``{"findings": [...], "total": N, "limit": ..., "offset": ...}``.
        """
        if severity is not None and severity not in VALID_SEVERITIES:
            valid = ", ".join(sorted(VALID_SEVERITIES))
            raise ValueError(f'Invalid severity filter "{severity}". Must be one of: {valid}')
        if status is not None and status not in VALID_FINDING_STATUSES:
            valid = ", ".join(sorted(VALID_FINDING_STATUSES))
            raise ValueError(f'Invalid status filter "{status}". Must be one of: {valid}')
        # All filters are simple equality on identically-named columns.
        filters = {
            "severity": severity,
            "status": status,
            "scan_source": scan_source,
            "scan_run_id": scan_run_id,
            "file_id": file_id,
            "issue_id": issue_id,
        }
        clauses: list[str] = []
        params: list[Any] = []
        for col, val in filters.items():
            if val is not None:
                clauses.append(f"{col} = ?")
                params.append(val)

        where = " AND ".join(clauses) if clauses else "1=1"

        total: int = self.conn.execute(
            f"SELECT COUNT(*) FROM scan_findings WHERE {where}",
            params,
        ).fetchone()[0]

        rows = self.conn.execute(
            f"SELECT * FROM scan_findings WHERE {where} ORDER BY updated_at DESC LIMIT ? OFFSET ?",
            [*params, limit, offset],
        ).fetchall()

        findings = [self._build_scan_finding(r).to_dict() for r in rows]
        return {"findings": findings, "total": total, "limit": limit, "offset": offset}

    def promote_finding_to_observation(
        self,
        finding_id: str,
        *,
        priority: int | None = None,
        actor: str = "",
    ) -> ObservationDict:
        """Promote a finding to an observation.

        Creates an observation note from the finding's data.  Priority
        is inferred from severity if not provided explicitly.
        """
        finding = self.get_finding(finding_id)
        if priority is None:
            priority = self._SEVERITY_TO_PRIORITY.get(finding["severity"], 3)

        file_path = self._file_path_for_finding(finding["file_id"])
        if not file_path:
            logger.warning(
                "Promoting finding %s without file context (file_id=%s not found)",
                finding_id,
                finding["file_id"],
            )

        summary = f"[{finding['scan_source']}] {finding['message']}"
        detail = f"rule: {finding['rule_id']}, severity: {finding['severity']}"
        if not file_path:
            detail += f"\n\nNote: file record for file_id={finding['file_id']} was not found."
        return self.create_observation(
            summary,
            detail=detail,
            file_path=file_path,
            line=finding.get("line_start"),
            priority=priority,
            actor=actor,
        )

    def _file_path_for_finding(self, file_id: str) -> str:
        """Look up the file path for a file_id, returning empty string if not found."""
        row = self.conn.execute("SELECT path FROM file_records WHERE id = ?", (file_id,)).fetchone()
        if row is None:
            logger.warning("File record not found for file_id=%s during finding promotion", file_id)
            return ""
        return str(row["path"])

    def get_file_findings_summary(self, file_id: str) -> FindingsSummary:
        """Get a severity-bucketed summary of findings for a file."""
        _open = self._OPEN_FINDINGS_FILTER
        _sev = self._severity_bucket_sql(_open)
        row = self.conn.execute(
            f"SELECT COUNT(*) AS total_findings, "
            f"SUM(CASE WHEN {_open} THEN 1 ELSE 0 END) AS open_findings, "
            f"{_sev} "
            f"FROM scan_findings WHERE file_id = ?",
            (file_id,),
        ).fetchone()
        return {
            "total_findings": row["total_findings"],
            "open_findings": row["open_findings"] or 0,
            "critical": row["critical"] or 0,
            "high": row["high"] or 0,
            "medium": row["medium"] or 0,
            "low": row["low"] or 0,
            "info": row["info"] or 0,
        }

    def get_global_findings_stats(self) -> GlobalFindingsStats:
        """Get project-wide severity-bucketed findings stats."""
        _open = self._OPEN_FINDINGS_FILTER
        _sev = self._severity_bucket_sql(_open)
        row = self.conn.execute(
            f"SELECT COUNT(*) AS total_findings, "
            f"SUM(CASE WHEN {_open} THEN 1 ELSE 0 END) AS open_findings, "
            f"COUNT(DISTINCT CASE WHEN {_open} THEN file_id END) AS files_with_findings, "
            f"{_sev} "
            f"FROM scan_findings",
        ).fetchone()
        return {
            "total_findings": row["total_findings"],
            "open_findings": row["open_findings"] or 0,
            "files_with_findings": row["files_with_findings"],
            "critical": row["critical"] or 0,
            "high": row["high"] or 0,
            "medium": row["medium"] or 0,
            "low": row["low"] or 0,
            "info": row["info"] or 0,
        }

    def get_file_detail(self, file_id: str) -> FileDetail:
        """Get a structured file detail response with separated data layers."""
        f = self.get_file(file_id)
        associations = self.get_file_associations(file_id)
        recent = self.get_findings(file_id, limit=10)
        summary = self.get_file_findings_summary(file_id)
        # Observation count (no sweep, but filter expired — read-only path).
        # Guarded for pre-v7 DBs where observations table may not exist.
        has_obs_table = self.conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='observations'").fetchone()
        if has_obs_table:
            obs_count = self.conn.execute(
                "SELECT COUNT(*) FROM observations WHERE file_id = ? AND expires_at > ?",
                (file_id, _now_iso()),
            ).fetchone()[0]
        else:
            obs_count = 0
        return {
            "file": f.to_dict(),
            "associations": associations,
            "recent_findings": [r.to_dict() for r in recent],
            "summary": summary,
            "observation_count": obs_count,
        }

    # -- File associations ---------------------------------------------------

    def add_file_association(
        self,
        file_id: str,
        issue_id: str,
        assoc_type: AssocType,
    ) -> None:
        """Link a file to an issue. Idempotent (duplicates ignored)."""
        if assoc_type not in VALID_ASSOC_TYPES:
            msg = f'Invalid assoc_type "{assoc_type}". Must be one of: {", ".join(sorted(VALID_ASSOC_TYPES))}'
            raise ValueError(msg)
        # Validate issue exists before creating the association
        row = self.conn.execute("SELECT 1 FROM issues WHERE id = ?", (issue_id,)).fetchone()
        if row is None:
            msg = f'Issue not found: "{issue_id}". Verify the issue exists before creating an association.'
            raise ValueError(msg)
        now = _now_iso()
        try:
            self.conn.execute(
                "INSERT OR IGNORE INTO file_associations (file_id, issue_id, assoc_type, created_at) VALUES (?, ?, ?, ?)",
                (file_id, issue_id, assoc_type, now),
            )
            self.conn.commit()
        except Exception:
            self.conn.rollback()
            raise

    def get_file_associations(self, file_id: str) -> list[FileAssociation]:
        """Get all issue associations for a file."""
        rows = self.conn.execute(
            "SELECT fa.*, i.title as issue_title, i.status as issue_status "
            "FROM file_associations fa "
            "LEFT JOIN issues i ON fa.issue_id = i.id "
            "WHERE fa.file_id = ? ORDER BY fa.created_at DESC",
            (file_id,),
        ).fetchall()
        return [
            {
                "id": r["id"],
                "file_id": r["file_id"],
                "issue_id": r["issue_id"],
                "assoc_type": r["assoc_type"],
                "created_at": r["created_at"],
                "issue_title": r["issue_title"],
                "issue_status": r["issue_status"],
            }
            for r in rows
        ]

    def get_issue_files(self, issue_id: str) -> list[IssueFileAssociation]:
        """Get all files associated with an issue (issue -> files direction)."""
        rows = self.conn.execute(
            "SELECT fa.*, fr.path as file_path, fr.language as file_language "
            "FROM file_associations fa "
            "JOIN file_records fr ON fa.file_id = fr.id "
            "WHERE fa.issue_id = ? ORDER BY fa.created_at DESC",
            (issue_id,),
        ).fetchall()
        return [
            {
                "id": r["id"],
                "file_id": r["file_id"],
                "issue_id": r["issue_id"],
                "assoc_type": r["assoc_type"],
                "created_at": r["created_at"],
                "file_path": r["file_path"],
                "file_language": r["file_language"],
            }
            for r in rows
        ]

    def get_issue_findings(self, issue_id: str) -> list[ScanFinding]:
        """Get all scan findings related to an issue."""
        rows = self.conn.execute(
            "SELECT sf.* FROM scan_findings sf WHERE sf.issue_id = ? "
            "UNION "
            "SELECT sf.* FROM scan_findings sf "
            "JOIN file_associations fa ON sf.file_id = fa.file_id "
            "WHERE fa.issue_id = ?",
            (issue_id, issue_id),
        ).fetchall()
        return [self._build_scan_finding(r) for r in rows]

    def get_file_hotspots(self, *, limit: int = 10) -> list[FileHotspot]:
        """Get files ranked by weighted finding severity score."""
        rows = self.conn.execute(
            f"""
            SELECT
                fr.id, fr.path, fr.language,
                SUM(CASE WHEN sf.severity = 'critical' THEN 1 ELSE 0 END) as cnt_critical,
                SUM(CASE WHEN sf.severity = 'high' THEN 1 ELSE 0 END) as cnt_high,
                SUM(CASE WHEN sf.severity = 'medium' THEN 1 ELSE 0 END) as cnt_medium,
                SUM(CASE WHEN sf.severity = 'low' THEN 1 ELSE 0 END) as cnt_low,
                SUM(CASE WHEN sf.severity = 'info' THEN 1 ELSE 0 END) as cnt_info,
                SUM(
                    CASE sf.severity
                        WHEN 'critical' THEN 10
                        WHEN 'high' THEN 5
                        WHEN 'medium' THEN 2
                        WHEN 'low' THEN 1
                        ELSE 0
                    END
                ) as score
            FROM file_records fr
            JOIN scan_findings sf ON sf.file_id = fr.id
            WHERE {self._OPEN_FINDINGS_FILTER_SF}
            GROUP BY fr.id
            HAVING score > 0
            ORDER BY score DESC
            LIMIT ?
            """,
            (limit,),
        ).fetchall()

        return [
            {
                "file": {"id": r["id"], "path": r["path"], "language": r["language"]},
                "score": r["score"],
                "findings_breakdown": {
                    "critical": r["cnt_critical"],
                    "high": r["cnt_high"],
                    "medium": r["cnt_medium"],
                    "low": r["cnt_low"],
                    "info": r["cnt_info"],
                },
            }
            for r in rows
        ]

    # -- File Timeline -------------------------------------------------------

    _TIMELINE_CTE = """
    WITH timeline AS (
        SELECT 'finding_created' AS type, first_seen AS timestamp,
               id AS source_id,
               json_object('scan_source', scan_source, 'rule_id', rule_id,
                           'severity', severity, 'message', message) AS data_json
        FROM scan_findings WHERE file_id = ?
        UNION ALL
        SELECT 'finding_updated' AS type, updated_at AS timestamp,
               id AS source_id,
               json_object('scan_source', scan_source, 'rule_id', rule_id,
                           'severity', severity, 'status', status) AS data_json
        FROM scan_findings WHERE file_id = ? AND updated_at != first_seen
        UNION ALL
        SELECT 'association_created' AS type, fa.created_at AS timestamp,
               CAST(fa.id AS TEXT) AS source_id,
               json_object('issue_id', fa.issue_id,
                           'issue_title', COALESCE(i.title, ''),
                           'assoc_type', fa.assoc_type) AS data_json
        FROM file_associations fa
        LEFT JOIN issues i ON fa.issue_id = i.id
        WHERE fa.file_id = ?
        UNION ALL
        SELECT 'file_metadata_update' AS type, created_at AS timestamp,
               CAST(id AS TEXT) AS source_id,
               json_object('field', field, 'old_value', old_value,
                           'new_value', new_value) AS data_json
        FROM file_events WHERE file_id = ?
    )
    """

    _TIMELINE_TYPE_FILTERS: ClassVar[dict[str, str]] = {
        "finding": "WHERE type IN ('finding_created', 'finding_updated')",
        "association": "WHERE type = 'association_created'",
        "file_metadata_update": "WHERE type = 'file_metadata_update'",
    }

    def get_file_timeline(
        self,
        file_id: str,
        *,
        limit: int = 50,
        offset: int = 0,
        event_type: str | None = None,
    ) -> PaginatedResult[TimelineEntry]:
        """Build a merged timeline of events for a file.

        Assembles entries from scan findings and file associations, sorted
        newest-first.  Each entry carries a deterministic ``id`` derived from
        ``sha256(type + timestamp + source_id)[:12]`` so clients can
        cache/deduplicate without server coordination.

        Pagination is pushed to SQL via UNION ALL + ORDER BY + LIMIT/OFFSET
        so only the requested page is materialized in Python.
        """
        self.get_file(file_id)  # validate existence

        if event_type is not None and event_type not in self._TIMELINE_TYPE_FILTERS:
            valid_types = tuple(self._TIMELINE_TYPE_FILTERS)
            raise ValueError(f'Invalid event_type "{event_type}". Must be one of: {", ".join(valid_types)}')

        type_filter = self._TIMELINE_TYPE_FILTERS[event_type] if event_type else ""
        base_params: list[Any] = [file_id, file_id, file_id, file_id]

        total_row = self.conn.execute(
            f"{self._TIMELINE_CTE} SELECT COUNT(*) FROM timeline {type_filter}",
            base_params,
        ).fetchone()
        total: int = total_row[0]

        rows = self.conn.execute(
            f"{self._TIMELINE_CTE} SELECT type, timestamp, source_id, data_json "
            f"FROM timeline {type_filter} "
            f"ORDER BY timestamp DESC LIMIT ? OFFSET ?",
            [*base_params, limit, offset],
        ).fetchall()

        entries: list[TimelineEntry] = []
        for r in rows:
            raw = f"{r['type']}:{r['timestamp']}:{r['source_id']}"
            entries.append(
                {
                    "id": hashlib.sha256(raw.encode()).hexdigest()[:12],
                    "type": r["type"],
                    "timestamp": r["timestamp"],
                    "source_id": r["source_id"],
                    "data": _safe_json_loads(r["data_json"], f"timeline:{r['source_id']}"),
                }
            )

        return {
            "results": entries,
            "total": total,
            "limit": limit,
            "offset": offset,
            "has_more": offset + limit < total,
        }
