"""Tests for action.yml — GitHub Action composite definition.

Validates the action.yml schema structure, required fields, security
properties, and YAML lint conformance.
"""

from __future__ import annotations

from pathlib import Path

import pytest
import yaml

ACTION_PATH = Path(__file__).resolve().parent.parent / "action.yml"


@pytest.fixture(scope="module")
def action() -> dict:
    """Load and parse action.yml once per module."""
    text = ACTION_PATH.read_text(encoding="utf-8")
    parsed = yaml.safe_load(text)
    assert isinstance(parsed, dict), "action.yml must be a YAML mapping"
    return parsed


class TestActionSchema:
    """Validate action.yml structure against GitHub Actions spec."""

    def test_required_top_level_keys(self, action: dict) -> None:
        for key in ("name", "description", "inputs", "outputs", "runs"):
            assert key in action, f"Missing required top-level key: {key}"

    def test_runs_is_composite(self, action: dict) -> None:
        assert action["runs"]["using"] == "composite"

    def test_branding_present(self, action: dict) -> None:
        assert "branding" in action
        assert "icon" in action["branding"]
        assert "color" in action["branding"]

    def test_command_input_required(self, action: dict) -> None:
        cmd = action["inputs"]["command"]
        assert cmd.get("required") is True

    def test_all_inputs_have_description(self, action: dict) -> None:
        for name, spec in action["inputs"].items():
            assert "description" in spec, f"Input '{name}' missing description"

    def test_all_outputs_have_description(self, action: dict) -> None:
        for name, spec in action["outputs"].items():
            assert "description" in spec, f"Output '{name}' missing description"

    def test_outputs_reference_step_ids(self, action: dict) -> None:
        step_ids = {
            s["id"] for s in action["runs"]["steps"] if "id" in s
        }
        for name, spec in action["outputs"].items():
            value = spec.get("value", "")
            if "steps." in value:
                ref_id = value.split("steps.")[1].split(".")[0]
                assert ref_id in step_ids, (
                    f"Output '{name}' references step '{ref_id}' "
                    f"which is not defined (have: {step_ids})"
                )

    def test_all_steps_have_shell_or_uses(self, action: dict) -> None:
        for i, step in enumerate(action["runs"]["steps"]):
            has_run = "run" in step
            has_uses = "uses" in step
            if has_run:
                assert "shell" in step, (
                    f"Step {i} ('{step.get('name', '?')}') has 'run' but no 'shell'"
                )
            assert has_run or has_uses, (
                f"Step {i} ('{step.get('name', '?')}') has neither 'run' nor 'uses'"
            )

    def test_expected_inputs(self, action: dict) -> None:
        expected = {
            "command", "target", "manifest", "api-key", "source",
            "format", "upload-sarif", "exit-zero", "version",
            "extra-args", "python-version",
        }
        actual = set(action["inputs"].keys())
        assert expected == actual, f"Input mismatch: missing={expected - actual}, extra={actual - expected}"

    def test_expected_outputs(self, action: dict) -> None:
        expected = {"exit-code", "output-file", "sarif-file"}
        actual = set(action["outputs"].keys())
        assert expected == actual


class TestActionSecurity:
    """Verify security properties of the action definition."""

    def test_no_direct_input_interpolation_in_run(self, action: dict) -> None:
        """Inputs must be passed via env:, never interpolated in run: blocks."""
        for step in action["runs"]["steps"]:
            run_block = step.get("run", "")
            assert "${{ inputs." not in run_block, (
                f"Step '{step.get('name', '?')}' interpolates inputs directly in run: block — "
                "use env: context instead to prevent shell injection"
            )

    def test_api_key_masking_step_exists(self, action: dict) -> None:
        mask_steps = [
            s for s in action["runs"]["steps"]
            if "add-mask" in s.get("run", "")
        ]
        assert len(mask_steps) >= 1, "No step masks the API key with ::add-mask::"

    def test_sarif_upload_uses_codeql_action(self, action: dict) -> None:
        sarif_steps = [
            s for s in action["runs"]["steps"]
            if "codeql-action/upload-sarif" in s.get("uses", "")
        ]
        assert len(sarif_steps) >= 1, "No SARIF upload step found"

    def test_sarif_upload_conditional(self, action: dict) -> None:
        """SARIF upload should only run when a SARIF file exists."""
        for step in action["runs"]["steps"]:
            if "codeql-action/upload-sarif" in step.get("uses", ""):
                condition = step.get("if", "")
                assert "sarif-file" in condition, (
                    "SARIF upload step should be conditional on sarif-file output"
                )

    def test_setup_python_is_first_step(self, action: dict) -> None:
        first = action["runs"]["steps"][0]
        assert "setup-python" in first.get("uses", ""), (
            "First step should be actions/setup-python"
        )

    def test_exit_zero_guarded_by_command(self, action: dict) -> None:
        """--exit-zero must only be passed for compare and bulk-scan."""
        main_step = next(s for s in action["runs"]["steps"] if s.get("id") == "run-census")
        run_block = main_step["run"]
        idx = run_block.find('CMD+=("--exit-zero")')
        assert idx != -1, "Expected --exit-zero to be added to CMD array"
        context = run_block[max(0, idx - 200):idx]
        assert "compare" in context and "bulk-scan" in context, (
            "--exit-zero should be guarded by a compare/bulk-scan check"
        )

    def test_sarif_only_on_compare(self, action: dict) -> None:
        """SARIF output is only available on compare (not bulk-scan, scan, integrity)."""
        main_step = next(s for s in action["runs"]["steps"] if s.get("id") == "run-census")
        run_block = main_step["run"]
        bulk_scan_case = run_block[run_block.index("bulk-scan)"):]
        bulk_scan_case = bulk_scan_case[:bulk_scan_case.index(";;")]
        assert "sarif" not in bulk_scan_case, (
            "bulk-scan case should not reference sarif format"
        )

    def test_sarif_format_validated_against_command(self, action: dict) -> None:
        """Explicit format=sarif must be rejected unless command is compare."""
        main_step = next(s for s in action["runs"]["steps"] if s.get("id") == "run-census")
        run_block = main_step["run"]
        assert "EFF_FORMAT" in run_block and "sarif" in run_block
        assert "command=compare" in run_block or '!= "compare"' in run_block, (
            "run block should validate sarif against compare"
        )
        assert "format=sarif is only valid" in run_block, (
            "run block should fail fast on invalid sarif + command combo"
        )

    def test_install_step_does_not_write_github_output(self, action: dict) -> None:
        """Avoid GITHUB_OUTPUT delimiter injection from unversioned census output."""
        install = action["runs"]["steps"][1]
        assert install.get("name") == "Install Census"
        assert "GITHUB_OUTPUT" not in install.get("run", ""), (
            "Install step should not append to GITHUB_OUTPUT unless values are sanitized"
        )
