"""
Incremental scan cache for code analysis.

Per-file result cache keyed by (project_root, file_path, language).
Uses file mtime as a fast-path check before falling back to SHA-256 hashing,
so unchanged large codebases add almost zero overhead.

Cache invalidation triggers:
  - File content changed (SHA-256 mismatch)
  - Rule paths changed (new/removed rules in code_analysis_rules.yaml)
  - Custom YAML rules edited (hash of .yaml files changed)
  - Custom scanner .py code changed (BouncyCastle, Paramiko, etc.)
"""

import hashlib
import json
import logging
import os
import subprocess
from collections import defaultdict
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, List, Optional, Set, Tuple

logger = logging.getLogger(__name__)

# ─────────────────────────────────────────────────────────────────────────────
# Paths
# ─────────────────────────────────────────────────────────────────────────────

_THIS_DIR = Path(__file__).parent               # rivet_server/app/code_analyzer/
_CUSTOM_DIR = _THIS_DIR / "custom"              # rivet_server/app/code_analyzer/custom/
_PROJECT_ROOT = _THIS_DIR.parent.parent.parent  # qc/
_CUSTOM_RULES_PATH = _PROJECT_ROOT / "rules" / "custom-rules"

# Maps language → supplementary custom scanner .py files whose content affects results.
# If any of these files change, ALL cached entries for that language are invalid.
_SUPPLEMENTARY_SCANNER_FILES: Dict[str, List[Path]] = {
    "java":   [_CUSTOM_DIR / "bouncycastle_crypto_scanner.py"],
    "python": [_CUSTOM_DIR / "paramiko_crypto_scanner.py"],
}


# ─────────────────────────────────────────────────────────────────────────────
# Fingerprint helpers
# ─────────────────────────────────────────────────────────────────────────────

def _git_head(repo_path: Path) -> str:
    """Return git HEAD commit SHA for a directory, or '' on failure."""
    try:
        result = subprocess.run(
            ["git", "-C", str(repo_path), "rev-parse", "HEAD"],
            capture_output=True, text=True, timeout=5,
        )
        if result.returncode == 0:
            return result.stdout.strip()
    except Exception:
        pass
    return ""


def _hash_path(path: Path) -> str:
    """SHA-256 of a single file's bytes, or '' if unreadable."""
    try:
        return hashlib.sha256(path.read_bytes()).hexdigest()
    except Exception:
        return ""


def _hash_yaml_dir(directory: Path) -> str:
    """SHA-256 over all .yaml/.yml files in a directory tree (sorted, stable)."""
    try:
        file_hashes = []
        for f in sorted(directory.rglob("*.yaml")) + sorted(directory.rglob("*.yml")):
            file_hashes.append(f"{f.name}:{_hash_path(f)}")
        return hashlib.sha256("|".join(file_hashes).encode()).hexdigest()
    except Exception:
        return ""


def compute_language_rule_fingerprint(
    language: str,
    resolved_rules: List[str],
    _scan_mode: str = "all",
) -> str:
    """
    Stable fingerprint covering every input that can change scan results for a language.

    Inputs hashed:
      1. Sorted resolved rule paths (config paths as computed by load_scanner_config)
      2. Custom YAML rules for this language (editing custom-rules/ yaml files)
      3. Supplementary scanner .py code for this language (editing BouncyCastle/Paramiko)
    """
    parts: List[str] = []

    # 1. Rule paths
    parts.append("rules:" + ",".join(sorted(resolved_rules)))

    # 2. Custom YAML rules content
    lang_custom_dir = _CUSTOM_RULES_PATH / language
    parts.append("custom_yaml:" + (_hash_yaml_dir(lang_custom_dir) if lang_custom_dir.exists() else ""))

    # 3. Supplementary scanner code
    for scanner_path in _SUPPLEMENTARY_SCANNER_FILES.get(language, []):
        parts.append(f"scanner:{scanner_path.name}:{_hash_path(scanner_path)}")

    return hashlib.sha256("|".join(parts).encode()).hexdigest()


def compute_supplementary_fingerprint(language: str) -> str:
    """
    Fingerprint for supplementary custom scanners (BouncyCastle, Paramiko).
    Only the scanner .py file content matters — no analysis rules involved.
    Returns '' if no supplementary scanner exists for the language.
    """
    scanner_paths = _SUPPLEMENTARY_SCANNER_FILES.get(language, [])
    if not scanner_paths:
        return ""
    parts = [f"{p.name}:{_hash_path(p)}" for p in scanner_paths]
    return hashlib.sha256("|".join(parts).encode()).hexdigest()


# ─────────────────────────────────────────────────────────────────────────────
# File content hashing
# ─────────────────────────────────────────────────────────────────────────────

def compute_file_hash(file_path: str) -> str:
    """SHA-256 of a file's content, streaming in 64KB chunks."""
    h = hashlib.sha256()
    try:
        with open(file_path, "rb") as f:
            for chunk in iter(lambda: f.read(65536), b""):
                h.update(chunk)
        return h.hexdigest()
    except OSError:
        return ""


# ─────────────────────────────────────────────────────────────────────────────
# Cache entry split helper
# ─────────────────────────────────────────────────────────────────────────────

def split_cached_vs_dirty(
    file_paths: List[str],
    project_root: str,
    language: str,
    rule_fingerprint: str,
    cache: "ScanCache",
) -> Tuple[List[str], List[str], List[Dict]]:
    """
    Split file_paths into (cached_files, dirty_files, cached_findings).

    cached_files  — files whose findings are already valid in the cache
    dirty_files   — files that need (re)scanning
    cached_findings — combined raw findings for all cached files

    The split logic:
      1. If no cache entry exists → dirty
      2. If rule fingerprint changed → dirty
      3. If mtime matches stored mtime → cached (fast path, no file read)
      4. mtime changed → compute SHA-256:
           hash matches → cached (mtime-only change, e.g. touch); update stored mtime
           hash differs  → dirty
    """
    cached_files: List[str] = []
    dirty_files: List[str] = []
    cached_findings: List[Dict] = []

    for file_path in file_paths:
        entry = cache.get_entry(project_root, file_path, language)

        # No entry or stale rule set → rescan
        if entry is None or entry.rule_fingerprint != rule_fingerprint:
            dirty_files.append(file_path)
            continue

        try:
            current_mtime = os.stat(file_path).st_mtime
        except OSError:
            # File disappeared between listing and checking — skip (stale cleanup handles it)
            dirty_files.append(file_path)
            continue

        if abs(current_mtime - entry.mtime) < 0.01:
            # Fast path: mtime unchanged → use cache
            cached_files.append(file_path)
            try:
                cached_findings.extend(json.loads(entry.findings_json or "[]"))
            except json.JSONDecodeError:
                pass
            continue

        # mtime changed → verify content
        file_hash = compute_file_hash(file_path)
        if file_hash == entry.file_hash:
            # Content unchanged (e.g. touch, git checkout restoring identical content)
            cache.update_mtime(entry.id, current_mtime)
            cached_files.append(file_path)
            try:
                cached_findings.extend(json.loads(entry.findings_json or "[]"))
            except json.JSONDecodeError:
                pass
        else:
            dirty_files.append(file_path)

    return cached_files, dirty_files, cached_findings


# ─────────────────────────────────────────────────────────────────────────────
# Cache write helper
# ─────────────────────────────────────────────────────────────────────────────

def queue_batch_cache_writes(
    batch_files: List[str],
    batch_findings: List[Dict],
    project_root: str,
    language: str,
    rule_fingerprint: str,
    cache: "ScanCache",
) -> None:
    """
    After scanning a batch, queue cache entries for every file in the batch.
    Files with no findings get an empty list — important so they are cached as 'clean'.
    """
    # Group findings by normalised file path
    findings_by_file: Dict[str, List[Dict]] = defaultdict(list)
    for finding in batch_findings:
        fp = finding.get("path", "")
        if fp:
            findings_by_file[os.path.normcase(fp)].append(finding)

    for file_path in batch_files:
        try:
            st = os.stat(file_path)
            file_hash = compute_file_hash(file_path)
            file_findings = findings_by_file.get(os.path.normcase(file_path), [])
            cache.queue_entry(
                project_root=project_root,
                file_path=file_path,
                language=language,
                mtime=st.st_mtime,
                file_hash=file_hash,
                rule_fingerprint=rule_fingerprint,
                findings=file_findings,
            )
        except OSError:
            pass  # File disappeared — skip caching


# ─────────────────────────────────────────────────────────────────────────────
# ScanCache
# ─────────────────────────────────────────────────────────────────────────────

class ScanCache:
    """
    Incremental scan cache backed by the main SQLite database (file_scan_cache table).

    Writes are batched and flushed together to minimise SQLite round-trips.
    All cache failures are non-fatal — a miss just triggers a fresh scan.
    """

    def __init__(self, session):
        self._session = session
        self._pending: List = []

    # ── Key ───────────────────────────────────────────────────────────────────

    @staticmethod
    def _make_id(project_root: str, file_path: str, language: str) -> str:
        key = f"{os.path.normcase(project_root)}|{os.path.normcase(file_path)}|{language}"
        return hashlib.sha256(key.encode()).hexdigest()

    # ── Read ──────────────────────────────────────────────────────────────────

    def get_entry(self, project_root: str, file_path: str, language: str):
        """Return the FileScanCache entry for this (project_root, file_path, language), or None."""
        from app.models import FileScanCache
        try:
            entry_id = self._make_id(project_root, file_path, language)
            return self._session.get(FileScanCache, entry_id)
        except Exception as e:
            logger.debug(f"Cache get failed for {file_path}: {e}")
            return None

    # ── Write (batched) ───────────────────────────────────────────────────────

    def queue_entry(
        self,
        project_root: str,
        file_path: str,
        language: str,
        mtime: float,
        file_hash: str,
        rule_fingerprint: str,
        findings: List[Dict],
    ) -> None:
        """Queue a cache entry to be written on the next flush_pending() call."""
        from app.models import FileScanCache
        entry = FileScanCache(
            id=self._make_id(project_root, file_path, language),
            project_root=os.path.normcase(project_root),
            file_path=os.path.normcase(file_path),
            language=language,
            mtime=mtime,
            file_hash=file_hash,
            rule_fingerprint=rule_fingerprint,
            findings_json=json.dumps(findings),
            cached_at=datetime.now(timezone.utc),
        )
        self._pending.append(entry)

    def flush_pending(self) -> None:
        """Upsert all queued entries and commit.  Clears the queue on success or failure."""
        if not self._pending:
            return
        try:
            for entry in self._pending:
                self._session.merge(entry)
            self._session.commit()
            logger.debug(f"Cache flushed {len(self._pending)} entries")
        except Exception as e:
            logger.warning(f"Cache flush failed: {e}")
            try:
                self._session.rollback()
            except Exception:
                pass
        finally:
            self._pending.clear()

    def update_mtime(self, entry_id: str, new_mtime: float) -> None:
        """Update just the stored mtime for a cache entry (content unchanged)."""
        from app.models import FileScanCache
        try:
            entry = self._session.get(FileScanCache, entry_id)
            if entry:
                entry.mtime = new_mtime
                self._session.add(entry)
                self._session.commit()
        except Exception as e:
            logger.debug(f"Cache mtime update failed for {entry_id}: {e}")

    # ── Stale entry cleanup ───────────────────────────────────────────────────

    def delete_stale_entries(self, project_root: str, seen_norm_paths: Set[str]) -> None:
        """
        Remove entries for files no longer present in the scanned directory.
        seen_norm_paths should be os.path.normcase'd absolute paths.
        Called once at the end of a complete (non-partial) scan.
        """
        from app.models import FileScanCache
        from sqlmodel import select
        try:
            norm_root = os.path.normcase(project_root)
            stmt = select(FileScanCache).where(FileScanCache.project_root == norm_root)
            entries = self._session.exec(stmt).all()
            stale = [e for e in entries if e.file_path not in seen_norm_paths]
            if stale:
                for e in stale:
                    self._session.delete(e)
                self._session.commit()
                logger.info(
                    f"Cache cleanup: removed {len(stale)} stale entries for {project_root}"
                )
        except Exception as e:
            logger.warning(f"Cache stale-entry cleanup failed: {e}")

    # ── Clear ─────────────────────────────────────────────────────────────────

    def clear_project(self, project_root: str) -> int:
        """Delete all cache entries for a specific project root. Returns count deleted."""
        from app.models import FileScanCache
        from sqlmodel import select
        try:
            norm_root = os.path.normcase(project_root)
            stmt = select(FileScanCache).where(FileScanCache.project_root == norm_root)
            entries = self._session.exec(stmt).all()
            count = len(entries)
            for e in entries:
                self._session.delete(e)
            self._session.commit()
            return count
        except Exception as e:
            logger.error(f"Cache clear_project failed: {e}")
            return 0

    def clear_all(self) -> int:
        """Delete every cache entry. Returns count deleted."""
        from app.models import FileScanCache
        from sqlmodel import select
        try:
            entries = self._session.exec(select(FileScanCache)).all()
            count = len(entries)
            for e in entries:
                self._session.delete(e)
            self._session.commit()
            logger.info(f"Scan cache cleared: {count} entries removed")
            return count
        except Exception as e:
            logger.error(f"Cache clear_all failed: {e}")
            return 0

    def get_stats(self, project_root: Optional[str] = None) -> Dict:
        """Return cache statistics for display in the UI."""
        from app.models import FileScanCache
        from sqlmodel import select, func
        try:
            stmt = select(FileScanCache)
            if project_root:
                stmt = stmt.where(FileScanCache.project_root == os.path.normcase(project_root))
            entries = self._session.exec(stmt).all()
            if not entries:
                return {"total_entries": 0, "languages": {}, "oldest_entry": None, "newest_entry": None}
            lang_counts: Dict[str, int] = defaultdict(int)
            for e in entries:
                lang_counts[e.language] += 1
            timestamps = [e.cached_at for e in entries if e.cached_at]
            return {
                "total_entries": len(entries),
                "languages": dict(lang_counts),
                "oldest_entry": min(timestamps).isoformat() if timestamps else None,
                "newest_entry": max(timestamps).isoformat() if timestamps else None,
            }
        except Exception as e:
            logger.error(f"Cache get_stats failed: {e}")
            return {"total_entries": 0, "languages": {}, "oldest_entry": None, "newest_entry": None}
