"""Semantic secret detection — finds secrets by key name context.

Analyzes variable/key names in configuration files to identify values
that are likely secrets, even when they have no distinctive format pattern.
Two-tier classification: high-signal keys always send to model, ambiguous
keys require value evidence (entropy/length) first.

Disabled by default; enabled via ``--semantic`` flag or ``semantic=True``.
"""

from __future__ import annotations

import importlib.resources
import re
from dataclasses import dataclass
from pathlib import Path

import yaml

# ---------------------------------------------------------------------------
# Key classification data — loaded from patterns/semantic.yaml
# ---------------------------------------------------------------------------

_HIGH_SIGNAL_EXACT: set[str] = set()
_HIGH_SIGNAL_SUFFIXES: tuple[str, ...] = ()
_HIGH_SIGNAL_PREFIXES: tuple[str, ...] = ()
_AMBIGUOUS_EXACT: set[str] = set()
_AMBIGUOUS_SUFFIXES: tuple[str, ...] = ()
_SAFE_KEYS: set[str] = set()
_PLACEHOLDER_EXACT: set[str] = set()
_PLACEHOLDER_PREFIXES: tuple[str, ...] = ()

# Patterns that match WHOLE-VALUE environment variable references only.
_ENV_REF_PATTERNS = [
    re.compile(r"^\$\{[A-Za-z_][A-Za-z0-9_]*\}$"),  # ${VAR}
    re.compile(r"^\$[A-Z_][A-Z0-9_]*$"),  # $VAR (uppercase only — allows $uperSecret)
    re.compile(r"^%\([A-Za-z_][A-Za-z0-9_]*\)s$"),  # %(var)s (Python format)
    re.compile(r"^%[A-Za-z_][A-Za-z0-9_]*%$"),  # %VAR% (Windows)
]

# Safety valve for pathological files.
MAX_CANDIDATES_PER_FILE = 200


def _load_key_lists() -> None:
    """Load key classification data from semantic.yaml."""
    global _HIGH_SIGNAL_EXACT, _HIGH_SIGNAL_SUFFIXES, _HIGH_SIGNAL_PREFIXES
    global _AMBIGUOUS_EXACT, _AMBIGUOUS_SUFFIXES, _SAFE_KEYS
    global _PLACEHOLDER_EXACT, _PLACEHOLDER_PREFIXES

    path = _find_semantic_yaml()
    raw = yaml.safe_load(path.read_text(encoding="utf-8"))

    _HIGH_SIGNAL_EXACT = set(raw.get("high_signal_exact", []))
    _HIGH_SIGNAL_SUFFIXES = tuple(raw.get("high_signal_suffixes", []))
    _HIGH_SIGNAL_PREFIXES = tuple(raw.get("high_signal_prefixes", []))
    _AMBIGUOUS_EXACT = set(raw.get("ambiguous_exact", []))
    _AMBIGUOUS_SUFFIXES = tuple(raw.get("ambiguous_suffixes", []))
    _SAFE_KEYS = set(raw.get("safe_keys", []))
    _PLACEHOLDER_EXACT = set(raw.get("placeholder_exact", []))
    _PLACEHOLDER_PREFIXES = tuple(raw.get("placeholder_prefixes", []))


def _find_semantic_yaml() -> Path:
    """Locate semantic.yaml — bundled package data, then repo root."""
    try:
        data_dir = importlib.resources.files("argus_nano.data")
        p = data_dir / "semantic.yaml"
        with importlib.resources.as_file(p) as real:
            if real.exists():
                return Path(real)
    except (ModuleNotFoundError, FileNotFoundError, TypeError):
        pass
    # Fallback: repo root patterns/
    repo = Path(__file__).resolve().parent.parent.parent
    candidate = repo / "patterns" / "semantic.yaml"
    if candidate.exists():
        return candidate
    raise FileNotFoundError("Cannot find patterns/semantic.yaml")


# ---------------------------------------------------------------------------
# Key classification
# ---------------------------------------------------------------------------


def classify_key(key_name: str) -> tuple[bool, bool]:
    """Classify a key name as sensitive.

    Returns ``(is_sensitive, is_ambiguous)``:
    - High-signal: ``(True, False)`` — always send to model
    - Ambiguous:   ``(True, True)``  — require value evidence
    - Safe:        ``(False, False)`` — skip entirely
    """
    if not _HIGH_SIGNAL_EXACT:
        _load_key_lists()

    key_lower = key_name.lower()

    # Safe keys take priority
    if key_lower in _SAFE_KEYS:
        return False, False

    # High-signal exact match
    if key_lower in _HIGH_SIGNAL_EXACT:
        return True, False

    # High-signal suffix/prefix
    if any(key_lower.endswith(s) for s in _HIGH_SIGNAL_SUFFIXES):
        return True, False
    if any(key_lower.startswith(p) for p in _HIGH_SIGNAL_PREFIXES):
        return True, False

    # Ambiguous exact match
    if key_lower in _AMBIGUOUS_EXACT:
        return True, True

    # Ambiguous suffix
    if any(key_lower.endswith(s) for s in _AMBIGUOUS_SUFFIXES):
        return True, True

    return False, False


# ---------------------------------------------------------------------------
# Value evidence check (for ambiguous keys)
# ---------------------------------------------------------------------------


def has_value_evidence(value: str) -> bool:
    """Check if a value has enough entropy/structure to be a real secret.

    Used for ambiguous keys where the key name alone isn't sufficient.
    Requires 3+ character classes to distinguish random secrets from
    config values like ``us-east-1`` or ``application/json``.
    """
    v = value.strip()

    if len(v) < 8:
        return False

    # Connection string with embedded credentials (user:pass@host)
    if "://" in v and "@" in v:
        return True

    # Long hex strings (>=32 chars, only 0-9a-fA-F) are likely crypto keys
    if len(v) >= 32 and all(c in "0123456789abcdefABCDEF" for c in v):
        return True

    has_upper = any(c.isupper() for c in v)
    has_lower = any(c.islower() for c in v)
    has_digit = any(c.isdigit() for c in v)
    # Exclude common separators (-_./: ) from "special" — they appear in
    # config values like regions, MIME types, and paths, not just secrets.
    has_special = any(c in "!@#$%^&*()+=[]{}|\\;,<>?~`" for c in v)
    classes = sum([has_upper, has_lower, has_digit, has_special])
    if classes < 3:
        return False

    # Natural language phrases
    if " " in v and len(v.split()) > 3:
        return False

    # URL without credentials
    if v.startswith("http://") or v.startswith("https://"):
        if "@" not in v:
            return False

    return True


# ---------------------------------------------------------------------------
# Placeholder detection
# ---------------------------------------------------------------------------


def is_placeholder(value: str) -> bool:
    """Determine if a value is clearly a placeholder, not a real secret."""
    v = value.strip().strip('"').strip("'")

    if len(v) < 3:
        return True

    # Whole-value environment variable references ONLY
    if any(p.match(v) for p in _ENV_REF_PATTERNS):
        return True

    if not _PLACEHOLDER_EXACT:
        _load_key_lists()

    # Exact placeholder match
    if v.lower() in _PLACEHOLDER_EXACT:
        return True

    # Placeholder prefix match
    if any(v.lower().startswith(p) for p in _PLACEHOLDER_PREFIXES):
        return True

    # Embedded variable references (e.g. connection strings with ${PASSWORD})
    if "${" in v or "{{" in v:
        return True

    # Template markers
    if v.startswith("<") and v.endswith(">"):
        return True
    if v.startswith("{") and v.endswith("}") and " " not in v and len(v) < 50:
        return True

    # All same character (after removing separators)
    stripped = v.replace("-", "").replace("_", "").replace(".", "")
    if len(stripped) > 0 and len(set(stripped)) <= 1:
        return True

    return False


# ---------------------------------------------------------------------------
# File format parsers
# ---------------------------------------------------------------------------


def _find_tf_variable_name(lines: list[str], default_line_idx: int) -> str | None:
    """Walk backwards from a ``default = ...`` line to find enclosing
    ``variable "name"`` or ``locals`` block in Terraform.
    """
    for j in range(default_line_idx - 1, max(default_line_idx - 20, -1), -1):
        s = lines[j].strip()
        # variable "token" {
        m = re.match(r'variable\s+"([^"]+)"', s)
        if m:
            return m.group(1)
        # locals {  — inside locals block, the key IS the variable name
        if s.startswith("locals"):
            return None  # key from the assignment itself is correct
        # Hit another block boundary
        if s == "}" or re.match(r"(resource|data|output|module)\s+", s):
            break
    return None


def extract_assignments(lines: list[str], file_path: str) -> list[tuple[str, str, int]]:
    """Extract ``(key_name, value, line_number)`` from file content."""
    ext = Path(file_path).suffix.lower()
    name = Path(file_path).name.lower()

    results: list[tuple[str, str, int]] = []

    for i, line in enumerate(lines, 1):
        if len(results) >= MAX_CANDIDATES_PER_FILE:
            break

        stripped = line.strip()
        if not stripped:
            continue

        # Commented-out lines: strip the comment prefix and re-parse.
        # Secrets in comments are still secrets in source control.
        if stripped.startswith("#"):
            stripped = stripped[1:].lstrip()
            line = stripped
        elif stripped.startswith("//"):
            stripped = stripped[2:].lstrip()
            line = stripped

        if not stripped:
            continue

        # .env / shell
        if ext in (".env", ".sh", ".bash", ".zsh") or name in (
            ".env",
            ".env.local",
            ".env.production",
            ".env.staging",
            ".env.development",
            ".env.docker",
        ):
            m = re.match(r"(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.+)", stripped)
            if m:
                results.append(
                    (
                        m.group(1),
                        m.group(2).strip().strip('"').strip("'"),
                        i,
                    )
                )
            continue

        # YAML
        if ext in (".yml", ".yaml"):
            # Docker-compose environment list: - KEY=value
            m = re.match(r"\s*-\s+([A-Za-z_][A-Za-z0-9_]*)=(.+)", stripped)
            if m:
                results.append(
                    (
                        m.group(1),
                        m.group(2).strip().strip('"').strip("'"),
                        i,
                    )
                )
                continue

            # Standard YAML: key: value
            m = re.match(r"\s*([A-Za-z_][A-Za-z0-9_.-]*):\s+(.+)", line)
            if m:
                val = m.group(2).strip()
                # Extract quoted content or strip inline comments
                if val.startswith('"') and '"' in val[1:]:
                    val = val[1 : val.index('"', 1)]
                elif val.startswith("'") and "'" in val[1:]:
                    val = val[1 : val.index("'", 1)]
                else:
                    # Unquoted: strip inline YAML comments
                    comment_idx = val.find(" #")
                    if comment_idx >= 0:
                        val = val[:comment_idx].rstrip()
                # Block scalar indicator
                if val in ("|", ">", "|+", "|-", ">+", ">-"):
                    block_indent = len(line) - len(line.lstrip())
                    block_lines: list[str] = []
                    for j in range(i, min(i + 20, len(lines))):
                        next_line = lines[j]
                        if next_line.strip() == "":
                            break
                        next_indent = len(next_line) - len(next_line.lstrip())
                        if next_indent <= block_indent:
                            break
                        block_lines.append(next_line.strip())
                    if block_lines:
                        results.append((m.group(1), "\n".join(block_lines), i))
                else:
                    results.append((m.group(1), val, i))
            continue

        # JSON
        if ext == ".json":
            m = re.match(r'\s*"([^"]+)"\s*:\s*"([^"]*)"', stripped)
            if m:
                results.append((m.group(1), m.group(2), i))
            continue

        # TOML
        if ext == ".toml":
            m = re.match(r"([A-Za-z_][A-Za-z0-9_.-]*)\s*=\s*(.+)", stripped)
            if m:
                results.append(
                    (
                        m.group(1),
                        m.group(2).strip().strip('"').strip("'"),
                        i,
                    )
                )
            continue

        # Python
        if ext == ".py":
            # Standard assignment: KEY = "value"
            m = re.match(r'([A-Za-z_][A-Za-z0-9_]*)\s*=\s*["\'](.+?)["\']', stripped)
            if m:
                results.append((m.group(1), m.group(2), i))
                continue
            # Dict literal: "key": "value"
            m = re.search(
                r'["\']([A-Za-z_][A-Za-z0-9_]*)["\']:\s*["\'](.+?)["\']',
                stripped,
            )
            if m:
                results.append((m.group(1), m.group(2), i))
                continue
            # dict() constructor: key="value"
            m = re.search(r'([A-Za-z_][A-Za-z0-9_]*)=["\'](.+?)["\']', stripped)
            if m:
                results.append((m.group(1), m.group(2), i))
            continue

        # Terraform
        if ext == ".tf":
            # variable "name" { ... default = "value" ... }
            # locals { name = "value" ... }
            m = re.match(r'\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*"([^"]*)"', stripped)
            if m:
                key = m.group(1)
                val = m.group(2)
                # If key is "default", look for enclosing variable block
                if key == "default":
                    var_name = _find_tf_variable_name(lines, i - 1)
                    if var_name:
                        key = var_name
                results.append((key, val, i))
            continue

        # Dockerfile
        if ext in (".dockerfile",) or name in (
            "dockerfile",
            "docker-compose.yml",
            "docker-compose.yaml",
        ):
            m = re.match(r"ENV\s+([A-Za-z_][A-Za-z0-9_]*)[\s=](.+)", stripped)
            if m:
                results.append(
                    (
                        m.group(1),
                        m.group(2).strip().strip('"').strip("'"),
                        i,
                    )
                )
                continue
            m = re.match(r"([A-Za-z_][A-Za-z0-9_]*)=(.+)", stripped)
            if m:
                results.append(
                    (
                        m.group(1),
                        m.group(2).strip().strip('"').strip("'"),
                        i,
                    )
                )
            continue

        # Skip unsupported file types (source code like .go, .js, .ts, .java
        # has too many variable names matching secret keys with non-literal values)

    return results


# ---------------------------------------------------------------------------
# Provider derivation from key names
# ---------------------------------------------------------------------------

# Suffixes to strip when deriving a provider from a key name.
_SECRET_SUFFIXES = (
    "_PASSWORD",
    "_PASSWD",
    "_SECRET",
    "_SECRET_KEY",
    "_SECRETKEY",
    "_KEY",
    "_TOKEN",
    "_AUTH",
    "_AUTH_TOKEN",
    "_API_KEY",
    "_APIKEY",
    "_ACCESS_KEY",
    "_PRIVATE_KEY",
    "_CREDENTIALS",
    "_CREDENTIAL",
    "_CONN_STRING",
    "_CONNECTION_STRING",
    "_DSN",
)

# Map cleaned prefixes (after suffix stripping) to friendly provider names.
_PREFIX_TO_PROVIDER: dict[str, str] = {
    "POSTGRES": "postgres",
    "POSTGRESQL": "postgres",
    "PG": "postgres",
    "PGBOUNCER": "postgres",
    "MYSQL": "mysql",
    "MARIADB": "mysql",
    "REDIS": "redis",
    "MONGO": "mongodb",
    "MONGODB": "mongodb",
    "ELASTICSEARCH": "elasticsearch",
    "ELASTIC": "elasticsearch",
    "RABBITMQ": "rabbitmq",
    "AMQP": "rabbitmq",
    "MEMCACHED": "memcached",
    "SQLITE": "sqlite",
    "MSSQL": "mssql",
    "SQLSERVER": "mssql",
    "DB": "database",
    "DATABASE": "database",
    "SMTP": "smtp",
    "MAIL": "smtp",
    "EMAIL": "smtp",
    "SENDGRID": "sendgrid",
    "MAILGUN": "mailgun",
    "AWS": "aws",
    "AMAZON": "aws",
    "GCP": "gcp",
    "GOOGLE": "gcp",
    "GCLOUD": "gcp",
    "AZURE": "azure",
    "GITHUB": "github",
    "GH": "github",
    "GITLAB": "gitlab",
    "BITBUCKET": "bitbucket",
    "DOCKER": "docker",
    "HEROKU": "heroku",
    "DIGITALOCEAN": "digitalocean",
    "DO": "digitalocean",
    "CLOUDFLARE": "cloudflare",
    "CF": "cloudflare",
    "STRIPE": "stripe",
    "TWILIO": "twilio",
    "SLACK": "slack",
    "DISCORD": "discord",
    "TELEGRAM": "telegram",
    "OPENAI": "openai",
    "ANTHROPIC": "anthropic",
    "DATADOG": "datadog",
    "DD": "datadog",
    "SENTRY": "sentry",
    "NEWRELIC": "newrelic",
    "NEW_RELIC": "newrelic",
    "PAGERDUTY": "pagerduty",
    "JENKINS": "jenkins",
    "CIRCLECI": "circleci",
    "TRAVIS": "travis",
    "NPM": "npm",
    "PYPI": "pypi",
    "NUGET": "nuget",
    "SONAR": "sonarqube",
    "SONARQUBE": "sonarqube",
    "VAULT": "hashicorp-vault",
    "HASHICORP": "hashicorp-vault",
    "TERRAFORM": "terraform",
    "TF": "terraform",
    "VERCEL": "vercel",
    "NETLIFY": "netlify",
    "SUPABASE": "supabase",
    "FIREBASE": "firebase",
    "SHOPIFY": "shopify",
    "LINEAR": "linear",
    "NOTION": "notion",
    "JIRA": "jira",
    "ATLASSIAN": "atlassian",
    "DJANGO": "django",
    "FLASK": "flask",
    "RAILS": "rails",
    "LARAVEL": "laravel",
    "SPRING": "spring",
    "JWT": "jwt",
    "OAUTH": "oauth",
    "LDAP": "ldap",
    "SSH": "ssh",
    "SSL": "ssl",
    "TLS": "ssl",
    "FTP": "ftp",
    "SFTP": "ftp",
    "S3": "aws-s3",
    "SQS": "aws-sqs",
    "SNS": "aws-sns",
    "APP": "application",
}


def derive_provider(key_name: str) -> str:
    """Derive a provider name from a semantic key name.

    Strips common secret suffixes (_PASSWORD, _SECRET, _KEY, _TOKEN, etc.)
    and maps the remaining prefix to a known provider. Returns "credential"
    if no recognizable prefix remains.
    """
    upper = key_name.upper().strip()

    # Strip known suffixes (longest first for _SECRET_KEY before _KEY).
    prefix = upper
    for suffix in sorted(_SECRET_SUFFIXES, key=len, reverse=True):
        if prefix.endswith(suffix) and len(prefix) > len(suffix):
            prefix = prefix[: -len(suffix)]
            break

    # Strip leading underscores / trailing underscores
    prefix = prefix.strip("_")

    if not prefix:
        return "credential"

    # Bare secret keywords with no service prefix
    bare_keywords = {
        "PASSWORD",
        "PASSWD",
        "SECRET",
        "SECRET_KEY",
        "SECRETKEY",
        "KEY",
        "TOKEN",
        "AUTH",
        "AUTH_TOKEN",
        "API_KEY",
        "APIKEY",
        "ACCESS_KEY",
        "PRIVATE_KEY",
        "CREDENTIALS",
        "CREDENTIAL",
        "CONN_STRING",
        "CONNECTION_STRING",
        "DSN",
    }
    if prefix in bare_keywords:
        return "credential"

    # Direct lookup
    if prefix in _PREFIX_TO_PROVIDER:
        return _PREFIX_TO_PROVIDER[prefix]

    # Try without common prefixes like APP_, SERVICE_, etc.
    for strip in ("APP_", "SERVICE_", "MY_", "MAIN_", "PRIMARY_", "DEFAULT_"):
        if prefix.startswith(strip) and len(prefix) > len(strip):
            remainder = prefix[len(strip) :]
            if remainder in _PREFIX_TO_PROVIDER:
                return _PREFIX_TO_PROVIDER[remainder]

    # Return lowercased prefix as-is (e.g. COCKROACHDB → cockroachdb)
    return prefix.lower().replace("_", "-")


# ---------------------------------------------------------------------------
# Data class for semantic findings
# ---------------------------------------------------------------------------


@dataclass
class SemanticMatch:
    """A potential secret identified by key-name context."""

    key_name: str
    value: str
    line_number: int
    detection_type: str = "semantic"
    is_ambiguous: bool = False


# ---------------------------------------------------------------------------
# Main scanner class
# ---------------------------------------------------------------------------


class SemanticScanner:
    """Finds secrets by analyzing key names rather than value formats."""

    def __init__(self) -> None:
        # Eagerly load key lists so classify_key is ready.
        if not _HIGH_SIGNAL_EXACT:
            _load_key_lists()

    def scan_file_content(self, lines: list[str], file_path: str) -> list[SemanticMatch]:
        """Scan file content for semantic secret candidates."""
        assignments = extract_assignments(lines, file_path)
        candidates: list[SemanticMatch] = []

        for key_name, value, line_num in assignments:
            is_sensitive, is_ambiguous = classify_key(key_name)

            if not is_sensitive:
                continue

            if is_placeholder(value):
                continue

            if is_ambiguous and not has_value_evidence(value):
                continue

            candidates.append(
                SemanticMatch(
                    key_name=key_name,
                    value=value,
                    line_number=line_num,
                    is_ambiguous=is_ambiguous,
                )
            )

        return candidates
