"""Tests to verify GitHub Actions workflow YAML files are valid and complete."""

from pathlib import Path
from typing import Any

import yaml

REPO_ROOT = Path(__file__).resolve().parent.parent


class TestCIWorkflow:
    """Verify .github/workflows/ci.yml structure."""

    workflow: Any = None

    def setup_method(self):
        ci_path = REPO_ROOT / ".github" / "workflows" / "ci.yml"
        assert ci_path.exists(), f"CI workflow not found at {ci_path}"
        with open(ci_path) as f:
            self.workflow = yaml.safe_load(f)

    def test_has_name(self):
        assert "name" in self.workflow

    def test_triggers_on_push_to_main(self):
        # YAML parses bare `on:` as boolean True
        triggers = self.workflow.get("on") or self.workflow.get(True)
        assert triggers is not None, "Workflow must have 'on' triggers"
        assert "push" in triggers
        assert "main" in triggers["push"].get("branches", [])

    def test_triggers_on_pull_request(self):
        triggers = self.workflow.get("on") or self.workflow.get(True)
        assert triggers is not None
        assert "pull_request" in triggers

    def test_supports_workflow_call(self):
        """CI workflow must support workflow_call for reuse from release.yml."""
        triggers = self.workflow.get("on") or self.workflow.get(True)
        assert triggers is not None
        assert "workflow_call" in triggers, (
            "CI workflow must support workflow_call for reuse"
        )

    def test_has_test_job(self):
        assert "jobs" in self.workflow
        assert "test" in self.workflow["jobs"]

    def test_python_matrix_includes_311_and_312(self):
        test_job = self.workflow["jobs"]["test"]
        strategy = test_job.get("strategy", {})
        matrix = strategy.get("matrix", {})
        python_versions = matrix.get("python-version", [])
        assert "3.11" in python_versions, "Python 3.11 missing from matrix"
        assert "3.12" in python_versions, "Python 3.12 missing from matrix"

    def test_has_pytest_step(self):
        test_job = self.workflow["jobs"]["test"]
        steps = test_job.get("steps", [])
        step_texts = " ".join(
            str(s.get("run", "")) + str(s.get("name", "")) for s in steps
        )
        assert "pytest" in step_texts, "No pytest step found in CI workflow"

    def test_installs_dev_dependencies(self):
        test_job = self.workflow["jobs"]["test"]
        steps = test_job.get("steps", [])
        step_texts = " ".join(str(s.get("run", "")) for s in steps)
        assert "dev" in step_texts, "Dev dependencies not installed in CI"

    def test_has_top_level_permissions(self):
        """CI workflow must have top-level permissions: {} for least privilege."""
        assert "permissions" in self.workflow, (
            "CI workflow must set top-level permissions: {} for least privilege"
        )

    def test_actions_pinned_to_shas(self):
        """All 'uses' steps must reference commit SHAs, not mutable tags."""
        import re
        for job_name, job in self.workflow.get("jobs", {}).items():
            for step in job.get("steps", []):
                uses = step.get("uses", "")
                if uses:
                    assert re.search(r"@[0-9a-f]{40}", uses), (
                        f"Action '{uses}' in job '{job_name}' is not pinned "
                        f"to a commit SHA — supply chain risk"
                    )

    def test_has_homebrew_formula_dry_run_job(self):
        """CI must validate the formula generation script via --dry-run."""
        assert "homebrew-formula-dry-run" in self.workflow["jobs"], (
            "CI workflow must have a homebrew-formula-dry-run job"
        )

    def test_homebrew_dry_run_runs_script(self):
        """The dry-run job must execute generate-homebrew-formula.sh --dry-run."""
        job = self.workflow["jobs"]["homebrew-formula-dry-run"]
        steps = job.get("steps", [])
        step_texts = " ".join(str(s.get("run", "")) for s in steps)
        assert "generate-homebrew-formula.sh" in step_texts, (
            "Dry-run job must run generate-homebrew-formula.sh"
        )
        assert "--dry-run" in step_texts, (
            "Dry-run job must pass --dry-run flag"
        )

    def test_shellcheck_lints_formula_script(self):
        """Shellcheck job must lint generate-homebrew-formula.sh."""
        shellcheck_job = self.workflow["jobs"]["shellcheck"]
        matrix_scripts = (
            shellcheck_job.get("strategy", {}).get("matrix", {}).get("script", [])
        )
        if matrix_scripts:
            assert any("generate-homebrew-formula.sh" in s for s in matrix_scripts), (
                "Shellcheck matrix must include generate-homebrew-formula.sh"
            )
        else:
            steps = shellcheck_job.get("steps", [])
            step_texts = " ".join(str(s.get("run", "")) for s in steps)
            assert "generate-homebrew-formula.sh" in step_texts, (
                "Shellcheck must lint generate-homebrew-formula.sh"
            )


class TestReleaseWorkflow:
    """Verify .github/workflows/release.yml structure."""

    workflow: Any = None

    def setup_method(self):
        release_path = REPO_ROOT / ".github" / "workflows" / "release.yml"
        assert release_path.exists(), f"Release workflow not found at {release_path}"
        with open(release_path) as f:
            self.workflow = yaml.safe_load(f)

    def test_has_name(self):
        assert "name" in self.workflow

    def test_triggers_on_version_tag(self):
        triggers = self.workflow.get("on") or self.workflow.get(True)
        assert triggers is not None, "Workflow must have 'on' triggers"
        assert "push" in triggers
        tags = triggers["push"].get("tags", [])
        assert any("v*" in t or "v**" in t for t in tags), (
            "Release workflow must trigger on v* tags"
        )

    def test_has_test_job(self):
        assert "test" in self.workflow["jobs"], (
            "Release workflow must have a test gate job"
        )

    def test_test_job_reuses_ci_workflow(self):
        """Release test job must use workflow_call to reuse ci.yml, not duplicate it."""
        test_job = self.workflow["jobs"]["test"]
        uses = test_job.get("uses", "")
        assert "ci.yml" in uses, (
            "Release test job must reuse ci.yml via workflow_call, not duplicate it"
        )

    def test_has_build_job(self):
        assert "build" in self.workflow["jobs"]

    def test_has_publish_job(self):
        assert "publish" in self.workflow["jobs"]

    def test_has_release_job(self):
        assert "release" in self.workflow["jobs"]

    def test_publish_has_id_token_permission(self):
        publish_job = self.workflow["jobs"]["publish"]
        permissions = publish_job.get("permissions", {})
        assert permissions.get("id-token") == "write", (
            "Publish job must have id-token: write for OIDC"
        )

    def test_build_uses_python_build(self):
        build_job = self.workflow["jobs"]["build"]
        steps = build_job.get("steps", [])
        step_texts = " ".join(str(s.get("run", "")) for s in steps)
        assert "build" in step_texts, "Build job must use python -m build"

    def test_release_creates_github_release(self):
        release_job = self.workflow["jobs"]["release"]
        steps = release_job.get("steps", [])
        step_texts = " ".join(
            str(s.get("run", "")) + str(s.get("name", "")) for s in steps
        )
        assert "release" in step_texts.lower(), (
            "Release job must create a GitHub Release"
        )

    def test_has_top_level_permissions(self):
        """Release workflow must have top-level permissions: {} for least privilege."""
        assert "permissions" in self.workflow, (
            "Release workflow must set top-level permissions: {} for least privilege"
        )

    def test_has_concurrency_control(self):
        """Release workflow must have concurrency control to prevent racing releases."""
        assert "concurrency" in self.workflow, (
            "Release workflow must have concurrency control"
        )

    def test_actions_pinned_to_shas(self):
        """All 'uses' steps must reference commit SHAs, not mutable tags."""
        import re
        for job_name, job in self.workflow.get("jobs", {}).items():
            # Skip reusable workflow calls (uses: ./.github/workflows/ci.yml)
            if "uses" in job and "steps" not in job:
                continue
            for step in job.get("steps", []):
                uses = step.get("uses", "")
                if uses:
                    assert re.search(r"@[0-9a-f]{40}", uses), (
                        f"Action '{uses}' in job '{job_name}' is not pinned "
                        f"to a commit SHA — supply chain risk"
                    )

    def test_checksums_not_in_pypi_upload_path(self):
        """SHA256SUMS.txt must not be in the dist/ artifact uploaded to PyPI."""
        build_job = self.workflow["jobs"]["build"]
        steps = build_job.get("steps", [])
        all_run = " ".join(str(s.get("run", "")) for s in steps)
        assert "mv SHA256SUMS.txt" in all_run or "cp SHA256SUMS.txt" in all_run, (
            "Build job must move SHA256SUMS.txt out of dist/ before upload"
        )

    def test_checksums_include_install_script(self):
        """SHA256SUMS.txt must include a checksum for install.sh (FR-4.6)."""
        build_job = self.workflow["jobs"]["build"]
        steps = build_job.get("steps", [])
        all_run = " ".join(str(s.get("run", "")) for s in steps)
        assert "install.sh" in all_run, (
            "Build job must include install.sh in SHA256SUMS.txt (FR-4.6)"
        )

    def test_release_notes_use_curl_f_flag(self):
        """Release notes curl command must include -f flag for HTTP error detection."""
        release_job = self.workflow["jobs"]["release"]
        steps = release_job.get("steps", [])
        all_run = " ".join(str(s.get("run", "")) for s in steps)
        assert "curl -fsSL" in all_run, (
            "Release notes must use curl -fsSL (with -f for HTTP error detection)"
        )

    def test_has_update_homebrew_job(self):
        """Release workflow must have an update-homebrew job."""
        assert "update-homebrew" in self.workflow["jobs"], (
            "Release workflow must have an update-homebrew job"
        )

    def test_update_homebrew_needs_publish(self):
        """update-homebrew must run after publish succeeds."""
        job = self.workflow["jobs"]["update-homebrew"]
        needs = job.get("needs", [])
        if isinstance(needs, str):
            needs = [needs]
        assert "publish" in needs, (
            "update-homebrew job must depend on publish"
        )

    def test_update_homebrew_generates_formula(self):
        """update-homebrew must run the formula generation script."""
        job = self.workflow["jobs"]["update-homebrew"]
        steps = job.get("steps", [])
        step_texts = " ".join(str(s.get("run", "")) for s in steps)
        assert "generate-homebrew-formula.sh" in step_texts, (
            "update-homebrew must run generate-homebrew-formula.sh"
        )

    def test_update_homebrew_pushes_to_tap(self):
        """update-homebrew must push the formula to the tap repo."""
        job = self.workflow["jobs"]["update-homebrew"]
        steps = job.get("steps", [])
        step_texts = " ".join(str(s.get("run", "")) for s in steps)
        assert "homebrew-colonyos" in step_texts, (
            "update-homebrew must push to rangelak/homebrew-colonyos"
        )

    def test_update_homebrew_uses_tap_token(self):
        """update-homebrew must use HOMEBREW_TAP_TOKEN secret."""
        job = self.workflow["jobs"]["update-homebrew"]
        steps = job.get("steps", [])
        step_envs = " ".join(
            str(s.get("env", {})) for s in steps
        )
        assert "HOMEBREW_TAP_TOKEN" in step_envs, (
            "update-homebrew must use HOMEBREW_TAP_TOKEN secret"
        )

    def test_sdist_uses_exact_version_not_glob(self):
        """Sdist file lookup must use exact version, not a glob pattern."""
        job = self.workflow["jobs"]["update-homebrew"]
        steps = job.get("steps", [])
        meta_run = ""
        for step in steps:
            if step.get("id") == "meta":
                meta_run = str(step.get("run", ""))
                break
        assert "colonyos-${VERSION}.tar.gz" in meta_run, (
            "Sdist lookup must use exact version (colonyos-${VERSION}.tar.gz), not a glob"
        )
        assert "colonyos-*.tar.gz" not in meta_run, (
            "Sdist lookup must not use glob pattern colonyos-*.tar.gz"
        )

    def test_update_homebrew_has_failure_alerting(self):
        """update-homebrew must have a failure-handling step to open an issue."""
        job = self.workflow["jobs"]["update-homebrew"]
        steps = job.get("steps", [])
        has_failure_step = any(
            "failure()" in str(step.get("if", "")) for step in steps
        )
        assert has_failure_step, (
            "update-homebrew must have an if: failure() step for alerting"
        )

    def test_failure_notification_uses_github_token(self):
        """Failure notification step must use GITHUB_TOKEN, not HOMEBREW_TAP_TOKEN.

        The issue is created on the current repo (rangelak/ColonyOS), so GITHUB_TOKEN
        is sufficient and guaranteed to have issue-write scope. The PAT (HOMEBREW_TAP_TOKEN)
        may not have issue-write scope on this repo.
        """
        job = self.workflow["jobs"]["update-homebrew"]
        steps = job.get("steps", [])
        failure_steps = [
            s for s in steps if "failure()" in str(s.get("if", ""))
        ]
        assert failure_steps, "Expected at least one failure() step"
        for step in failure_steps:
            env = step.get("env", {})
            gh_token = str(env.get("GH_TOKEN", ""))
            assert "GITHUB_TOKEN" in gh_token, (
                f"Failure notification step '{step.get('name', '')}' must use "
                "GITHUB_TOKEN (not HOMEBREW_TAP_TOKEN) for GH_TOKEN — "
                "GITHUB_TOKEN is guaranteed to have issue-write scope on the current repo"
            )
            assert "HOMEBREW_TAP_TOKEN" not in gh_token, (
                f"Failure notification step '{step.get('name', '')}' must not use "
                "HOMEBREW_TAP_TOKEN for issue creation"
            )

    def test_update_homebrew_has_concurrency_group(self):
        """update-homebrew must have its own concurrency group to prevent race conditions."""
        job = self.workflow["jobs"]["update-homebrew"]
        concurrency = job.get("concurrency", {})
        assert concurrency, (
            "update-homebrew must have a concurrency group"
        )

    def test_update_homebrew_pull_rebase_before_push(self):
        """update-homebrew must pull --rebase before push to handle concurrent tag pushes."""
        job = self.workflow["jobs"]["update-homebrew"]
        steps = job.get("steps", [])
        all_run = " ".join(str(s.get("run", "")) for s in steps)
        assert "pull --rebase" in all_run, (
            "update-homebrew must pull --rebase before push"
        )

    def test_release_notes_include_homebrew_install(self):
        """Release notes must include Homebrew install instructions."""
        release_job = self.workflow["jobs"]["release"]
        steps = release_job.get("steps", [])
        all_run = " ".join(str(s.get("run", "")) for s in steps)
        assert "brew install" in all_run, (
            "Release notes must include Homebrew install instructions"
        )


class TestHomebrewFormula:
    """Verify Formula/colonyos.rb structure."""

    formula_path: Any = None
    content: Any = None

    def setup_method(self):
        self.formula_path = REPO_ROOT / "Formula" / "colonyos.rb"
        assert self.formula_path.exists(), "Homebrew formula not found"
        self.content = self.formula_path.read_text(encoding="utf-8")

    def test_formula_documents_auto_update(self):
        """Formula must document that it is auto-updated by the release workflow."""
        assert "release workflow" in self.content.lower() or "auto" in self.content.lower(), (
            "Formula should document that it is auto-updated by the release workflow"
        )

    def test_formula_uses_canonical_pypi_url(self):
        """Formula must use files.pythonhosted.org, not pypi.io."""
        assert "files.pythonhosted.org" in self.content, (
            "Formula must use canonical PyPI URL (files.pythonhosted.org)"
        )
        assert "pypi.io" not in self.content, (
            "Formula must not use pypi.io — use files.pythonhosted.org instead"
        )

    def test_formula_documents_development_reference(self):
        """Formula must document that it is a development reference pointing to the tap."""
        assert "development reference" in self.content.lower() or "homebrew tap" in self.content.lower(), (
            "Formula should document that it is a development reference and point to the tap repo"
        )
