"""
Source-code scanner for hard-coded secrets.

Uses regular expressions to detect common patterns that suggest a secret value
has been embedded directly in Python source files.  Findings are emitted as
:class:`~safeconfig.core.exceptions.ExposedKeyWarning` warnings.

This scanner is intentionally conservative — it tries to avoid false negatives
(missed real secrets) at the cost of some false positives.
"""

from __future__ import annotations

import logging
import re
import warnings
from dataclasses import dataclass, field
from pathlib import Path

from pysafeconfigx.core.exceptions import ExposedKeyWarning

logger = logging.getLogger(__name__)

# ─── Patterns ────────────────────────────────────────────────────────────────

# Each tuple is (pattern, label) where label describes the finding.
_PATTERNS: list[tuple[str, str]] = [
    # Generic assignment patterns
    (
        r"""(?:api_key|apikey|api_secret|secret_key|secret|password|passwd|token|auth_token|access_token|private_key)\s*=\s*['"]([^'"]{8,})['"]""",
        "generic-secret-assignment",
    ),
    # AWS
    (r"""(?:AWS_ACCESS_KEY_ID|AWS_SECRET_ACCESS_KEY)\s*=\s*['"]([A-Za-z0-9/+=]{16,})['"]""", "aws-credential"),
    (r"""AKIA[0-9A-Z]{16}""", "aws-access-key-id"),
    (r"""(?<![A-Za-z0-9/+=])[A-Za-z0-9/+=]{40}(?![A-Za-z0-9/+=])""", "possible-aws-secret"),
    # GitHub tokens
    (r"""gh[pousr]_[A-Za-z0-9]{36,}""", "github-token"),
    # Generic high-entropy strings (32+ hex chars)
    (r"""['"][0-9a-fA-F]{32,}['"]""", "high-entropy-hex"),
    # Stripe-like keys
    (r"""(?:sk|pk)_(?:live|test)_[A-Za-z0-9]{24,}""", "stripe-key"),
    # Slack tokens
    (r"""xox[baprs]-[A-Za-z0-9\-]{10,}""", "slack-token"),
    # Sendgrid
    (r"""SG\.[A-Za-z0-9\-_.]{22,}\.[A-Za-z0-9\-_.]{43,}""", "sendgrid-key"),
    # Private key headers
    (r"""-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----""", "private-key-pem"),
]

_COMPILED = [(re.compile(p, re.IGNORECASE), label) for p, label in _PATTERNS]

# Lines / files to ignore
_IGNORE_COMMENTS = re.compile(r"#.*safeconfig:\s*ignore", re.IGNORECASE)
_IGNORE_FILENAMES = {".pyc", ".pyo"}

# ─── Data classes ────────────────────────────────────────────────────────────


@dataclass
class Finding:
    """A single potential secret found in source code.

    Attributes:
        file: Path to the file where the secret was found.
        line_no: 1-based line number.
        line: The source line (stripped of leading whitespace).
        match: The matched text.
        label: Human-readable category of the finding.
    """

    file: Path
    line_no: int
    line: str
    match: str
    label: str

    def __str__(self) -> str:
        short_match = self.match[:60] + "..." if len(self.match) > 60 else self.match
        return f"{self.file}:{self.line_no}  [{self.label}]  {short_match!r}"


@dataclass
class ScanResult:
    """Aggregated result of a directory scan.

    Attributes:
        scanned_files: Number of Python files inspected.
        findings: List of :class:`Finding` objects.
    """

    scanned_files: int = 0
    findings: list[Finding] = field(default_factory=list)

    @property
    def has_issues(self) -> bool:
        """Return *True* if any findings were detected."""
        return len(self.findings) > 0

    def __str__(self) -> str:
        lines = [f"Scanned {self.scanned_files} file(s), found {len(self.findings)} potential issue(s)."]
        for f in self.findings:
            lines.append(f"  {f}")
        return "\n".join(lines)


# ─── Public API ──────────────────────────────────────────────────────────────


def scan_file(path: Path) -> list[Finding]:
    """Scan a single Python file for hard-coded secrets.

    Lines containing ``# safeconfig: ignore`` are skipped.

    Args:
        path: Path to the ``.py`` file.

    Returns:
        List of :class:`Finding` objects (may be empty).
    """
    findings: list[Finding] = []

    try:
        text = path.read_text(encoding="utf-8", errors="replace")
    except OSError as exc:
        logger.warning("Could not read '%s': %s", path, exc)
        return findings

    for line_no, line in enumerate(text.splitlines(), start=1):
        if _IGNORE_COMMENTS.search(line):
            continue

        for pattern, label in _COMPILED:
            for match in pattern.finditer(line):
                findings.append(
                    Finding(
                        file=path,
                        line_no=line_no,
                        line=line.strip(),
                        match=match.group(0),
                        label=label,
                    )
                )

    return findings


def scan_directory(
    directory: str | Path,
    *,
    recursive: bool = True,
    emit_warnings: bool = True,
) -> ScanResult:
    """Scan all Python files in *directory* for hard-coded secrets.

    Args:
        directory: Root directory to scan.
        recursive: If ``True`` (default), recurse into subdirectories.
        emit_warnings: If ``True`` (default), emit a
            :class:`~safeconfig.core.exceptions.ExposedKeyWarning` for each
            finding.

    Returns:
        A :class:`ScanResult` summarising the scan.
    """
    root = Path(directory)
    pattern = "**/*.py" if recursive else "*.py"
    result = ScanResult()

    for py_file in sorted(root.glob(pattern)):
        if py_file.suffix in _IGNORE_FILENAMES:
            continue
        if any(part.startswith(".") for part in py_file.parts):
            continue

        result.scanned_files += 1
        file_findings = scan_file(py_file)
        result.findings.extend(file_findings)

        if emit_warnings:
            for finding in file_findings:
                warnings.warn(
                    str(finding),
                    ExposedKeyWarning,
                    stacklevel=2,
                )

    logger.debug(
        "Scan complete: %d file(s), %d finding(s).",
        result.scanned_files,
        len(result.findings),
    )
    return result
