"""Scanner — the main user-facing API for Argus Nano.

Combines regex pattern matching (PatternRegistry) with an optional
ONNX ML classifier (OnnxModel) to detect real secrets in files.
"""

from __future__ import annotations

import fnmatch
import logging
import os
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from pathlib import Path
from typing import Optional

from argus_nano.patterns import PatternMatch, PatternRegistry

logger = logging.getLogger("argus_nano")

# Extensions that are almost certainly binary — skip without reading.
_BINARY_EXTENSIONS: set[str] = {
    ".png",
    ".jpg",
    ".jpeg",
    ".gif",
    ".bmp",
    ".ico",
    ".svg",
    ".webp",
    ".mp3",
    ".mp4",
    ".avi",
    ".mov",
    ".mkv",
    ".wav",
    ".flac",
    ".zip",
    ".gz",
    ".tar",
    ".bz2",
    ".xz",
    ".7z",
    ".rar",
    ".exe",
    ".dll",
    ".so",
    ".dylib",
    ".bin",
    ".o",
    ".a",
    ".pyc",
    ".pyo",
    ".class",
    ".jar",
    ".war",
    ".whl",
    ".egg",
    ".pdf",
    ".doc",
    ".docx",
    ".xls",
    ".xlsx",
    ".ppt",
    ".pptx",
    ".ttf",
    ".otf",
    ".woff",
    ".woff2",
    ".eot",
    ".onnx",
}

# Severity ordering (for sorting results).
_SEVERITY_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3}

# Default max file size to scan (1 MB).
_MAX_FILE_SIZE = 1_048_576

# Lock / generated files that should never be scanned.
_DEFAULT_SKIP_FILES = frozenset(
    {
        "package-lock.json",
        "yarn.lock",
        "pnpm-lock.yaml",
        "go.sum",
        "Cargo.lock",
        "poetry.lock",
        "Pipfile.lock",
        "Gemfile.lock",
        "composer.lock",
        "packages.lock.json",
    }
)

# Config/infrastructure file extensions for --config mode.
_CONFIG_EXTENSIONS: frozenset[str] = frozenset(
    {
        ".env",
        ".yml",
        ".yaml",
        ".json",
        ".toml",
        ".tf",
        ".tfvars",
        ".cfg",
        ".conf",
        ".ini",
        ".properties",
        ".xml",
        ".plist",
        ".docker",
    }
)

# Dotfiles whose Path.suffix is "" in Python 3.12+ (leading dot is not an extension).
# These must be matched by exact name, not by extension.
_CONFIG_NAMES: frozenset[str] = frozenset(
    {
        "dockerfile",
        ".env",
        ".my.cnf",
        ".npmrc",
        ".pypirc",
        ".netrc",
        ".htpasswd",
        ".pgpass",
        ".bashrc",
        ".bash_profile",
        ".zshrc",
        ".profile",
    }
)

_CONFIG_NAME_PREFIXES: tuple[str, ...] = (
    "docker-compose",
    "dockerfile",
    "secrets.",
    "credentials.",
    ".env.",
)


def _is_config_file(path: Path) -> bool:
    """Check if a file is a config/infrastructure file."""
    name = path.name.lower()
    suffix = path.suffix.lower()
    if suffix in _CONFIG_EXTENSIONS:
        return True
    if name in _CONFIG_NAMES:
        return True
    return any(name.startswith(p) for p in _CONFIG_NAME_PREFIXES)


@dataclass(frozen=True)
class ScanResult:
    """A confirmed or candidate secret finding."""

    severity: str
    file_path: str
    line_number: int
    provider: str
    pattern_id: str
    value_masked: str
    confidence: float
    detection_type: str = "format"  # "format" or "semantic"
    key_name: str = ""  # populated for semantic findings


class Scanner:
    """Two-stage secrets scanner: regex pre-filter + optional ONNX classifier."""

    def __init__(
        self,
        patterns_only: bool = False,
        threshold: float = 0.5,
        model_path: Optional[Path] = None,
        tokenizer_path: Optional[Path] = None,
        providers_path: Optional[Path] = None,
        benign_path: Optional[Path] = None,
        providers: Optional[list[str]] = None,
        semantic: bool = True,
        config: bool = False,
        threads: Optional[int] = None,
        jobs: Optional[int] = None,
    ) -> None:
        self._patterns_only = patterns_only
        self._threshold = threshold
        self._model_path = model_path
        self._tokenizer_path = tokenizer_path
        self._semantic = semantic
        self._config = config
        self._threads = threads
        self._jobs = jobs or os.cpu_count() or 4

        self._registry = PatternRegistry(
            providers_path=providers_path,
            benign_path=benign_path,
        )

        # Semantic scanner (only if enabled).
        self._semantic_scanner = None
        if semantic:
            from argus_nano.semantic import SemanticScanner

            self._semantic_scanner = SemanticScanner()

        # Lazy-loaded model (None until first use in full mode).
        self._model = None
        if not patterns_only:
            self._try_load_model(providers, threads)

    # ------------------------------------------------------------------
    # Public API
    # ------------------------------------------------------------------

    def scan_file(self, path: str | Path) -> list[ScanResult]:
        """Scan a single file and return detected secrets."""
        path = Path(path)
        if not path.is_file():
            return []
        if path.name in _DEFAULT_SKIP_FILES:
            return []
        if _is_binary(path):
            return []

        try:
            text = path.read_text(encoding="utf-8", errors="replace")
        except (OSError, PermissionError):
            return []

        lines = text.splitlines()
        return self._scan_lines(lines, str(path))

    def scan_directory(
        self,
        path: str | Path,
        exclude: Optional[list[str]] = None,
        progress_fn=None,
    ) -> list[ScanResult]:
        """Recursively scan a directory. *exclude* is a list of glob patterns.

        In full mode, regex candidates are collected across ALL files first,
        then classified in a single batched ML pass for efficiency.

        *progress_fn*, if provided, is called with ``(stage, completed, total)``
        where stage is ``"files"`` or ``"classify"``.
        """
        path = Path(path)
        exclude = exclude or []
        if progress_fn:
            progress_fn("walk", 0, 0)
        files = _walk_files(path, exclude)

        # Config-only mode: filter to config/infrastructure files only
        if self._config:
            total_walked = len(files)
            files = [f for f in files if _is_config_file(f)]
            logger.info(
                "Config-only mode: scanning %d of %d files",
                len(files),
                total_walked,
            )

        # If patterns-only or no model, scan files independently (fast path)
        if self._patterns_only or self._model is None:
            results: list[ScanResult] = []
            for i, fpath in enumerate(files):
                results.extend(self.scan_file(fpath))
                if progress_fn:
                    progress_fn("files", i + 1, len(files))
            return _sort_results(results)

        # Full mode: two-stage with global batching
        # Stage 1 — parallel file read + regex pre-filter + semantic
        # Each candidate: (source_type, match, file_path, lines, line_idx_0)
        all_candidates: list[tuple[str, object, str, list[str], int]] = []

        def _process_file(fpath: Path):
            lines = _read_file_lines(fpath)
            if lines is None:
                return []
            fp = str(fpath)
            cands = []
            seen: set[tuple[int, int]] = set()
            regex_lines: set[int] = set()

            # Regex candidates
            for item in self._regex_candidates(lines, fp):
                m, _, _, idx = item
                fp_key = (m.line_number, hash(m.value))
                seen.add(fp_key)
                regex_lines.add(m.line_number)
                cands.append(("format", m, fp, lines, idx))

            # Semantic candidates -- skip lines already claimed by regex
            if self._semantic_scanner:
                for smatch in self._semantic_scanner.scan_file_content(lines, fp):
                    if smatch.line_number in regex_lines:
                        continue
                    fp_key = (smatch.line_number, hash(smatch.value))
                    if fp_key not in seen:
                        seen.add(fp_key)
                        cands.append(("semantic", smatch, fp, lines, smatch.line_number - 1))

            return cands

        with ThreadPoolExecutor(max_workers=self._jobs) as pool:
            futures = {pool.submit(_process_file, fp): fp for fp in files}
            done = 0
            for future in as_completed(futures):
                fp = futures[future]
                try:
                    candidates = future.result(timeout=30)
                    all_candidates.extend(candidates)
                except TimeoutError:
                    logger.warning("Timeout scanning %s — skipping", fp)
                except Exception as exc:
                    logger.warning("Error scanning %s: %s", fp, exc)
                done += 1
                if progress_fn:
                    progress_fn("files", done, len(files))

        if not all_candidates:
            return []

        # Separate semantic candidates (bypass ML) from format candidates.
        # Semantic candidates already passed three gates (sensitive key,
        # not placeholder, value evidence) so they are reported directly.
        format_candidates: list[tuple[str, object, str, list[str], int]] = []
        semantic_results: list[ScanResult] = []
        for source, m, fp, lines, idx in all_candidates:
            if source == "semantic":
                semantic_results.append(_semantic_to_result(m, fp, 0.9))
            else:
                format_candidates.append((source, m, fp, lines, idx))

        if not format_candidates:
            return _sort_results(semantic_results)

        # Stage 1.5 — cross-file dedup by context text
        unique_texts: dict[str, int] = {}
        unique_list: list[str] = []
        candidate_to_unique: list[int] = []

        for source, m, fp, lines, idx in format_candidates:
            ctx = _build_context(lines, idx, fp)
            if ctx not in unique_texts:
                unique_texts[ctx] = len(unique_list)
                unique_list.append(ctx)
            candidate_to_unique.append(unique_texts[ctx])

        dedup_ratio = len(format_candidates) / max(len(unique_list), 1)
        if dedup_ratio > 1.05:
            logger.info(
                "Dedup: %d candidates -> %d unique contexts (%.1fx reduction)",
                len(format_candidates),
                len(unique_list),
                dedup_ratio,
            )

        # Stage 2 — batch ML classification on unique contexts only
        def _classify_progress(completed, total):
            if progress_fn:
                progress_fn("classify", completed, total)

        unique_predictions = self._model.predict_batch(
            unique_list,
            progress_fn=_classify_progress,
        )

        # Stage 3 — map predictions back and filter
        results: list[ScanResult] = []
        for i, (source, m, fp, _, _) in enumerate(format_candidates):
            label, conf = unique_predictions[candidate_to_unique[i]]
            if label == 1 and conf >= self._threshold:
                results.append(_match_to_result(m, fp, conf))

        # Add semantic results (already accepted)
        results.extend(semantic_results)

        return _sort_results(results)

    def check(self, value: str, context: Optional[str] = None) -> list[ScanResult]:
        """Check a single string for secret patterns.

        If *context* is provided it is used as surrounding code for ML
        classification; otherwise the value alone is used.
        """
        line = context if context else value
        matches = self._registry.scan_line(line, line_number=1)
        if not matches:
            # Try matching the raw value if it wasn't in the context
            matches = self._registry.scan_line(value, line_number=1)

        results: list[ScanResult] = []
        for m in matches:
            if self._registry.is_benign(m.value, m.pattern_id):
                continue
            if self._patterns_only or self._model is None:
                results.append(_match_to_result(m, "<check>", 1.0))
            else:
                text = context or value
                label, conf = self._model.predict(text)
                if label == 1 and conf >= self._threshold:
                    results.append(_match_to_result(m, "<check>", conf))

        return _sort_results(results)

    @property
    def registry(self) -> PatternRegistry:
        return self._registry

    # ------------------------------------------------------------------
    # Internal helpers
    # ------------------------------------------------------------------

    def _regex_candidates(self, lines: list[str], file_path: str) -> list[tuple[PatternMatch, str, list[str], int]]:
        """Run regex pre-filter on lines. Returns (match, file_path, lines, line_idx_0)."""
        candidates: list[tuple[PatternMatch, str, list[str], int]] = []
        seen: set[tuple[str, int]] = set()

        in_pem_block = False
        for line_num_0, line in enumerate(lines):
            stripped = line.strip()

            if in_pem_block:
                if stripped.startswith("-----END ") and stripped.endswith("-----"):
                    in_pem_block = False
                continue

            if stripped.startswith("-----BEGIN ") and stripped.endswith("-----"):
                in_pem_block = True

            line_num = line_num_0 + 1
            matches = self._registry.scan_line(line, line_num)
            for m in matches:
                key = (m.value, m.line_number)
                if key in seen:
                    continue
                seen.add(key)
                if self._registry.is_benign(m.value, m.pattern_id):
                    continue
                candidates.append((m, file_path, lines, line_num_0))

        return candidates

    def _try_load_model(self, providers: Optional[list[str]] = None, threads: Optional[int] = None) -> None:
        """Attempt to load the ONNX model; fall back to patterns-only."""
        try:
            from argus_nano.model import OnnxModel

            self._model = OnnxModel(
                model_path=self._model_path,
                tokenizer_path=self._tokenizer_path,
                providers=providers,
                threads=threads,
            )
        except Exception as exc:
            logger.info("Model not available, using patterns-only mode: %s", exc)
            self._model = None

    def _scan_lines(self, lines: list[str], file_path: str) -> list[ScanResult]:
        """Core scanning loop over file lines.

        Pass 1a: regex scan to collect non-benign candidates.
        Pass 1b: semantic scan (if enabled) for key-name-based candidates.
        Pass 2:  batch ML classification (if model available).
        Pass 3:  filter by label and confidence threshold.
        """
        # (source_type, match_or_smatch, line_idx_0)
        candidates: list[tuple[str, object, int]] = []
        seen: set[tuple[int, int]] = set()  # (line_number, hash(value)) fingerprint
        regex_lines: set[int] = set()  # lines already claimed by regex

        # Pass 1a: collect regex candidates
        in_pem_block = False
        for line_num_0, line in enumerate(lines):
            stripped = line.strip()

            # Skip base64 content inside PEM blocks (certificates, public
            # keys, etc.).  The BEGIN marker line is still scanned so that
            # private-key patterns fire normally.
            if in_pem_block:
                if stripped.startswith("-----END ") and stripped.endswith("-----"):
                    in_pem_block = False
                continue

            if stripped.startswith("-----BEGIN ") and stripped.endswith("-----"):
                in_pem_block = True

            line_num = line_num_0 + 1
            matches = self._registry.scan_line(line, line_num)

            for m in matches:
                fp = (m.line_number, hash(m.value))
                if fp in seen:
                    continue
                seen.add(fp)

                if self._registry.is_benign(m.value, m.pattern_id):
                    continue

                candidates.append(("format", m, line_num_0))
                regex_lines.add(m.line_number)

        # Pass 1b: semantic candidates (if enabled)
        # Semantic candidates already passed three gates (sensitive key,
        # not placeholder, value evidence for ambiguous keys) so they are
        # reported directly without ML confirmation.
        # Skip lines already claimed by regex -- the regex match is more
        # specific (provider, pattern ID, severity).
        semantic_results: list[ScanResult] = []
        if self._semantic_scanner:
            for smatch in self._semantic_scanner.scan_file_content(lines, file_path):
                if smatch.line_number in regex_lines:
                    continue
                fp = (smatch.line_number, hash(smatch.value))
                if fp not in seen:
                    seen.add(fp)
                    if self._patterns_only or self._model is None:
                        score = _heuristic_score(smatch.value)
                        if score >= 0.3:
                            semantic_results.append(
                                _semantic_to_result(
                                    smatch,
                                    file_path,
                                    score,
                                )
                            )
                    else:
                        semantic_results.append(
                            _semantic_to_result(
                                smatch,
                                file_path,
                                0.9,
                            )
                        )

        if not candidates and not semantic_results:
            return []

        # Patterns-only or no model: return all format candidates as-is
        if self._patterns_only or self._model is None:
            results: list[ScanResult] = []
            for source, match, _ in candidates:
                results.append(_match_to_result(match, file_path, 1.0))
            results.extend(semantic_results)
            return _sort_results(results)

        # Pass 2: batch ML classification (format candidates only)
        texts = [_build_context(lines, idx, file_path) for _, _, idx in candidates]
        predictions = self._model.predict_batch(texts)

        # Pass 3: filter format candidates by label and confidence
        results: list[ScanResult] = []
        for (source, match, _), (label, conf) in zip(candidates, predictions):
            if label == 1 and conf >= self._threshold:
                results.append(_match_to_result(match, file_path, conf))

        # Add semantic results (already accepted)
        results.extend(semantic_results)

        return _sort_results(results)


# ======================================================================
# Module-level helpers
# ======================================================================


_DEFAULT_SKIP_DIRS = frozenset(
    {
        "node_modules",
        "__pycache__",
        ".git",
        ".hg",
        ".svn",
        "vendor",
        "dist",
        "build",
        ".tox",
        ".mypy_cache",
        ".pytest_cache",
        ".venv",
        "venv",
        "env",
        ".env",
        ".next",
        ".nuxt",
        "coverage",
        ".terraform",
    }
)


def _walk_files(path: Path, exclude: list[str]) -> list[Path]:
    """Collect all scannable file paths under *path*, honoring exclusions."""
    files = []
    for root, dirs, fnames in os.walk(path):
        dirs[:] = [d for d in dirs if d not in _DEFAULT_SKIP_DIRS and not d.startswith(".")]
        for fname in fnames:
            if fname in _DEFAULT_SKIP_FILES:
                continue
            fpath = Path(root) / fname
            rel = str(fpath.relative_to(path))
            if any(fnmatch.fnmatch(rel, pat) for pat in exclude):
                continue
            if _is_binary(fpath):
                continue
            files.append(fpath)
    return files


def _read_file_lines(path: Path) -> list[str] | None:
    """Read a file and return its lines, or None if unreadable."""
    try:
        text = path.read_text(encoding="utf-8", errors="replace")
    except (OSError, PermissionError):
        return None
    return text.splitlines()


def _mask_value(value: str) -> str:
    """Mask the middle of a secret value for safe display."""
    if len(value) <= 8:
        return value[:2] + "****" + value[-2:] if len(value) > 4 else "****"
    return value[:4] + "****" + value[-4:]


def _match_to_result(m: PatternMatch, file_path: str, confidence: float) -> ScanResult:
    return ScanResult(
        severity=m.severity,
        file_path=file_path,
        line_number=m.line_number,
        provider=m.provider,
        pattern_id=m.pattern_id,
        value_masked=_mask_value(m.value),
        confidence=confidence,
        detection_type="format",
    )


def _semantic_to_result(m, file_path: str, confidence: float) -> ScanResult:
    """Convert a SemanticMatch to a ScanResult."""
    from argus_nano.semantic import derive_provider

    return ScanResult(
        severity="high",
        file_path=file_path,
        line_number=m.line_number,
        provider=derive_provider(m.key_name),
        pattern_id="semantic_key",
        value_masked=_mask_value(m.value),
        confidence=confidence,
        detection_type="semantic",
        key_name=m.key_name,
    )


def _heuristic_score(value: str) -> float:
    """Score how likely a value is a real secret (0.0 to 1.0).

    Used only when the ML model is unavailable for semantic candidates.
    """
    score = 0.0

    if len(value) >= 6:
        score += 0.15
    if len(value) >= 8:
        score += 0.1
    if len(value) >= 16:
        score += 0.1
    if len(value) >= 32:
        score += 0.1

    has_upper = any(c.isupper() for c in value)
    has_lower = any(c.islower() for c in value)
    has_digit = any(c.isdigit() for c in value)
    has_special = any(c in "!@#$%^&*()_+-=[]{}|;:,.<>?" for c in value)
    classes = sum([has_upper, has_lower, has_digit, has_special])
    score += classes * 0.1

    # Known weak passwords
    weak_passwords = {
        "admin",
        "admin123",
        "root",
        "root123",
        "default",
        "password",
        "password1",
        "letmein",
        "welcome",
        "welcome1",
        "qwerty",
        "qwerty123",
        "123456",
        "1234567890",
    }
    if value.lower() in weak_passwords:
        score = max(score, 0.5)

    # Connection string with embedded password
    if "://" in value and "@" in value:
        score = max(score, 0.6)

    return min(score, 1.0)


def _build_context(lines: list[str], center_idx: int, file_path: str, window: int = 5) -> str:
    """Build an 11-line context window formatted for the ML model."""
    start = max(0, center_idx - window)
    end = min(len(lines), center_idx + window + 1)
    context_lines = lines[start:end]
    return f"[FILE] {file_path} [/FILE]\n" + "\n".join(context_lines)


def _is_binary(path: Path) -> bool:
    """Quick check for binary files."""
    if path.suffix.lower() in _BINARY_EXTENSIONS:
        return True
    try:
        if path.stat().st_size > _MAX_FILE_SIZE:
            return True
        # Read first 8 KB and look for null bytes
        with open(path, "rb") as f:
            chunk = f.read(8192)
        return b"\x00" in chunk
    except (OSError, PermissionError):
        return True


def _sort_results(results: list[ScanResult]) -> list[ScanResult]:
    return sorted(
        results,
        key=lambda r: (_SEVERITY_ORDER.get(r.severity, 9), r.line_number),
    )
