"""Tests for `.gitea/scripts/lint-workflow-yaml.py` — Gitea-1.22.6-hostile shape lint.

Hard-gate (Tier-2) lint that catches workflow YAML shapes Gitea 1.22.6
silently rejects, so they never reach `main`. The six anti-patterns are
documented in saved memory; this test suite is the structural enforcement.

Per-rule positive (anti-pattern present -> exit 1) + negative (clean -> exit 0)
cases, plus a multi-file collision case and an aggregation case.

Run:
    python3 -m pytest tests/test_lint_workflow_yaml.py -v

Dependencies: stdlib + PyYAML. No network.

Cross-links:
- feedback_gitea_workflow_dispatch_inputs_unsupported (rule 1)
- internal task #81 (rule 2 — workflow_run unsupported)
- feedback_workflow_name_with_slash_breaks_parsing (rule 3, if filed)
- feedback_gitea_cross_repo_uses_blocked (rule 5)
- feedback_act_runner_github_server_url (rule 6)
- feedback_smoke_test_vendor_truth_not_shape_match (test-shape rule)
"""
from __future__ import annotations

import re
import subprocess
import sys
import textwrap
import importlib.util
from pathlib import Path

import pytest  # noqa: F401  (declares the dep)
import yaml

REPO_ROOT = Path(__file__).resolve().parents[1]
SCRIPT = REPO_ROOT / ".gitea" / "scripts" / "lint-workflow-yaml.py"


def _run_lint(workflow_dir: Path) -> subprocess.CompletedProcess:
    """Invoke the lint as a subprocess against an isolated workflow dir."""
    return subprocess.run(
        [sys.executable, str(SCRIPT), "--workflow-dir", str(workflow_dir)],
        capture_output=True,
        text=True,
    )


def _write(workflow_dir: Path, name: str, content: str) -> Path:
    """Write a workflow YAML fixture and return its path."""
    workflow_dir.mkdir(parents=True, exist_ok=True)
    p = workflow_dir / name
    p.write_text(textwrap.dedent(content).lstrip())
    return p


# ---------------------------------------------------------------------------
# Rule 1 — workflow_dispatch.inputs (Gitea 1.22.6 parser rejects)
# ---------------------------------------------------------------------------

WD_INPUTS_BAD = """
    name: bad-wd-inputs
    on:
      workflow_dispatch:
        inputs:
          version:
            description: "version"
            required: true
            type: string
    jobs:
      x:
        runs-on: ubuntu-latest
        steps:
          - run: echo hi
"""

WD_INPUTS_OK = """
    name: ok-wd-no-inputs
    on:
      workflow_dispatch:
      push:
        branches: [main]
    jobs:
      x:
        runs-on: ubuntu-latest
        steps:
          - run: echo hi
"""


def test_rule1_workflow_dispatch_inputs_detects_violation(tmp_path):
    _write(tmp_path, "bad.yml", WD_INPUTS_BAD)
    r = _run_lint(tmp_path)
    assert r.returncode == 1
    assert "workflow_dispatch.inputs" in r.stdout
    assert "bad.yml" in r.stdout


def test_rule1_workflow_dispatch_inputs_passes_when_absent(tmp_path):
    _write(tmp_path, "ok.yml", WD_INPUTS_OK)
    r = _run_lint(tmp_path)
    assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}"


# ---------------------------------------------------------------------------
# Rule 2 — workflow_run event (not supported on Gitea 1.22.6)
# ---------------------------------------------------------------------------

WF_RUN_BAD = """
    name: bad-workflow-run
    on:
      workflow_run:
        workflows: ["upstream"]
        types: [completed]
    jobs:
      x:
        runs-on: ubuntu-latest
        steps:
          - run: echo hi
"""

WF_RUN_OK = """
    name: ok-no-workflow-run
    on:
      push:
        branches: [main]
    jobs:
      x:
        runs-on: ubuntu-latest
        steps:
          - run: echo hi
"""


def test_rule2_workflow_run_event_detects_violation(tmp_path):
    _write(tmp_path, "bad.yml", WF_RUN_BAD)
    r = _run_lint(tmp_path)
    assert r.returncode == 1
    assert "workflow_run" in r.stdout
    assert "bad.yml" in r.stdout


def test_rule2_workflow_run_event_passes_when_absent(tmp_path):
    _write(tmp_path, "ok.yml", WF_RUN_OK)
    r = _run_lint(tmp_path)
    assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}"


# ---------------------------------------------------------------------------
# Rule 3 — name: contains "/" (breaks "<workflow> / <job> (<event>)" parsing)
# ---------------------------------------------------------------------------

NAME_SLASH_BAD = """
    name: ci / build
    on: [push]
    jobs:
      x:
        runs-on: ubuntu-latest
        steps:
          - run: echo hi
"""

NAME_SLASH_OK = """
    name: ci-build
    on: [push]
    jobs:
      x:
        runs-on: ubuntu-latest
        steps:
          - run: echo hi
"""


def test_rule3_name_with_slash_detects_violation(tmp_path):
    _write(tmp_path, "bad.yml", NAME_SLASH_BAD)
    r = _run_lint(tmp_path)
    assert r.returncode == 1
    assert "name" in r.stdout.lower()
    assert "/" in r.stdout
    assert "bad.yml" in r.stdout


def test_rule3_name_with_slash_passes_when_absent(tmp_path):
    _write(tmp_path, "ok.yml", NAME_SLASH_OK)
    r = _run_lint(tmp_path)
    assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}"


# ---------------------------------------------------------------------------
# Rule 4 — name collision across files (cross-file)
# ---------------------------------------------------------------------------

COLLISION_A = """
    name: shared-name
    on: [push]
    jobs:
      x:
        runs-on: ubuntu-latest
        steps:
          - run: echo a
"""

COLLISION_B = """
    name: shared-name
    on: [push]
    jobs:
      x:
        runs-on: ubuntu-latest
        steps:
          - run: echo b
"""

DISTINCT_A = """
    name: name-a
    on: [push]
    jobs:
      x:
        runs-on: ubuntu-latest
        steps:
          - run: echo a
"""

DISTINCT_B = """
    name: name-b
    on: [push]
    jobs:
      x:
        runs-on: ubuntu-latest
        steps:
          - run: echo b
"""


def test_rule4_name_collision_across_two_files_detects_violation(tmp_path):
    _write(tmp_path, "a.yml", COLLISION_A)
    _write(tmp_path, "b.yml", COLLISION_B)
    r = _run_lint(tmp_path)
    assert r.returncode == 1
    assert ("collision" in r.stdout.lower()) or ("duplicate" in r.stdout.lower())
    assert "shared-name" in r.stdout


def test_rule4_name_collision_passes_when_names_distinct(tmp_path):
    _write(tmp_path, "a.yml", DISTINCT_A)
    _write(tmp_path, "b.yml", DISTINCT_B)
    r = _run_lint(tmp_path)
    assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}"


# ---------------------------------------------------------------------------
# Rule 5 — cross-repo `uses: org/repo/...@ref` (blocked on 1.22.6)
# ---------------------------------------------------------------------------

CROSS_REPO_BAD = """
    name: bad-cross-repo
    on: [push]
    jobs:
      x:
        runs-on: ubuntu-latest
        steps:
          - uses: molecule-ai/molecule-ci/.gitea/actions/audit-force-merge@main
"""

# actions/checkout — bare `org/repo@ref` form — allowed. Rule 5 targets
# `org/repo/SUBPATH@ref` cross-repo composite/reusable references because
# only those resolve through `[actions].DEFAULT_ACTIONS_URL`+org-suspended-host.
CROSS_REPO_OK = """
    name: ok-no-cross-repo
    on: [push]
    jobs:
      x:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
          - run: echo hi
"""


def test_rule5_cross_repo_uses_detects_violation(tmp_path):
    _write(tmp_path, "bad.yml", CROSS_REPO_BAD)
    r = _run_lint(tmp_path)
    assert r.returncode == 1
    assert ("cross-repo" in r.stdout.lower()) or ("uses" in r.stdout.lower())
    assert "bad.yml" in r.stdout


def test_rule5_cross_repo_uses_passes_when_only_actions_org(tmp_path):
    _write(tmp_path, "ok.yml", CROSS_REPO_OK)
    r = _run_lint(tmp_path)
    assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}"


# ---------------------------------------------------------------------------
# Rule 6 — GITHUB_SERVER_URL heuristic (warn-not-fail per halt-condition 3)
# ---------------------------------------------------------------------------

GH_API_REF_NO_SERVER = """
    name: warn-server-url
    on: [push]
    jobs:
      x:
        runs-on: ubuntu-latest
        steps:
          - run: curl https://api.github.com/repos/foo/bar
"""

GH_API_REF_WITH_SERVER = """
    name: ok-server-url-set
    on: [push]
    env:
      GITHUB_SERVER_URL: https://git.moleculesai.app
    jobs:
      x:
        runs-on: ubuntu-latest
        steps:
          - run: curl https://api.github.com/repos/foo/bar
"""


def test_rule6_github_server_url_missing_is_warning_not_fatal(tmp_path):
    """Heuristic rule — emits warning but does NOT exit 1.

    Per halt-condition 3: heuristic may false-positive (current main has 3:
    OCI label + jq-release URL refs). Downgrade to warn-not-fail.
    """
    _write(tmp_path, "warn.yml", GH_API_REF_NO_SERVER)
    r = _run_lint(tmp_path)
    assert r.returncode == 0
    combined = (r.stdout + r.stderr).lower()
    assert ("github_server_url" in combined) or ("::warning" in combined)


def test_rule6_github_server_url_present_no_warning(tmp_path):
    _write(tmp_path, "ok.yml", GH_API_REF_WITH_SERVER)
    r = _run_lint(tmp_path)
    assert r.returncode == 0
    # No warning emitted (server URL is set)
    assert "::warning" not in r.stdout


# ---------------------------------------------------------------------------
# Aggregation — single file with multiple anti-patterns
# ---------------------------------------------------------------------------

MULTI_VIOLATIONS = """
    name: ci / multi
    on:
      workflow_dispatch:
        inputs:
          v:
            type: string
      workflow_run:
        workflows: [up]
        types: [completed]
    jobs:
      x:
        runs-on: ubuntu-latest
        steps:
          - uses: molecule-ai/molecule-ci/.gitea/actions/x@main
"""


def test_all_violations_aggregated_single_file(tmp_path):
    _write(tmp_path, "multi.yml", MULTI_VIOLATIONS)
    r = _run_lint(tmp_path)
    assert r.returncode == 1
    out = r.stdout
    # All four FATAL rules should be reported (1, 2, 3, 5)
    assert "workflow_dispatch.inputs" in out
    assert "workflow_run" in out
    assert "/" in out  # rule 3 surfaces the slash
    assert ("cross-repo" in out.lower()) or ("uses" in out.lower())


# ---------------------------------------------------------------------------
# Empty-dir / no-workflows edge case
# ---------------------------------------------------------------------------

def test_no_workflows_exits_zero(tmp_path):
    r = _run_lint(tmp_path)
    assert r.returncode == 0


# ---------------------------------------------------------------------------
# Vendor-truth: rule 1 catches the exact 2026-05-11 publish-runtime.yml shape
# ---------------------------------------------------------------------------

# The exact YAML shape from feedback_gitea_workflow_dispatch_inputs_unsupported
# that caused publish-runtime-v1.0.0 to silently freeze PyPI at 0.1.129 for ~24h.
PUBLISH_RUNTIME_VENDOR_TRUTH = """
    name: publish-runtime
    on:
      push:
        tags: ['runtime-v*']
      workflow_dispatch:
        inputs:
          version:
            description: "Version to publish (e.g. 0.1.6). Required for manual dispatch."
            required: true
            type: string
    jobs:
      x:
        runs-on: ubuntu-latest
        steps:
          - run: echo hi
"""


def test_rule1_catches_2026_05_11_publish_runtime_regression(tmp_path):
    """Vendor-truth fixture: the exact YAML shape that froze PyPI for 24h."""
    _write(tmp_path, "publish-runtime.yml", PUBLISH_RUNTIME_VENDOR_TRUTH)
    r = _run_lint(tmp_path)
    assert r.returncode == 1, (
        "Lint must catch the 2026-05-11 publish-runtime regression "
        f"(memory: feedback_gitea_workflow_dispatch_inputs_unsupported)."
        f"\nstdout={r.stdout}"
    )


# ---------------------------------------------------------------------------
# Rule 7 — production deploys cannot rely on broken Gitea concurrency
# ---------------------------------------------------------------------------

PROD_CONCURRENCY_BAD = """
    name: prod-concurrency-bad
    on: [push]
    jobs:
      deploy:
        runs-on: ubuntu-latest
        concurrency:
          group: production-auto-deploy
          cancel-in-progress: false
        steps:
          - run: curl https://api.moleculesai.app/cp/admin/tenants/redeploy-fleet
"""


def test_rule7_prod_deploy_concurrency_detects_violation(tmp_path):
    _write(tmp_path, "bad.yml", PROD_CONCURRENCY_BAD)
    r = _run_lint(tmp_path)
    assert r.returncode == 1
    assert "production deploy" in r.stdout.lower()
    assert "concurrency" in r.stdout.lower()


# ---------------------------------------------------------------------------
# Rule 8 — production deploys must not dump raw CP responses/errors
# ---------------------------------------------------------------------------

PROD_RAW_LOG_BAD = """
    name: prod-raw-log-bad
    on: [push]
    jobs:
      deploy:
        runs-on: ubuntu-latest
        steps:
          - run: |
              curl https://api.moleculesai.app/cp/admin/tenants/redeploy-fleet -o "$HTTP_RESPONSE"
              jq . "$HTTP_RESPONSE"
              jq -r '.results[]? | .error' "$HTTP_RESPONSE"
"""

PROD_REDACTED_LOG_OK = """
    name: prod-redacted-log-ok
    on: [push]
    jobs:
      deploy:
        runs-on: ubuntu-latest
        env:
          PROD_AUTO_DEPLOY_DISABLED: ${{ vars.PROD_AUTO_DEPLOY_DISABLED || '' }}
        steps:
          - run: |
              curl https://api.moleculesai.app/cp/admin/tenants/redeploy-fleet -o "$HTTP_RESPONSE"
              jq '{ok, result_count: (.results // [] | length)}' "$HTTP_RESPONSE"
              jq -r '.results[]? | ((.error // "") != "")' "$HTTP_RESPONSE"
"""


def test_rule8_prod_deploy_raw_log_detects_violation(tmp_path):
    _write(tmp_path, "bad.yml", PROD_RAW_LOG_BAD)
    r = _run_lint(tmp_path)
    assert r.returncode == 1
    assert "raw production cp response" in r.stdout.lower()


def test_rule8_prod_deploy_allows_redacted_summary(tmp_path):
    _write(tmp_path, "ok.yml", PROD_REDACTED_LOG_OK)
    r = _run_lint(tmp_path)
    assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}"


# ---------------------------------------------------------------------------
# Rule 9 — production deploys require an operational control
# ---------------------------------------------------------------------------

PROD_NO_CONTROL_BAD = """
    name: prod-no-control-bad
    on: [push]
    jobs:
      deploy:
        runs-on: ubuntu-latest
        steps:
          - run: curl https://api.moleculesai.app/cp/admin/tenants/redeploy-fleet
"""

PROD_KILL_SWITCH_OK = """
    name: prod-kill-switch-ok
    on: [push]
    jobs:
      deploy:
        runs-on: ubuntu-latest
        env:
          PROD_AUTO_DEPLOY_DISABLED: ${{ vars.PROD_AUTO_DEPLOY_DISABLED || '' }}
        steps:
          - run: curl https://api.moleculesai.app/cp/admin/tenants/redeploy-fleet
"""

PROD_ROLLBACK_OK = """
    name: prod-rollback-ok
    on:
      workflow_dispatch:
    jobs:
      deploy:
        runs-on: ubuntu-latest
        env:
          PROD_MANUAL_REDEPLOY_TARGET_TAG: ${{ vars.PROD_MANUAL_REDEPLOY_TARGET_TAG || '' }}
        steps:
          - run: curl https://api.moleculesai.app/cp/admin/tenants/redeploy-fleet
"""


def test_rule9_prod_deploy_requires_kill_switch_or_rollback(tmp_path):
    _write(tmp_path, "bad.yml", PROD_NO_CONTROL_BAD)
    r = _run_lint(tmp_path)
    assert r.returncode == 1
    assert "kill switch" in r.stdout.lower()


def test_rule9_prod_auto_deploy_allows_kill_switch(tmp_path):
    _write(tmp_path, "ok.yml", PROD_KILL_SWITCH_OK)
    r = _run_lint(tmp_path)
    assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}"


def test_rule9_prod_manual_deploy_allows_rollback_control(tmp_path):
    _write(tmp_path, "ok.yml", PROD_ROLLBACK_OK)
    r = _run_lint(tmp_path)
    assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}"


# ---------------------------------------------------------------------------
# Rule 10 — docker info piped to head under pipefail
# ---------------------------------------------------------------------------

DOCKER_INFO_HEAD_BAD = """
    name: docker-info-head-bad
    on: [push]
    jobs:
      build:
        runs-on: ubuntu-latest
        steps:
          - run: |
              set -euo pipefail
              docker info 2>&1 | head -5 || exit 1
"""

DOCKER_INFO_CAPTURE_OK = """
    name: docker-info-capture-ok
    on: [push]
    jobs:
      build:
        runs-on: ubuntu-latest
        steps:
          - run: |
              set -euo pipefail
              docker_info="$(docker info 2>&1)" || exit 1
              printf '%s\\n' "${docker_info}" | sed -n '1,5p'
"""

DOCKER_INFO_SEPARATE_STEP_OK = """
    name: docker-info-separate-step-ok
    on: [push]
    jobs:
      build:
        runs-on: ubuntu-latest
        steps:
          - run: |
              set -euo pipefail
              echo setup
          - run: |
              docker info 2>&1 | head -5 || true
"""


def test_rule10_docker_info_head_under_pipefail_detects_violation(tmp_path):
    _write(tmp_path, "bad.yml", DOCKER_INFO_HEAD_BAD)
    r = _run_lint(tmp_path)
    assert r.returncode == 1
    assert "docker info" in r.stdout.lower()
    assert "pipefail" in r.stdout.lower()


def test_rule10_docker_info_capture_passes(tmp_path):
    _write(tmp_path, "ok.yml", DOCKER_INFO_CAPTURE_OK)
    r = _run_lint(tmp_path)
    assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}"


def test_rule10_docker_info_head_in_separate_step_without_pipefail_passes(tmp_path):
    _write(tmp_path, "ok.yml", DOCKER_INFO_SEPARATE_STEP_OK)
    r = _run_lint(tmp_path)
    assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}"


# ---------------------------------------------------------------------------
# CI change detector fanout — workflow-only PRs keep required contexts without
# running Go/Canvas/Python/shellcheck heavy steps.
# ---------------------------------------------------------------------------

CI_WORKFLOW = REPO_ROOT / ".gitea" / "workflows" / "ci.yml"
CI_SURFACES = ("platform", "canvas", "python", "scripts")
DETECT_CHANGES_SCRIPT = REPO_ROOT / ".gitea" / "scripts" / "detect-changes.py"


def _load_detect_changes():
    spec = importlib.util.spec_from_file_location("detect_changes", DETECT_CHANGES_SCRIPT)
    assert spec is not None
    module = importlib.util.module_from_spec(spec)
    assert spec.loader is not None
    spec.loader.exec_module(module)
    return module


def _ci_change_patterns() -> dict[str, re.Pattern[str]]:
    detect_changes = _load_detect_changes()
    patterns = {
        surface: re.compile(pattern)
        for surface, pattern in detect_changes.PROFILES["ci"].items()
    }
    assert set(patterns) == set(CI_SURFACES)
    return patterns


def _classify_ci_change(*paths: str) -> dict[str, bool]:
    patterns = _ci_change_patterns()
    return {
        surface: any(pattern.search(path) for path in paths)
        for surface, pattern in patterns.items()
    }


def test_ci_change_detector_workflow_only_edits_do_not_trigger_heavy_surfaces():
    assert _classify_ci_change(".gitea/workflows/ci.yml") == {
        "platform": False,
        "canvas": False,
        "python": False,
        "scripts": False,
    }
    assert _classify_ci_change(".github/workflows/ci.yml") == {
        "platform": False,
        "canvas": False,
        "python": False,
        "scripts": False,
    }


def test_ci_change_detector_narrow_surface_edits_only_trigger_their_surface():
    assert _classify_ci_change("workspace-server/internal/handlers/foo.go") == {
        "platform": True,
        "canvas": False,
        "python": False,
        "scripts": False,
    }
    assert _classify_ci_change("canvas/app/page.tsx") == {
        "platform": False,
        "canvas": True,
        "python": False,
        "scripts": False,
    }
    assert _classify_ci_change("workspace/a2a_mcp_server.py") == {
        "platform": False,
        "canvas": False,
        "python": True,
        "scripts": False,
    }
    assert _classify_ci_change("tests/e2e/test_model_slug.sh") == {
        "platform": False,
        "canvas": False,
        "python": False,
        "scripts": True,
    }


def test_ci_change_detector_docs_and_meta_scripts_do_not_trigger_surfaces():
    assert _classify_ci_change("README.md") == {
        "platform": False,
        "canvas": False,
        "python": False,
        "scripts": False,
    }
    assert _classify_ci_change(".gitea/scripts/lint-workflow-yaml.py") == {
        "platform": False,
        "canvas": False,
        "python": False,
        "scripts": False,
    }


def test_ci_platform_go_steps_are_path_scoped_on_all_events():
    doc = yaml.safe_load(CI_WORKFLOW.read_text(encoding="utf-8"))
    platform = doc["jobs"]["platform-build"]
    assert platform.get("needs") == "changes"

    expensive_steps = [
        step
        for step in platform["steps"]
        if step.get("uses")
        or step.get("run", "").startswith("go ")
        or "golangci-lint" in step.get("run", "")
    ]
    assert expensive_steps
    for step in expensive_steps:
        expr = step.get("if", "")
        assert "needs.changes.outputs.platform == 'true'" in expr
        assert "github.event_name != 'pull_request'" not in expr


def test_ci_canvas_nextjs_steps_are_path_scoped_on_all_events():
    doc = yaml.safe_load(CI_WORKFLOW.read_text(encoding="utf-8"))
    canvas = doc["jobs"]["canvas-build"]
    assert canvas.get("needs") == "changes"

    expensive_steps = [
        step
        for step in canvas["steps"]
        if step.get("uses")
        or step.get("run", "").startswith("npm ")
        or step.get("run", "").startswith("npx ")
    ]
    assert expensive_steps
    for step in expensive_steps:
        expr = step.get("if", "")
        assert "needs.changes.outputs.canvas == 'true'" in expr
        assert "github.event_name != 'pull_request'" not in expr


def test_ci_shellcheck_steps_are_path_scoped_on_all_events():
    doc = yaml.safe_load(CI_WORKFLOW.read_text(encoding="utf-8"))
    shellcheck = doc["jobs"]["shellcheck"]
    assert shellcheck.get("needs") == "changes"

    expensive_steps = [
        step
        for step in shellcheck["steps"]
        if step.get("uses") or step.get("run", "").startswith(("bash ", "find ", "shellcheck "))
    ]
    assert expensive_steps
    for step in expensive_steps:
        expr = step.get("if", "")
        assert "needs.changes.outputs.scripts == 'true'" in expr
        assert "github.event_name != 'pull_request'" not in expr
