"""
Tests for engine.pipeline — 产物 Gate 逐级验收流水线 (P0+P1 骨架).

覆盖：
  1. 静态校验（forward-ref consumes、duplicate id）
  2. 受限 gate DSL 求值器（功能 + 安全：拒绝属性/import/裸名）
  3. resolve_artifacts 运行时契约校验（required 缺失）
  4. run_pipeline 端到端（fake executor）：通过 / 多上游 consumes / gate 失败 retry→halt / skip
  5. 每阶段 verdict + manifest + write-ahead state
  6. resume_pipeline 跳过已通过阶段
"""
import json
import os
import shutil
import tempfile

import pytest

from agentpaas.engine.pipeline import (
    load_pipeline, validate_pipeline, eval_rule_gate, resolve_artifacts,
    run_pipeline, resume_pipeline, _parse_judge_response,
    estimate_pipeline_cost, build_judge_term, term_judge, JudgeRequest,
    Stage, ArtifactSpec, ResolvedArtifact, GateError, PipelineError,
    StageStatus, PipelineStatus, StageExecResult, JudgeResult,
)

_REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_DEMO_PRESET = os.path.join(_REPO, "agentexample", "pipelines", "research-demo.yml")


def _ollama_up() -> bool:
    import urllib.request
    try:
        urllib.request.urlopen("http://127.0.0.1:11434/api/tags", timeout=2)
        return True
    except Exception:
        return False


@pytest.fixture
def agent_dir():
    d = tempfile.mkdtemp(prefix="test_pipeline_")
    yield d
    shutil.rmtree(d, ignore_errors=True)


# ============================================================
# helpers — fake executors that write artifacts (no LLM)
# ============================================================

def _writer(files: dict):
    """Return a stage executor that writes {relpath: content} into the stage ws."""
    def _exec(ec):
        for rel, content in files.items():
            p = os.path.join(ec.stage_ws, rel)
            os.makedirs(os.path.dirname(p), exist_ok=True)
            with open(p, "w", encoding="utf-8") as f:
                f.write(content)
        return StageExecResult(output="ok", status="completed")
    return _exec


# ============================================================
# 1. Static validation
# ============================================================

class TestStaticValidation:

    def test_valid_pipeline(self):
        cfg = {"pipeline": {"id": "p", "stages": [
            {"id": "a", "produces": [{"name": "x.md", "path": "final/x.md"}],
             "gate": {"kind": "rule", "rule": 'has("x.md")'}},
            {"id": "b", "consumes": ["a"], "gate": {"kind": "rule", "rule": "True"}},
        ]}}
        p = load_pipeline(cfg)
        assert validate_pipeline(p) == []

    def test_forward_consumes_rejected(self):
        cfg = {"pipeline": {"id": "p", "stages": [
            {"id": "a", "consumes": ["b"]},   # b not yet declared
            {"id": "b"},
        ]}}
        errs = validate_pipeline(load_pipeline(cfg))
        assert any("forward" in e for e in errs)

    def test_duplicate_stage_id_rejected(self):
        cfg = {"pipeline": {"id": "p", "stages": [{"id": "a"}, {"id": "a"}]}}
        errs = validate_pipeline(load_pipeline(cfg))
        assert any("duplicate" in e for e in errs)

    def test_run_rejects_invalid(self, agent_dir):
        cfg = {"pipeline": {"id": "p", "stages": [{"id": "a", "consumes": ["z"]}]}}
        with pytest.raises(PipelineError):
            run_pipeline(cfg, "in", agent_dir, stage_executor=_writer({}))

    def test_unsafe_rule_caught_statically(self, agent_dir):
        """§15 P2-8: a banned gate expression fails validation BEFORE the executor runs."""
        ran = {"n": 0}

        def exec_spy(ec):
            ran["n"] += 1
            return StageExecResult(output="ok", status="completed")

        cfg = {"pipeline": {"id": "p", "stages": [
            {"id": "a", "gate": {"kind": "rule", "rule": 'open("/etc/passwd")'}},
        ]}}
        errs = validate_pipeline(load_pipeline(cfg))
        assert any("not allowed" in e for e in errs)
        with pytest.raises(PipelineError):
            run_pipeline(cfg, "in", agent_dir, stage_executor=exec_spy)
        assert ran["n"] == 0   # executor never ran


# ============================================================
# 2. Bounded gate DSL evaluator
# ============================================================

class TestGateEvaluator:

    def _resolved(self, tmp, name="plan.md", content="contains hypothesis here"):
        p = os.path.join(tmp, name)
        with open(p, "w") as f:
            f.write(content)
        return {name: ResolvedArtifact(name=name, type="md", path=p, exists=True,
                                       size_bytes=len(content))}

    def test_has_and_contains_pass(self, agent_dir):
        r = self._resolved(agent_dir)
        passed, reasons = eval_rule_gate('has("plan.md") and contains("plan.md", "hypothesis")', r)
        assert passed is True

    def test_contains_fail(self, agent_dir):
        r = self._resolved(agent_dir, content="no keyword")
        passed, _ = eval_rule_gate('contains("plan.md", "hypothesis")', r)
        assert passed is False

    def test_json_len(self, agent_dir):
        p = os.path.join(agent_dir, "refs.json")
        with open(p, "w") as f:
            json.dump(list(range(12)), f)
        r = {"refs.json": ResolvedArtifact("refs.json", "json", p, True, os.path.getsize(p))}
        assert eval_rule_gate('json_len("refs.json") >= 10', r)[0] is True
        assert eval_rule_gate('json_len("refs.json") >= 100', r)[0] is False

    def test_count(self, agent_dir):
        r = self._resolved(agent_dir)
        assert eval_rule_gate("count() >= 1", r)[0] is True

    # --- safety: dangerous expressions must raise GateError, not execute ---

    @pytest.mark.parametrize("expr", [
        '__import__("os").system("echo pwned")',
        'open("/etc/passwd")',
        'has.__class__',
        'count().__class__.__bases__',
        '[].append(1)',
        'lambda: 1',
    ])
    def test_unsafe_expr_rejected(self, expr):
        with pytest.raises(GateError):
            eval_rule_gate(expr, {})


# ============================================================
# 3. Runtime artifact contract validation
# ============================================================

class TestResolveArtifacts:

    def test_missing_required(self, agent_dir):
        st = Stage(id="a", produces=[ArtifactSpec(name="x.md", path="final/x.md", required=True)])
        resolved, problems = resolve_artifacts(st, agent_dir)
        assert len(problems) == 1 and problems[0].startswith("x.md:")
        assert resolved["x.md"].exists is False

    def test_resolved_with_hash(self, agent_dir):
        os.makedirs(os.path.join(agent_dir, "final"))
        with open(os.path.join(agent_dir, "final", "x.md"), "w") as f:
            f.write("data")
        st = Stage(id="a", produces=[ArtifactSpec(name="x.md", path="final/x.md")])
        resolved, missing = resolve_artifacts(st, agent_dir)
        assert missing == []
        assert resolved["x.md"].exists is True
        assert resolved["x.md"].sha256 is not None

    def test_path_breakout_rejected(self, agent_dir):
        """§15 P1-1: absolute / .. paths must not escape the stage workspace."""
        for bad in ("/etc/passwd", "../../../../etc/passwd", "final/../../secret"):
            st = Stage(id="a", produces=[ArtifactSpec(name="x", path=bad, required=True)])
            resolved, problems = resolve_artifacts(st, agent_dir)
            assert problems, f"unsafe path not rejected: {bad}"
            assert resolved["x"].exists is False

    def test_invalid_json_type_fails(self, agent_dir):
        """§15 P2-E: type=json that doesn't parse must NOT count as a valid artifact."""
        os.makedirs(os.path.join(agent_dir, "results"))
        with open(os.path.join(agent_dir, "results", "refs.json"), "w") as f:
            f.write("not valid json {{{")
        st = Stage(id="a", produces=[ArtifactSpec(name="refs.json", type="json",
                                                  path="results/refs.json", required=True)])
        resolved, problems = resolve_artifacts(st, agent_dir)
        assert problems            # invalid json → contract problem
        assert resolved["refs.json"].exists is False


# ============================================================
# 4-5. End-to-end run
# ============================================================

class TestRunPipeline:

    def _two_stage_cfg(self):
        return {"pipeline": {"id": "research", "defaults": {"retry": 1}, "stages": [
            {"id": "plan", "produces": [{"name": "plan.md", "path": "final/plan.md"}],
             "gate": {"kind": "rule", "rule": 'has("plan.md") and contains("plan.md", "hypothesis")'}},
            {"id": "lit", "consumes": ["plan"],
             "produces": [{"name": "refs.json", "path": "results/refs.json", "type": "json"}],
             "gate": {"kind": "rule", "rule": 'json_len("refs.json") >= 3'}},
        ]}}

    def test_completes_and_persists(self, agent_dir):
        execs = {
            "plan": _writer({"final/plan.md": "our hypothesis is X"}),
            "lit": _writer({"results/refs.json": json.dumps([1, 2, 3, 4])}),
        }
        result = run_pipeline(self._two_stage_cfg(), "topic", agent_dir,
                              stage_executor=lambda ec: execs[ec.stage.id](ec))
        assert result.status == PipelineStatus.COMPLETED
        assert [s.status for s in result.stages] == [StageStatus.PASSED, StageStatus.PASSED]
        # verdict + manifest + state persisted
        plan_ws = os.path.join(result.workspace_path, "plan")
        assert os.path.isfile(os.path.join(plan_ws, "verdict.json"))
        assert os.path.isfile(os.path.join(plan_ws, "manifest.json"))
        assert os.path.isfile(os.path.join(result.workspace_path, "pipeline_state.json"))

    def test_multi_upstream_consumes(self, agent_dir):
        """stage 3 consumes two non-adjacent upstream stages via the artifact store."""
        seen = {}

        def s3_exec(ec):
            # read upstream artifacts through the store-backed consumed view
            seen["consumed_keys"] = sorted(ec.consumed.keys())
            seen["plan_path"] = ec.consumed["plan"]["plan.md"].path
            with open(os.path.join(ec.stage_ws, "final", "paper.md"), "w") as f:
                f.write("done")
            return StageExecResult(output="ok", status="completed")

        cfg = {"pipeline": {"id": "p", "stages": [
            {"id": "plan", "produces": [{"name": "plan.md", "path": "final/plan.md"}],
             "gate": {"kind": "rule", "rule": 'has("plan.md")'}},
            {"id": "lit", "consumes": ["plan"], "produces": [{"name": "s.md", "path": "final/s.md"}],
             "gate": {"kind": "rule", "rule": 'has("s.md")'}},
            {"id": "paper", "consumes": ["plan", "lit"],
             "produces": [{"name": "paper.md", "path": "final/paper.md"}],
             "gate": {"kind": "rule", "rule": 'has("paper.md")'}},
        ]}}
        execs = {
            "plan": _writer({"final/plan.md": "h"}),
            "lit": _writer({"final/s.md": "survey"}),
            "paper": s3_exec,
        }
        result = run_pipeline(cfg, "x", agent_dir, stage_executor=lambda ec: execs[ec.stage.id](ec))
        assert result.status == PipelineStatus.COMPLETED
        assert seen["consumed_keys"] == ["lit", "plan"]   # both non-adjacent upstreams visible
        assert seen["plan_path"].endswith("plan.md")

    def test_retry_then_halts(self, agent_dir):
        """on_fail=retry + retry=2 → 1+2 attempts, then FAILED/HALTED."""
        calls = {"n": 0}

        def failing(ec):
            calls["n"] += 1
            return StageExecResult(output="", status="completed")  # never writes artifact

        cfg = {"pipeline": {"id": "p", "stages": [
            {"id": "a", "retry": 2, "on_fail": "retry",
             "produces": [{"name": "x.md", "path": "final/x.md", "required": True}],
             "gate": {"kind": "rule", "rule": 'has("x.md")'}},
        ]}}
        result = run_pipeline(cfg, "x", agent_dir, stage_executor=failing)
        assert result.status == PipelineStatus.HALTED
        assert result.stages[0].status == StageStatus.FAILED
        assert calls["n"] == 3   # 1 + retry(2)

    def test_halt_does_not_retry(self, agent_dir):
        """§15 P1-4: on_fail=halt ignores retry budget — single attempt, immediate halt."""
        calls = {"n": 0}

        def failing(ec):
            calls["n"] += 1
            return StageExecResult(output="", status="completed")

        cfg = {"pipeline": {"id": "p", "stages": [
            {"id": "a", "retry": 2, "on_fail": "halt",   # retry=2 must be ignored
             "produces": [{"name": "x.md", "path": "final/x.md", "required": True}],
             "gate": {"kind": "rule", "rule": 'has("x.md")'}},
        ]}}
        result = run_pipeline(cfg, "x", agent_dir, stage_executor=failing)
        assert result.status == PipelineStatus.HALTED
        assert calls["n"] == 1   # halt = no retry

    def test_skip_on_fail_continues(self, agent_dir):
        cfg = {"pipeline": {"id": "p", "stages": [
            {"id": "a", "on_fail": "skip",
             "produces": [{"name": "x.md", "path": "final/x.md", "required": True}],
             "gate": {"kind": "rule", "rule": 'has("x.md")'}},
            {"id": "b", "produces": [{"name": "y.md", "path": "final/y.md"}],
             "gate": {"kind": "rule", "rule": 'has("y.md")'}},
        ]}}
        execs = {"a": _writer({}), "b": _writer({"final/y.md": "ok"})}
        result = run_pipeline(cfg, "x", agent_dir, stage_executor=lambda ec: execs[ec.stage.id](ec))
        assert result.status == PipelineStatus.COMPLETED
        assert result.stages[0].status == StageStatus.SKIPPED
        assert result.stages[1].status == StageStatus.PASSED


# ============================================================
# 6. Resume
# ============================================================

class TestResume:

    def test_resume_skips_passed(self, agent_dir):
        cfg = {"pipeline": {"id": "p", "stages": [
            {"id": "a", "produces": [{"name": "x.md", "path": "final/x.md"}],
             "gate": {"kind": "rule", "rule": 'has("x.md")'}},
            {"id": "b", "produces": [{"name": "y.md", "path": "final/y.md"}],
             "gate": {"kind": "rule", "rule": 'has("y.md")'}},
        ]}}

        # First run: stage a passes, stage b halts (writes nothing)
        run_counts = {"a": 0, "b": 0}

        def exec1(ec):
            run_counts[ec.stage.id] += 1
            if ec.stage.id == "a":
                with open(os.path.join(ec.stage_ws, "final", "x.md"), "w") as f:
                    f.write("ok")
            return StageExecResult(output="ok", status="completed")

        cfg["pipeline"]["stages"][1]["on_fail"] = "halt"
        r1 = run_pipeline(cfg, "x", agent_dir, stage_executor=exec1)
        assert r1.status == PipelineStatus.HALTED
        assert run_counts == {"a": 1, "b": 1}

        # Resume: stage a should be skipped (already passed); only b re-runs
        def exec2(ec):
            run_counts[ec.stage.id] += 1
            with open(os.path.join(ec.stage_ws, "final", "y.md"), "w") as f:
                f.write("ok")
            return StageExecResult(output="ok", status="completed")

        r2 = resume_pipeline(r1.workspace_path, cfg, stage_executor=exec2)
        assert r2.status == PipelineStatus.COMPLETED
        assert run_counts["a"] == 1   # NOT re-run
        assert run_counts["b"] == 2   # re-run once on resume

    def test_resume_after_retry_pass_uses_correct_dir(self, agent_dir):
        """§15 P1-2: stage passes on attempt 2 (_a2 dir); downstream after resume must
        consume that attempt's artifact, not the empty base stage dir."""
        # stage a fails attempt 1, passes attempt 2; stage b halts first run, then passes on resume.
        a_calls = {"n": 0}

        def a_exec(ec):
            a_calls["n"] += 1
            if a_calls["n"] >= 2:   # succeed only on the 2nd attempt
                with open(os.path.join(ec.stage_ws, "final", "x.md"), "w") as f:
                    f.write("content-from-attempt-2")
            return StageExecResult(output="ok", status="completed")

        consumed_path = {}

        def b_exec(ec):
            # capture the path a's artifact resolves to through the store
            ra = ec.consumed.get("a", {}).get("x.md")
            consumed_path["p"] = ra.path if ra else None
            return StageExecResult(output="", status="completed")  # fail → halt first time

        cfg = {"pipeline": {"id": "p", "stages": [
            {"id": "a", "retry": 2, "on_fail": "retry",
             "produces": [{"name": "x.md", "path": "final/x.md", "required": True}],
             "gate": {"kind": "rule", "rule": 'has("x.md")'}},
            {"id": "b", "consumes": ["a"], "on_fail": "halt",
             "produces": [{"name": "y.md", "path": "final/y.md", "required": True}],
             "gate": {"kind": "rule", "rule": 'has("y.md")'}},
        ]}}

        r1 = run_pipeline(cfg, "x", agent_dir,
                          stage_executor=lambda ec: (a_exec if ec.stage.id == "a" else b_exec)(ec))
        assert r1.status == PipelineStatus.HALTED
        # a passed on attempt 2 → its artifact lives in the _a2 dir
        assert consumed_path["p"] and consumed_path["p"].endswith("x.md")
        assert "_a2" in consumed_path["p"]

        # resume: a skipped (uses persisted _a2 workspace_path); b passes
        def b_exec2(ec):
            ra = ec.consumed.get("a", {}).get("x.md")
            consumed_path["resume"] = ra.path if ra else None
            with open(os.path.join(ec.stage_ws, "final", "y.md"), "w") as f:
                f.write("ok")
            return StageExecResult(output="ok", status="completed")

        r2 = resume_pipeline(r1.workspace_path, cfg,
                             stage_executor=lambda ec: b_exec2(ec))
        assert r2.status == PipelineStatus.COMPLETED
        # after resume, a's artifact still resolves from the correct (_a2) dir
        assert consumed_path["resume"] and "_a2" in consumed_path["resume"]


# ============================================================
# 7. LLM-judge gate
# ============================================================

class TestLLMJudgeGate:

    @pytest.mark.parametrize("text,expected", [
        ('{"score": 0.9, "reasons": ["good"]}', 0.9),
        ('Here is my verdict: {"score":0.4,"reasons":["meh"]} thanks', 0.4),
        ('score: 0.75 overall', 0.75),
        ('1.0', 1.0),
        ('total garbage, no number at all', 0.0),
        ('{"score": 5}', 1.0),     # clamped to [0,1]
    ])
    def test_parse_judge_response(self, text, expected):
        score, _ = _parse_judge_response(text)
        assert abs(score - expected) < 1e-6

    def test_judge_pass_injected(self, agent_dir):
        captured = {}

        def fake_judge(req):
            captured["rubric"] = req.rubric
            captured["text"] = req.artifacts_text
            return JudgeResult(score=0.85, passed=True, reasons=["covers everything"],
                               usage={"provider": "fake", "input_tokens": 10, "output_tokens": 5})

        cfg = {"pipeline": {"id": "p", "stages": [
            {"id": "a", "produces": [{"name": "essay.md", "path": "final/essay.md"}],
             "gate": {"kind": "llm_judge", "judge": {"rubric": "states a hypothesis", "threshold": 0.7}}},
        ]}}
        r = run_pipeline(cfg, "x", agent_dir,
                         stage_executor=_writer({"final/essay.md": "our hypothesis is X"}),
                         judge=fake_judge)
        assert r.status == PipelineStatus.COMPLETED
        v = r.stages[0].verdict
        assert v.passed is True and v.score == 0.85
        assert any("score=0.85" in s for s in v.reasons)
        assert any("judge_usage" in s for s in v.reasons)
        assert "hypothesis" in captured["text"]   # judge actually saw the artifact content

    def test_judge_fail_halts(self, agent_dir):
        def low_judge(req):
            return JudgeResult(score=0.30, passed=False, reasons=["too thin"])

        cfg = {"pipeline": {"id": "p", "stages": [
            {"id": "a", "on_fail": "halt",
             "produces": [{"name": "essay.md", "path": "final/essay.md"}],
             "gate": {"kind": "llm_judge", "judge": {"rubric": "deep analysis", "threshold": 0.7}}},
        ]}}
        r = run_pipeline(cfg, "x", agent_dir,
                         stage_executor=_writer({"final/essay.md": "thin"}),
                         judge=low_judge)
        assert r.status == PipelineStatus.HALTED
        assert r.stages[0].status == StageStatus.FAILED
        assert r.stages[0].verdict.score == 0.30

    @pytest.mark.skipif(not _ollama_up(), reason="ollama not reachable on :11434")
    def test_judge_live_ollama(self, agent_dir):
        """Real local-ollama judge — backs the self-hosted '逐级验收' selling point."""
        cfg = {"pipeline": {"id": "p", "stages": [
            {"id": "a", "on_fail": "halt",
             "produces": [{"name": "essay.md", "path": "final/essay.md"}],
             "gate": {"kind": "llm_judge", "judge": {
                 "model": {"provider": "ollama", "name": "qwen2.5:7b"},
                 "rubric": "The text must clearly state a research hypothesis.",
                 "threshold": 0.5}}},
        ]}}
        good = ("Our central hypothesis is that cost-graded effect types can predict "
                "an agent's resource consumption before execution. We test it on 835 configs.")
        r = run_pipeline(cfg, "topic", agent_dir,
                         stage_executor=_writer({"final/essay.md": good}))
        v = r.stages[0].verdict
        assert v is not None and v.score is not None
        assert 0.0 <= v.score <= 1.0
        assert r.status in (PipelineStatus.COMPLETED, PipelineStatus.HALTED)


# ============================================================
# 8. Cost estimation (②: judge as term, Paper34 prediction side)
# ============================================================

class TestCostEstimate:

    def test_estimate_pipeline_cost_includes_judge(self):
        """estimate_pipeline_cost folds agent + judge-term cost; llm_judge stage > 0."""
        cfg = {"pipeline": {"id": "p", "stages": [
            {"id": "a",
             "agent": {"type": "simple",
                       "model": {"provider": "ollama", "name": "qwen2.5:7b"},
                       "systemPrompt": "x"},
             "produces": [{"name": "a.md", "path": "final/a.md"}],
             "gate": {"kind": "llm_judge",
                      "judge": {"model": {"provider": "ollama", "name": "qwen2.5:7b"},
                                "rubric": "r", "threshold": 0.5}}},
        ]}}
        total, breakdown = estimate_pipeline_cost(load_pipeline(cfg))
        assert len(breakdown) == 1
        assert breakdown[0][0] == "a"
        assert total.tokens > 0            # judge term contributes a token upper bound
        assert 0 < total.probability <= 1

    def test_build_judge_term_is_a_term(self):
        from lambdagent.cost_grade import estimate_cost
        term = build_judge_term({"rubric": "r", "model": {"provider": "ollama", "name": "qwen2.5:7b"}})
        g = estimate_cost(term)            # judge is in the term graph → cost computable
        assert g.tokens > 0


# ============================================================
# 9. Live ollama: term judge + research demo end-to-end
# ============================================================

class TestLiveOllama:

    @pytest.mark.skipif(not _ollama_up(), reason="ollama not reachable on :11434")
    def test_term_judge_live(self, agent_dir):
        req = JudgeRequest(
            rubric="The text must clearly state a research hypothesis.",
            threshold=0.5, model={"provider": "ollama", "name": "qwen2.5:7b"},
            artifacts_text="### x\nOur hypothesis is that graded cost predicts agent resource use.")
        jr = term_judge(req)
        assert 0.0 <= jr.score <= 1.0
        assert jr.usage and jr.usage.get("in_term_graph") is True

    @pytest.mark.skipif(not _ollama_up(), reason="ollama not reachable on :11434")
    def test_research_demo_end_to_end(self, agent_dir):
        """① Real plan→lit run on local ollama via the research-demo preset.

        Asserts ORCHESTRATION invariants (terminal status, stage order, state +
        verdict persisted), not the small model's exact output — a live local-LLM
        e2e must not flake on model nondeterminism or transient ollama hiccups.
        """
        assert os.path.isfile(_DEMO_PRESET)
        r = run_pipeline(_DEMO_PRESET, "形式化资源语义在 AI agent 调度中的应用", agent_dir)
        # orchestration reached a terminal state and ran in declared order
        assert r.status in (PipelineStatus.COMPLETED, PipelineStatus.HALTED)
        assert [s.stage_id for s in r.stages][:1] == ["plan"]
        assert os.path.isfile(os.path.join(r.workspace_path, "pipeline_state.json"))
        # when the plan stage passes, the artifact bridge must have produced plan.md
        plan = r.stages[0]
        if plan.status == StageStatus.PASSED:
            assert os.path.isfile(os.path.join(plan.workspace_path, "final", "plan.md"))


# ============================================================
# Human gate: YIELD + resolve_human_gate（§6.3，P3 完成版）
# ============================================================

from agentpaas.engine.pipeline import resolve_human_gate


class TestHumanGate:

    def _cfg(self, on_fail="halt"):
        """s1 规则门 → s2 人工门 → s3 规则门（验证断点前后都正确）。"""
        return {"pipeline": {"id": "hg", "stages": [
            {"id": "s1",
             "produces": [{"name": "plan", "type": "md", "path": "plan.md"}],
             "gate": {"kind": "rule", "rule": 'has("plan")'}},
            {"id": "s2", "consumes": ["s1"],
             "produces": [{"name": "draft", "type": "md", "path": "draft.md"}],
             "gate": {"kind": "human", "prompt": "请导师确认草稿方向"},
             "on_fail": on_fail, "retry": 1},
            {"id": "s3", "consumes": ["s2"],
             "produces": [{"name": "final", "type": "md", "path": "final.md"}],
             "gate": {"kind": "rule", "rule": 'has("final")'}},
        ]}}

    def _execs(self, counter):
        def make(files):
            def _exec(ec):
                counter[ec.stage.id] = counter.get(ec.stage.id, 0) + 1
                for rel, content in files.items():
                    p = os.path.join(ec.stage_ws, rel)
                    os.makedirs(os.path.dirname(p) or ec.stage_ws, exist_ok=True)
                    with open(p, "w", encoding="utf-8") as f:
                        f.write(content)
                return StageExecResult(output="ok", status="completed")
            return _exec
        table = {"s1": make({"plan.md": "the plan"}),
                 "s2": make({"draft.md": "the draft"}),
                 "s3": make({"final.md": "the final"})}
        return lambda ec: table[ec.stage.id](ec)

    def test_yields_at_human_gate(self, agent_dir):
        n = {}
        r = run_pipeline(self._cfg(), "topic", agent_dir, stage_executor=self._execs(n))
        assert r.status == PipelineStatus.AWAITING_HUMAN
        assert n == {"s1": 1, "s2": 1}  # s3 没跑
        # state 落盘正确，verdict 是 pending 占位
        state = json.load(open(os.path.join(r.workspace_path, "pipeline_state.json")))
        s2 = state["stages"]["s2"]
        assert s2["status"] == "awaiting_human"
        assert s2["gate_prompt"] == "请导师确认草稿方向"
        v = json.load(open(os.path.join(s2["workspace_path"], "verdict.json")))
        assert v["passed"] is False and "PENDING_HUMAN" in v["reasons"][0]

    def test_approved_resumes_to_completion(self, agent_dir):
        n = {}
        ex = self._execs(n)
        r = run_pipeline(self._cfg(), "topic", agent_dir, stage_executor=ex)
        r2 = resolve_human_gate(self._cfg(), r.workspace_path, "approved",
                                note="方向没问题", stage_executor=ex)
        assert r2.status == PipelineStatus.COMPLETED
        # s1/s2 没重算，只补跑了 s3
        assert n == {"s1": 1, "s2": 1, "s3": 1}
        # human verdict 覆盖了 pending 占位
        state = json.load(open(os.path.join(r.workspace_path, "pipeline_state.json")))
        s2 = state["stages"]["s2"]
        assert s2["status"] == "passed"
        v = json.load(open(s2["verdict_path"]))
        assert v["passed"] is True and "方向没问题" in v["reasons"][0]

    def test_rejected_halt(self, agent_dir):
        n = {}
        ex = self._execs(n)
        r = run_pipeline(self._cfg(on_fail="halt"), "topic", agent_dir, stage_executor=ex)
        r2 = resolve_human_gate(self._cfg(on_fail="halt"), r.workspace_path,
                                "rejected", note="重写", stage_executor=ex)
        assert r2.status == PipelineStatus.HALTED
        assert n == {"s1": 1, "s2": 1}  # 不重跑
        state = json.load(open(os.path.join(r.workspace_path, "pipeline_state.json")))
        assert state["stages"]["s2"]["status"] == "failed"

    def test_rejected_retry_redoes_stage_then_awaits_again(self, agent_dir):
        n = {}
        ex = self._execs(n)
        r = run_pipeline(self._cfg(on_fail="retry"), "topic", agent_dir, stage_executor=ex)
        r2 = resolve_human_gate(self._cfg(on_fail="retry"), r.workspace_path,
                                "rejected", note="再来一版", stage_executor=ex)
        # 重做后再次停在人工门
        assert r2.status == PipelineStatus.AWAITING_HUMAN
        assert n == {"s1": 1, "s2": 2}  # s2 重跑一次, s1 不重算
        # 再批准 → 跑完
        r3 = resolve_human_gate(self._cfg(on_fail="retry"), r.workspace_path,
                                "approved", stage_executor=ex)
        assert r3.status == PipelineStatus.COMPLETED
        assert n == {"s1": 1, "s2": 2, "s3": 1}

    def test_rejected_skip_continues(self, agent_dir):
        n = {}
        ex = self._execs(n)
        r = run_pipeline(self._cfg(on_fail="skip"), "topic", agent_dir, stage_executor=ex)
        r2 = resolve_human_gate(self._cfg(on_fail="skip"), r.workspace_path,
                                "rejected", stage_executor=ex)
        assert r2.status == PipelineStatus.COMPLETED
        state = json.load(open(os.path.join(r.workspace_path, "pipeline_state.json")))
        assert state["stages"]["s2"]["status"] == "skipped"
        assert n == {"s1": 1, "s2": 1, "s3": 1}

    def test_bad_decision_and_no_awaiting(self, agent_dir):
        n = {}
        ex = self._execs(n)
        r = run_pipeline(self._cfg(), "topic", agent_dir, stage_executor=ex)
        with pytest.raises(PipelineError):
            resolve_human_gate(self._cfg(), r.workspace_path, "maybe")
        resolve_human_gate(self._cfg(), r.workspace_path, "approved", stage_executor=ex)
        with pytest.raises(PipelineError):  # 已无 awaiting 阶段
            resolve_human_gate(self._cfg(), r.workspace_path, "approved", stage_executor=ex)
