"""Dockerfile and infrastructure-as-code security scanner.

Rules are loaded from ``dockerfile_rules.json`` via the shared rule engine.
Each rule declares a ``file_type`` (dockerfile, compose, ci_yaml) and a
``check_type`` that determines the scanning strategy.

Add new Dockerfile, Compose, or CI checks by editing the JSON file.
"""

from __future__ import annotations

import re
from pathlib import Path
from typing import Any

from maeris_mcp.scanners.rule_engine import build_finding, load_rules, render_template, resolve_severity

_RULES_PATH = Path(__file__).parent / "dockerfile_rules.json"

# ---------------------------------------------------------------------------
# File-type routing regexes
# ---------------------------------------------------------------------------

_IS_DOCKERFILE_RE = re.compile(r"(^|[\\/])Dockerfile(\..+)?$", re.IGNORECASE)
_IS_COMPOSE_RE = re.compile(r"(^|[\\/])docker-compose[^/\\]*\.ya?ml$", re.IGNORECASE)
_IS_CI_YAML_RE = re.compile(
    r"(^|[\\/])(\.circleci[\\/]config|\.travis|\.gitlab-ci|Jenkinsfile|"
    r"bitbucket-pipelines|azure-pipelines|cloudbuild)\.ya?ml$"
    r"|(^|[\\/])\.github[\\/]workflows[\\/][^/\\]+\.ya?ml$",
    re.IGNORECASE,
)
_IS_JENKINSFILE_RE = re.compile(r"(^|[\\/])Jenkinsfile(\..+)?$", re.IGNORECASE)


def _detect_file_type(file_path: str) -> str | None:
    norm = file_path.replace("\\", "/")
    if _IS_DOCKERFILE_RE.search(norm):
        return "dockerfile"
    if _IS_COMPOSE_RE.search(norm):
        return "compose"
    if _IS_CI_YAML_RE.search(norm) or _IS_JENKINSFILE_RE.search(norm):
        return "ci_yaml"
    return None


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _line_number(content: str, pos: int) -> int:
    return content[:pos].count("\n") + 1


def _is_variable_ref(value: str) -> bool:
    v = value.strip().strip('"').strip("'")
    if re.match(r"^\$\{?[A-Za-z_]", v):
        return True
    if re.match(r"^https?://[^:@\s\"']+$", v):
        return True
    return False


_MAJOR_ONLY_TAG_RE = re.compile(r"^\d+(-\S+)?$")


# ---------------------------------------------------------------------------
# Check-type handlers
# ---------------------------------------------------------------------------

def _handle_secret_env(rule: dict, file_path: str, content: str) -> list[dict[str, Any]]:
    """Detect hardcoded secrets in ENV instructions."""
    findings: list[dict[str, Any]] = []
    pattern: re.Pattern = rule["_pattern"]
    file_name = Path(file_path).name

    for m in pattern.finditer(content):
        name = m.group("name")
        value = m.group("value").strip()
        if _is_variable_ref(value):
            continue
        line = _line_number(content, m.start())
        ctx = {
            "file_path": file_path,
            "file_name": file_name,
            "line": line,
            "name": name,
            "value": value,
            "snippet": m.group(0).strip()[:120],
        }
        findings.append(build_finding(rule, ctx))

    return findings


def _handle_unpinned_from(rule: dict, file_path: str, content: str) -> list[dict[str, Any]]:
    """Detect unpinned or floating-tag FROM instructions."""
    findings: list[dict[str, Any]] = []
    pattern: re.Pattern = rule["_pattern"]
    file_name = Path(file_path).name

    for m in pattern.finditer(content):
        image = m.group("image")
        tag = m.group("tag") or ""
        if image.lower() in ("scratch",):
            continue

        is_unpinned = not tag or tag.lower() == "latest"
        is_floating = bool(_MAJOR_ONLY_TAG_RE.match(tag))

        if not (is_unpinned or is_floating):
            continue

        line = _line_number(content, m.start())

        if is_unpinned:
            tag_display = f":{tag}" if tag else " (no tag \u2014 implicit latest)"
            tag_colon = (":" + tag) if tag else ""
            title_tpl = rule.get("title_template_unpinned", rule.get("title_template", ""))
            desc_tpl = rule.get("description_template_unpinned", rule.get("description_template", ""))
        else:
            tag_display = f":{tag}"
            tag_colon = f":{tag}"
            title_tpl = rule.get("title_template_floating", rule.get("title_template", ""))
            desc_tpl = rule.get("description_template_floating", rule.get("description_template", ""))

        ctx: dict[str, Any] = {
            "file_path": file_path,
            "file_name": file_name,
            "line": line,
            "image": image,
            "tag": tag,
            "tag_display": tag_display,
            "tag_colon": tag_colon,
            "is_unpinned": is_unpinned,
            "snippet": m.group(0).strip()[:120],
        }

        severity = resolve_severity(rule, ctx)
        title = render_template(title_tpl, ctx)
        description = render_template(desc_tpl, ctx)
        recommendation = render_template(rule.get("recommendation_template", rule.get("recommendation", "")), ctx)

        findings.append({
            "ruleId": rule.get("id", ""),
            "title": title,
            "description": description,
            "severity": severity,
            "confidence": rule.get("confidence", "high"),
            "owaspCategory": rule.get("owasp", ""),
            "cweId": rule.get("cwe", ""),
            "filePath": file_path,
            "lineStart": line,
            "codeSnippet": ctx["snippet"],
            "recommendation": recommendation,
            "referenceLinks": rule.get("references", []),
        })

    return findings


def _handle_missing_user(rule: dict, file_path: str, content: str) -> list[dict[str, Any]]:
    """Detect Dockerfiles that don't set a non-root USER."""
    user_src = rule.get("user_pattern", r"^[ \t]*USER[ \t]+(?P<user>\S+)")
    user_re = re.compile(user_src, re.IGNORECASE | re.MULTILINE)

    user_matches = list(user_re.finditer(content))
    non_root_user = any(
        m.group("user").lower() not in ("root", "0") for m in user_matches
    )
    if non_root_user:
        return []

    file_name = Path(file_path).name
    ctx = {
        "file_path": file_path,
        "file_name": file_name,
        "line": 1,
        "snippet": "",
    }
    return [build_finding(rule, ctx)]


def _handle_regex_match(rule: dict, file_path: str, content: str) -> list[dict[str, Any]]:
    """Generic regex-match check — finds all occurrences of the pattern."""
    findings: list[dict[str, Any]] = []
    pattern: re.Pattern = rule["_pattern"]
    file_name = Path(file_path).name
    seen_lines: set[int] = set()

    for m in pattern.finditer(content):
        line = _line_number(content, m.start())
        if line in seen_lines:
            continue
        seen_lines.add(line)
        ctx: dict[str, Any] = {
            "file_path": file_path,
            "file_name": file_name,
            "line": line,
            "snippet": m.group(0).strip()[:120],
        }
        # Extract named groups into context for template use
        if m.groupdict():
            ctx.update(m.groupdict())
        findings.append(build_finding(rule, ctx))

    return findings


_CHECK_HANDLERS = {
    "secret_env": _handle_secret_env,
    "unpinned_from": _handle_unpinned_from,
    "missing_user": _handle_missing_user,
    "regex_match": _handle_regex_match,
}


# ---------------------------------------------------------------------------
# Per-file scanning
# ---------------------------------------------------------------------------

def _scan_file(file_path: str, content: str, file_type: str, rules: list[dict]) -> list[dict[str, Any]]:
    findings: list[dict[str, Any]] = []

    for rule in rules:
        if rule.get("file_type") != file_type:
            continue
        check_type = rule.get("check_type", "regex_match")
        handler = _CHECK_HANDLERS.get(check_type)
        if handler:
            findings.extend(handler(rule, file_path, content))

    return findings


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

def scan_dockerfile_secrets(
    file_contents: dict[str, str],
) -> tuple[list[dict[str, Any]], str | None]:
    """Scan Dockerfile and infra-as-code files for security issues.

    Args:
        file_contents: Dict mapping relative file paths to their contents.

    Returns:
        (findings, error) — findings is the list of issue dicts;
        error is a human-readable message on failure, or None on success.
        findings may be empty even on success (no issues found).
    """
    try:
        rules = load_rules(_RULES_PATH)
    except Exception as exc:
        return [], f"Dockerfile/infra scanner failed to load rules: {exc}"

    findings: list[dict[str, Any]] = []

    try:
        for file_path, content in file_contents.items():
            if not content:
                continue
            file_type = _detect_file_type(file_path)
            if not file_type:
                continue
            findings.extend(_scan_file(file_path, content, file_type, rules))
    except Exception as exc:
        return [], f"Dockerfile/infra scanner error: {exc}"

    return findings, None
