"""Secrets Collector — filesystem + git history scanner."""

from __future__ import annotations

import re
from pathlib import Path
from typing import Any

import yaml

from sentinel.collectors.base import BaseCollector
from sentinel.collectors.secrets.entropy import (
    extract_tokens,
    is_high_entropy_string,
    shannon_entropy,
)
from sentinel.models.config import SecretsConfig

MAX_FILE_BYTES = 1 * 1024 * 1024  # 1MB

BINARY_EXTENSIONS = {
    ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".webp",
    ".mp4", ".mp3", ".wav", ".avi", ".mov",
    ".pdf", ".zip", ".tar", ".gz", ".bz2", ".xz", ".7z",
    ".exe", ".dll", ".so", ".dylib", ".class", ".pyc", ".pyo",
    ".db", ".sqlite", ".sqlite3",
    ".woff", ".woff2", ".ttf", ".eot",
}

# Skip lock files for entropy (full of hashes that look like secrets)
LOCKFILE_NAMES = {
    "package-lock.json", "yarn.lock", "pnpm-lock.yaml",
    "Pipfile.lock", "uv.lock", "poetry.lock",
    "Cargo.lock", "go.sum", "Gemfile.lock",
}


class SecretsCollector(BaseCollector):
    module_name = "secrets"

    def __init__(self, repo_path: Path, config: SecretsConfig) -> None:
        super().__init__(repo_path)
        self.config = config
        self._patterns = self._load_patterns()
        self._allowlist_patterns = self._load_allowlist_patterns()

    def collect(self) -> dict[str, Any]:
        matches: list[dict[str, Any]] = []
        files_scanned = 0

        gitignore_spec = self._load_gitignore_spec()

        for filepath in self._walk_files(gitignore_spec):
            if self._is_path_allowlisted(filepath):
                continue
            if filepath.name in LOCKFILE_NAMES:
                continue
            try:
                content_bytes = filepath.read_bytes()
                if b"\x00" in content_bytes[:8000]:
                    continue  # binary file
                text = content_bytes.decode("utf-8", errors="replace")
            except OSError:
                continue
            files_scanned += 1
            rel = str(filepath.relative_to(self.repo_path))
            matches.extend(self._scan_content(text, rel))

        history_matches: list[dict[str, Any]] = []
        if self.config.scan_history:
            history_matches = self._scan_git_history()

        return {
            "files_scanned": files_scanned,
            "matches": matches,
            "history_matches": history_matches,
            "config": {
                "entropy_threshold": self.config.entropy_threshold,
                "entropy_min_length": self.config.entropy_min_length,
                "scan_history": self.config.scan_history,
                "history_depth": self.config.history_depth,
            },
        }

    def _scan_content(self, text: str, filepath: str) -> list[dict[str, Any]]:
        matches: list[dict[str, Any]] = []
        lines = text.splitlines()
        for line_no, line in enumerate(lines, 1):
            if self._is_allowlisted_value(line):
                continue
            # Pattern matching
            for pattern_def in self._patterns:
                m = pattern_def["compiled"].search(line)
                if m:
                    value = m.group(0)
                    matches.append({
                        "type": "pattern",
                        "pattern_id": pattern_def["id"],
                        "pattern_name": pattern_def["name"],
                        "severity": pattern_def["severity"],
                        "file": filepath,
                        "line": line_no,
                        "value_redacted": self._redact(value),
                        "snippet": line.strip()[:200],
                    })
            # Entropy analysis (skip lines already matched by pattern)
            for token in extract_tokens(line):
                if is_high_entropy_string(
                    token,
                    self.config.entropy_threshold,
                    self.config.entropy_min_length,
                ):
                    if self._is_allowlisted_value(token):
                        continue
                    matches.append({
                        "type": "entropy",
                        "pattern_id": "entropy.high_entropy_string",
                        "pattern_name": "High-entropy string",
                        "severity": "medium",
                        "file": filepath,
                        "line": line_no,
                        "value_redacted": self._redact(token),
                        "snippet": line.strip()[:200],
                        "entropy": round(shannon_entropy(token), 2),
                    })
        return matches

    def _scan_git_history(self) -> list[dict[str, Any]]:
        try:
            import git
        except ImportError:
            return []
        matches: list[dict[str, Any]] = []
        try:
            repo = git.Repo(self.repo_path)
            commits = list(repo.iter_commits(max_count=self.config.history_depth))
            for commit in commits:
                if not commit.parents:
                    continue
                parent = commit.parents[0]
                diff = parent.diff(commit, create_patch=True)
                for d in diff:
                    if not d.diff:
                        continue
                    diff_bytes = d.diff
                    patch = (
                        diff_bytes.decode("utf-8", errors="replace")
                        if isinstance(diff_bytes, bytes)
                        else str(diff_bytes)
                    )
                    for line in patch.splitlines():
                        if not line.startswith("+"):
                            continue
                        content = line[1:]
                        if self._is_allowlisted_value(content):
                            continue
                        for pattern_def in self._patterns:
                            m = pattern_def["compiled"].search(content)
                            if m:
                                value = m.group(0)
                                matches.append({
                                    "type": "history",
                                    "pattern_id": pattern_def["id"],
                                    "pattern_name": pattern_def["name"],
                                    "severity": "high",
                                    "file": d.b_path or d.a_path or "unknown",
                                    "commit": commit.hexsha[:8],
                                    "commit_message": commit.message.strip()[:100],
                                    "value_redacted": self._redact(value),
                                    "snippet": content.strip()[:200],
                                })
        except Exception:  # noqa: BLE001
            pass
        return matches

    def _load_patterns(self) -> list[dict[str, Any]]:
        rules_file = Path(__file__).parent.parent.parent / "rules" / "secrets_patterns.yml"
        if not rules_file.exists():
            return []
        data = yaml.safe_load(rules_file.read_text()) or {}
        result = []
        for entry in data.get("patterns", []):
            try:
                compiled = re.compile(entry["pattern"])
                result.append({**entry, "compiled": compiled})
            except re.error:
                pass
        return result

    def _load_allowlist_patterns(self) -> list[re.Pattern[str]]:
        rules_file = Path(__file__).parent.parent.parent / "rules" / "secrets_patterns.yml"
        if not rules_file.exists():
            return []
        data = yaml.safe_load(rules_file.read_text()) or {}
        result = []
        for pat in data.get("allowlist_patterns", []):
            try:
                result.append(re.compile(pat, re.IGNORECASE))
            except re.error:
                pass
        return result

    def _load_gitignore_spec(self) -> Any:
        try:
            import pathspec
            gi = self.repo_path / ".gitignore"
            if gi.exists():
                return pathspec.PathSpec.from_lines("gitwildmatch", gi.read_text().splitlines())
        except ImportError:
            pass
        return None

    def _walk_files(self, gitignore_spec: Any) -> list[Path]:
        result = []
        for p in self.repo_path.rglob("*"):
            if not p.is_file():
                continue
            rel_parts = p.relative_to(self.repo_path).parts
            # Skip hidden directories (but not hidden files like .env)
            if any(
                part.startswith(".")
                and part not in (".env", ".gitlab-ci.yml", ".gitignore", ".gitlab")
                for part in rel_parts[:-1]
            ):
                continue
            if p.suffix.lower() in BINARY_EXTENSIONS:
                continue
            if p.stat().st_size > MAX_FILE_BYTES:
                continue
            rel = str(p.relative_to(self.repo_path))
            if gitignore_spec:
                try:
                    if gitignore_spec.match_file(rel):
                        # Still scan .env even if gitignored
                        if p.name not in (".env",) and not p.name.startswith(".env."):
                            continue
                except Exception:  # noqa: BLE001
                    pass
            result.append(p)
        return result

    def _is_path_allowlisted(self, filepath: Path) -> bool:
        try:
            import pathspec
            rel = str(filepath.relative_to(self.repo_path))
            spec = pathspec.PathSpec.from_lines("gitwildmatch", self.config.allowlist)
            return spec.match_file(rel)
        except Exception:  # noqa: BLE001
            return False

    def _is_allowlisted_value(self, text: str) -> bool:
        return any(p.search(text) for p in self._allowlist_patterns)

    @staticmethod
    def _redact(value: str) -> str:
        if len(value) <= 8:
            return "****"
        return f"{value[:4]}****{value[-4:]}"
