"""GitLab CI YAML parser.

Parses .gitlab-ci.yml (and any included local files) into a normalized
intermediate representation used by the CI/CD analyzer.
"""

from __future__ import annotations

import re
from pathlib import Path
from typing import Any, cast

import yaml


class GitLabCIParser:
    """
    Parses a .gitlab-ci.yml file and returns a structured dict.

    The output structure:
    {
        "raw": <full parsed YAML dict>,
        "jobs": {job_name: {job_dict}},
        "stages": [str],
        "workflow": {workflow_dict or None},
        "global_variables": {var_name: var_value_or_dict},
        "include": [include_entry],
        "default": {default_dict or None},
        "included_files": [{"path": str, "content": raw_yaml_str}],
        "parse_errors": [str],
    }
    """

    # Variables that carry user-influenced values (injection risk)
    USER_CONTROLLED_VARS = {
        "CI_COMMIT_REF_NAME",
        "CI_COMMIT_BRANCH",
        "CI_COMMIT_MESSAGE",
        "CI_COMMIT_TITLE",
        "CI_MERGE_REQUEST_SOURCE_BRANCH_NAME",
        "CI_MERGE_REQUEST_TITLE",
        "CI_MERGE_REQUEST_DESCRIPTION",
    }

    def __init__(self, repo_path: Path, deep_include: bool = False) -> None:
        self.repo_path = repo_path
        self.deep_include = deep_include

    def parse(self) -> dict[str, Any]:
        ci_file = self.repo_path / ".gitlab-ci.yml"
        result: dict[str, Any] = {
            "ci_file_found": ci_file.exists(),
            "ci_file_path": str(ci_file),
            "raw": {},
            "jobs": {},
            "stages": [],
            "workflow": None,
            "global_variables": {},
            "include": [],
            "default": None,
            "included_files": [],
            "runner_config": None,
            "parse_errors": [],
        }

        if not ci_file.exists():
            return result

        try:
            raw = yaml.safe_load(ci_file.read_text(encoding="utf-8")) or {}
        except yaml.YAMLError as exc:
            result["parse_errors"].append(f".gitlab-ci.yml: {exc}")
            return result

        result["raw"] = raw
        result["stages"] = raw.get("stages", [])
        result["workflow"] = raw.get("workflow")
        result["global_variables"] = self._extract_variables(raw.get("variables", {}))
        result["include"] = self._normalise_include(raw.get("include", []))
        result["default"] = raw.get("default")

        # Extract jobs (everything that isn't a reserved top-level key)
        reserved = {"stages", "workflow", "variables", "include", "default",
                    "image", "services", "before_script", "after_script",
                    "cache", "artifacts"}
        jobs: dict[str, Any] = {}
        for key, value in raw.items():
            if key not in reserved and isinstance(value, dict) and not key.startswith("."):
                jobs[key] = value
        result["jobs"] = jobs

        # Resolve local includes
        if self.deep_include:
            result["included_files"] = self._resolve_local_includes(
                result["include"], result["parse_errors"]
            )
            # Merge jobs from included files
            for inc in result["included_files"]:
                for key, value in inc.get("parsed", {}).items():
                    if key not in reserved and isinstance(value, dict) and not key.startswith("."):
                        jobs[f"{inc['path']}::{key}"] = value

        # Parse runner config if present
        result["runner_config"] = self._parse_runner_config()

        return result

    def _extract_variables(self, variables_block: Any) -> dict[str, Any]:
        """Normalise the variables block into a uniform dict."""
        if isinstance(variables_block, dict):
            return variables_block
        return {}

    def _normalise_include(self, include_val: Any) -> list[dict[str, Any]]:
        """Normalise include: to a list of dicts."""
        if not include_val:
            return []
        if isinstance(include_val, str):
            return [{"local": include_val}]
        if isinstance(include_val, dict):
            return [include_val]
        if isinstance(include_val, list):
            result = []
            for entry in include_val:
                if isinstance(entry, str):
                    result.append({"local": entry})
                elif isinstance(entry, dict):
                    result.append(entry)
            return result
        return []

    def _resolve_local_includes(
        self, includes: list[dict[str, Any]], errors: list[str]
    ) -> list[dict[str, Any]]:
        resolved: list[dict[str, Any]] = []
        for entry in includes:
            local_path = entry.get("local")
            if not local_path:
                continue
            # GitLab local paths start with /; strip it for filesystem resolution
            rel = local_path.lstrip("/")
            full = self.repo_path / rel
            if not full.exists():
                errors.append(f"include: local: '{local_path}' not found")
                continue
            try:
                raw_text = full.read_text(encoding="utf-8")
                parsed = yaml.safe_load(raw_text) or {}
                resolved.append({"path": local_path, "content": raw_text, "parsed": parsed})
            except yaml.YAMLError as exc:
                errors.append(f"include: local: '{local_path}': {exc}")
        return resolved

    def _parse_runner_config(self) -> dict[str, Any] | None:
        """Parse .gitlab-runner/config.toml if present."""
        try:
            import toml
        except ImportError:
            return None

        config_toml = self.repo_path / ".gitlab-runner" / "config.toml"
        if not config_toml.exists():
            return None
        try:
            return cast(dict[str, Any], toml.loads(config_toml.read_text(encoding="utf-8")))
        except Exception:  # noqa: BLE001
            return None

    # ------------------------------------------------------------------
    # Static helpers used by the analyzer
    # ------------------------------------------------------------------

    @staticmethod
    def job_images(job: dict[str, Any]) -> list[str]:
        """Extract all image references from a job (image + services)."""
        images: list[str] = []
        img = job.get("image")
        if isinstance(img, str):
            images.append(img)
        elif isinstance(img, dict):
            images.append(img.get("name", ""))
        for svc in job.get("services", []):
            if isinstance(svc, str):
                images.append(svc)
            elif isinstance(svc, dict):
                images.append(svc.get("name", ""))
        return [i for i in images if i]

    @staticmethod
    def image_is_unpinned(image_ref: str) -> tuple[bool, str]:
        """
        Return (is_unpinned, reason).
        Pinned = includes @sha256: digest.
        """
        if "@sha256:" in image_ref:
            return False, ""
        if image_ref.endswith(":latest") or ":" not in image_ref:
            return True, "uses :latest tag"
        return True, "tag-only (not pinned to digest)"

    @staticmethod
    def scripts_contain_user_var_injection(scripts: list[str]) -> list[str]:
        """
        Return list of lines that interpolate user-controlled CI variables
        directly into shell commands without quoting.
        """
        risky: list[str] = []
        user_vars = GitLabCIParser.USER_CONTROLLED_VARS
        for line in scripts:
            for var in user_vars:
                # Unquoted: $VAR or ${VAR} not inside double-quotes
                if re.search(rf'\${{{var}}}|\${var}(?!")', line):
                    risky.append(line.strip())
                    break
        return risky

    @staticmethod
    def scripts_contain_curl_pipe_bash(scripts: list[str]) -> list[str]:
        """Return lines matching curl|wget piped to shell execution."""
        pattern = re.compile(
            r'(curl|wget)\s+.*\|\s*(ba)?sh',
            re.IGNORECASE,
        )
        return [s.strip() for s in scripts if pattern.search(s)]

    @staticmethod
    def extract_all_scripts(job: dict[str, Any]) -> list[str]:
        """Flatten before_script + script + after_script into a single list."""
        lines: list[str] = []
        for key in ("before_script", "script", "after_script"):
            val = job.get(key, [])
            if isinstance(val, list):
                lines.extend(str(item) for item in val)
            elif isinstance(val, str):
                lines.append(val)
        return lines

    @staticmethod
    def job_looks_like_deploy(name: str, job: dict[str, Any]) -> bool:
        """Heuristic: does this job appear to be a deployment job?"""
        deploy_keywords = {
            "deploy", "release", "publish", "push", "ship",
            "rollout", "helm", "kubectl", "terraform apply",
            "ansible-playbook", "serverless deploy",
        }
        name_lower = name.lower()
        if any(kw in name_lower for kw in deploy_keywords):
            return True
        scripts = GitLabCIParser.extract_all_scripts(job)
        combined = " ".join(scripts).lower()
        return any(kw in combined for kw in {"kubectl apply", "helm upgrade", "terraform apply"})

    @staticmethod
    def job_looks_like_security_scan(name: str) -> bool:
        scan_keywords = {
            "sast", "dast", "secret", "dependency", "container",
            "scan", "security", "vuln", "trivy", "semgrep",
        }
        name_lower = name.lower()
        return any(kw in name_lower for kw in scan_keywords)
