"""
Tests for the Runtime Plan Executor.

Tests cover:
  - Single step execution (each bridge type)
  - Multi-step with context passing
  - Signature validation (reject tampered plans)
  - TTL validation (reject expired plans)
  - Error handling (step failure returns partial results)
  - Context resolution
  - Cloud metadata safety (no user content leaked)
"""

from __future__ import annotations

import asyncio
import time
from dataclasses import dataclass
from typing import Optional
from unittest.mock import MagicMock, patch

import pytest

from runtime.executor.plan_executor import (
    ExecutionPlan,
    ExecutionResult,
    ExecutionStep,
    PlanExecutor,
    StepResult,
    sign_plan,
    validate_signature,
    validate_ttl,
)
from runtime.executor.context import resolve_context
from runtime.executor.local_bridge import BridgeResponse, LocalBridge, _estimate_tokens
from runtime.executor.cloud_bridge import CloudBridge
from runtime.executor.agent_bridge import AgentBridge


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

SIGNING_KEY = "test-secret-key-for-unit-tests"


def _make_plan(
    steps: list[ExecutionStep] | None = None,
    original_input: str = "test input",
    plan_id: str = "test-plan-001",
    ttl: int = 300,
    sign: bool = True,
) -> ExecutionPlan:
    """Create a test plan, optionally signed."""
    plan = ExecutionPlan(
        plan_id=plan_id,
        steps=steps or [],
        original_input=original_input,
        created_at=time.time(),
        ttl_seconds=ttl,
    )
    if sign:
        plan.signature = sign_plan(plan, SIGNING_KEY)
    return plan


def _make_step(
    step_id: str = "step_1",
    step_type: str = "local",
    backend: str = "ollama",
    model: str = "llama3",
    prompt: str = "Analyze: {original_input}",
    context_refs: list[str] | None = None,
    endpoint: str = "",
    auth_token: str = "",
    api_key: str = "",
    critical: bool = True,
) -> ExecutionStep:
    """Create a test execution step."""
    return ExecutionStep(
        step_id=step_id,
        type=step_type,
        backend=backend,
        model=model,
        prompt=prompt,
        context_refs=context_refs or [],
        endpoint=endpoint,
        auth_token=auth_token,
        api_key=api_key,
        critical=critical,
    )


def _make_bridge_response(
    output: str = "test output",
    success: bool = True,
    error: str | None = None,
    elapsed: float = 0.5,
    tokens: int = 10,
) -> BridgeResponse:
    """Create a mock bridge response."""
    return BridgeResponse(
        output=output,
        success=success,
        error=error,
        elapsed_seconds=elapsed,
        token_estimate=tokens,
        model="test-model",
        backend="test-backend",
    )


def _run_async(coro):
    """Run an async function synchronously for tests."""
    return asyncio.run(coro)


# ---------------------------------------------------------------------------
# Token estimation
# ---------------------------------------------------------------------------

class TestTokenEstimation:
    def test_empty_string(self):
        assert _estimate_tokens("") == 0

    def test_whitespace_only(self):
        assert _estimate_tokens("   ") == 0

    def test_single_word(self):
        assert _estimate_tokens("hello") == 2  # ceil(1 * 1.3)

    def test_multiple_words(self):
        tokens = _estimate_tokens("the quick brown fox")
        assert tokens == 6  # ceil(4 * 1.3) = ceil(5.2) = 6


# ---------------------------------------------------------------------------
# Signature validation
# ---------------------------------------------------------------------------

class TestSignatureValidation:
    def test_valid_signature(self):
        plan = _make_plan(sign=True)
        assert validate_signature(plan, SIGNING_KEY)

    def test_invalid_signature(self):
        plan = _make_plan(sign=True)
        plan.signature = "tampered-signature-value"
        assert not validate_signature(plan, SIGNING_KEY)

    def test_wrong_key(self):
        plan = _make_plan(sign=True)
        assert not validate_signature(plan, "wrong-key")

    def test_empty_signature(self):
        plan = _make_plan(sign=False)
        plan.signature = ""
        assert not validate_signature(plan, SIGNING_KEY)

    def test_empty_key(self):
        plan = _make_plan(sign=True)
        assert not validate_signature(plan, "")

    def test_tampered_plan_id(self):
        plan = _make_plan(sign=True)
        plan.plan_id = "tampered-id"
        assert not validate_signature(plan, SIGNING_KEY)

    def test_tampered_original_input(self):
        plan = _make_plan(sign=True, original_input="original")
        plan.original_input = "tampered"
        assert not validate_signature(plan, SIGNING_KEY)

    def test_tampered_step_added(self):
        steps = [_make_step(step_id="step_1")]
        plan = _make_plan(steps=steps, sign=True)
        plan.steps.append(_make_step(step_id="step_2"))
        assert not validate_signature(plan, SIGNING_KEY)

    def test_tampered_step_model_changed(self):
        steps = [_make_step(step_id="step_1", model="llama3")]
        plan = _make_plan(steps=steps, sign=True)
        plan.steps[0].model = "malicious-model"
        assert not validate_signature(plan, SIGNING_KEY)

    def test_sign_and_verify_roundtrip(self):
        steps = [
            _make_step(step_id="step_1", model="llama3"),
            _make_step(step_id="step_2", backend="claude", model="claude-sonnet-4-20250514"),
        ]
        plan = _make_plan(steps=steps, original_input="complex input", sign=False)
        plan.signature = sign_plan(plan, SIGNING_KEY)
        assert validate_signature(plan, SIGNING_KEY)


# ---------------------------------------------------------------------------
# TTL validation
# ---------------------------------------------------------------------------

class TestTTLValidation:
    def test_fresh_plan(self):
        plan = _make_plan(ttl=300)
        assert validate_ttl(plan)

    def test_expired_plan(self):
        plan = _make_plan(ttl=300)
        plan.created_at = time.time() - 600  # 10 minutes ago
        assert not validate_ttl(plan)

    def test_zero_ttl_means_no_expiry(self):
        plan = _make_plan(ttl=0)
        plan.created_at = time.time() - 999999
        assert validate_ttl(plan)

    def test_just_expired(self):
        plan = _make_plan(ttl=60)
        plan.created_at = time.time() - 61  # Just over the limit
        assert not validate_ttl(plan)

    def test_not_yet_expired(self):
        plan = _make_plan(ttl=60)
        plan.created_at = time.time() - 30  # Halfway through
        assert validate_ttl(plan)


# ---------------------------------------------------------------------------
# Context resolution
# ---------------------------------------------------------------------------

class TestContextResolution:
    def test_original_input_substitution(self):
        step = _make_step(prompt="Analyze: {original_input}")
        plan = _make_plan(original_input="Q4 revenue was $2.3M")
        result = resolve_context(step, plan, {})
        assert result == "Analyze: Q4 revenue was $2.3M"

    def test_step_output_substitution(self):
        step = _make_step(
            step_id="step_2",
            prompt="Review this: {step_1_output}",
            context_refs=["step_1"],
        )
        plan = _make_plan()
        prev = {
            "step_1": StepResult(step_id="step_1", output="Analysis complete.", success=True),
        }
        result = resolve_context(step, plan, prev)
        assert result == "Review this: Analysis complete."

    def test_multiple_refs(self):
        step = _make_step(
            step_id="step_3",
            prompt="Combine:\n{step_1_output}\n{step_2_output}",
            context_refs=["step_1", "step_2"],
        )
        plan = _make_plan()
        prev = {
            "step_1": StepResult(step_id="step_1", output="First.", success=True),
            "step_2": StepResult(step_id="step_2", output="Second.", success=True),
        }
        result = resolve_context(step, plan, prev)
        assert "First." in result
        assert "Second." in result

    def test_failed_step_ref_gives_empty_string(self):
        step = _make_step(
            step_id="step_2",
            prompt="Review: {step_1_output}",
            context_refs=["step_1"],
        )
        plan = _make_plan()
        prev = {
            "step_1": StepResult(step_id="step_1", output="", success=False, error="Timeout"),
        }
        result = resolve_context(step, plan, prev)
        assert result == "Review: "

    def test_unknown_ref_left_untouched(self):
        step = _make_step(prompt="Value: {unknown_variable}")
        plan = _make_plan()
        result = resolve_context(step, plan, {})
        assert result == "Value: {unknown_variable}"

    def test_combined_original_input_and_step_refs(self):
        step = _make_step(
            step_id="step_2",
            prompt="Input: {original_input}\nPrior: {step_1_output}",
            context_refs=["step_1"],
        )
        plan = _make_plan(original_input="user question")
        prev = {
            "step_1": StepResult(step_id="step_1", output="analysis result", success=True),
        }
        result = resolve_context(step, plan, prev)
        assert result == "Input: user question\nPrior: analysis result"


# ---------------------------------------------------------------------------
# Single step execution (each bridge type)
# ---------------------------------------------------------------------------

class TestSingleStepExecution:
    """Test executing plans with a single step, one for each bridge type."""

    def test_local_step(self):
        """Local (Ollama) step execution."""
        mock_local = MagicMock(spec=LocalBridge)
        mock_local.call.return_value = _make_bridge_response(output="local reply")

        executor = PlanExecutor(
            signing_key=SIGNING_KEY,
            local_bridge=mock_local,
        )

        step = _make_step(step_id="step_1", step_type="local", backend="ollama", model="llama3")
        plan = _make_plan(steps=[step])

        result = _run_async(executor.execute(plan))

        assert result.success
        assert result.final_output == "local reply"
        assert len(result.steps) == 1
        assert result.steps[0].success
        mock_local.call.assert_called_once()

    def test_cloud_step(self):
        """Cloud (Claude/Gemini/GPT) step execution."""
        mock_cloud = MagicMock(spec=CloudBridge)
        mock_cloud.call.return_value = _make_bridge_response(output="cloud reply")

        executor = PlanExecutor(
            signing_key=SIGNING_KEY,
            cloud_bridge=mock_cloud,
        )

        step = _make_step(
            step_id="step_1",
            step_type="cloud",
            backend="claude",
            model="claude-sonnet-4-20250514",
            api_key="sk-test-key",
        )
        plan = _make_plan(steps=[step])

        result = _run_async(executor.execute(plan))

        assert result.success
        assert result.final_output == "cloud reply"
        mock_cloud.call.assert_called_once()
        # Verify the api_key was passed through
        call_kwargs = mock_cloud.call.call_args
        assert call_kwargs.kwargs.get("api_key") == "sk-test-key" or \
               call_kwargs[1].get("api_key") == "sk-test-key" or \
               "sk-test-key" in str(call_kwargs)

    def test_local_agent_step(self):
        """Local agent step execution."""
        mock_agent = MagicMock(spec=AgentBridge)
        mock_agent.call_local.return_value = _make_bridge_response(output="agent reply")

        executor = PlanExecutor(
            signing_key=SIGNING_KEY,
            agent_bridge=mock_agent,
        )

        step = _make_step(
            step_id="step_1",
            step_type="local_agent",
            model="dexter-financial-v2",
            endpoint="localhost:9100",
        )
        plan = _make_plan(steps=[step])

        result = _run_async(executor.execute(plan))

        assert result.success
        assert result.final_output == "agent reply"
        mock_agent.call_local.assert_called_once()

    def test_remote_agent_step(self):
        """Remote agent step execution."""
        mock_agent = MagicMock(spec=AgentBridge)
        mock_agent.call_remote.return_value = _make_bridge_response(output="remote reply")

        executor = PlanExecutor(
            signing_key=SIGNING_KEY,
            agent_bridge=mock_agent,
        )

        step = _make_step(
            step_id="step_1",
            step_type="remote_agent",
            model="acme-contract-review",
            endpoint="https://api.acme-agents.com/v1/review",
            auth_token="mkt-token-123",
        )
        plan = _make_plan(steps=[step])

        result = _run_async(executor.execute(plan))

        assert result.success
        assert result.final_output == "remote reply"
        mock_agent.call_remote.assert_called_once()

    def test_unknown_step_type(self):
        """Unknown step type fails gracefully."""
        executor = PlanExecutor(signing_key=SIGNING_KEY)

        step = _make_step(step_id="step_1", step_type="quantum_computer")
        plan = _make_plan(steps=[step])

        result = _run_async(executor.execute(plan))

        assert not result.success
        assert "Unknown step type" in result.error


# ---------------------------------------------------------------------------
# Multi-step with context passing
# ---------------------------------------------------------------------------

class TestMultiStepExecution:
    """Test executing multi-step plans with context flowing between steps."""

    def test_three_step_chain(self):
        """Classic 3-step: Claude analyzes, Gemini fact-checks, Llama summarizes."""
        mock_local = MagicMock(spec=LocalBridge)
        mock_cloud = MagicMock(spec=CloudBridge)

        # Configure mock responses
        mock_cloud.call.side_effect = [
            _make_bridge_response(output="Claude analysis of the data"),
            _make_bridge_response(output="Gemini confirmed the facts"),
        ]
        mock_local.call.return_value = _make_bridge_response(output="Final summary")

        executor = PlanExecutor(
            signing_key=SIGNING_KEY,
            local_bridge=mock_local,
            cloud_bridge=mock_cloud,
        )

        steps = [
            _make_step(
                step_id="step_1",
                step_type="cloud",
                backend="claude",
                model="claude-sonnet-4-20250514",
                prompt="Analyze: {original_input}",
                api_key="claude-key",
            ),
            _make_step(
                step_id="step_2",
                step_type="cloud",
                backend="gemini",
                model="gemini-2.0-flash",
                prompt="Fact-check: {step_1_output}",
                context_refs=["step_1"],
                api_key="gemini-key",
            ),
            _make_step(
                step_id="step_3",
                step_type="local",
                backend="ollama",
                model="llama3",
                prompt="Summarize:\n{step_1_output}\n{step_2_output}",
                context_refs=["step_1", "step_2"],
            ),
        ]
        plan = _make_plan(steps=steps, original_input="Q4 revenue was $2.3M")

        result = _run_async(executor.execute(plan))

        assert result.success
        assert result.final_output == "Final summary"
        assert len(result.steps) == 3
        assert all(s.success for s in result.steps)
        assert result.total_tokens > 0

        # Verify context was passed: step 2's prompt should contain step 1's output
        step2_call = mock_cloud.call.call_args_list[1]
        step2_prompt = step2_call.kwargs.get("prompt", step2_call[0][2] if len(step2_call[0]) > 2 else "")
        # The prompt should have been resolved with step_1_output

    def test_context_passes_between_steps(self):
        """Verify that step N can access step N-1's output."""
        mock_local = MagicMock(spec=LocalBridge)

        call_count = [0]
        def mock_call(model, prompt, config=None):
            call_count[0] += 1
            if call_count[0] == 1:
                return _make_bridge_response(output="first_result")
            else:
                # The second step should receive the first step's output
                assert "first_result" in prompt
                return _make_bridge_response(output="second_result")

        mock_local.call.side_effect = mock_call

        executor = PlanExecutor(
            signing_key=SIGNING_KEY,
            local_bridge=mock_local,
        )

        steps = [
            _make_step(step_id="step_1", prompt="Start: {original_input}"),
            _make_step(step_id="step_2", prompt="Continue: {step_1_output}"),
        ]
        plan = _make_plan(steps=steps)

        result = _run_async(executor.execute(plan))

        assert result.success
        assert result.final_output == "second_result"
        assert call_count[0] == 2


# ---------------------------------------------------------------------------
# Signature rejection
# ---------------------------------------------------------------------------

class TestSignatureRejection:
    """Plans with invalid signatures must be rejected."""

    def test_tampered_plan_rejected(self):
        executor = PlanExecutor(signing_key=SIGNING_KEY)
        plan = _make_plan(sign=True)
        plan.signature = "definitely-not-valid"

        result = _run_async(executor.execute(plan))

        assert not result.success
        assert "signature" in result.error.lower() or "tamper" in result.error.lower()
        assert len(result.steps) == 0  # No steps executed

    def test_unsigned_plan_rejected(self):
        executor = PlanExecutor(signing_key=SIGNING_KEY)
        plan = _make_plan(sign=False)
        plan.signature = ""

        result = _run_async(executor.execute(plan))

        assert not result.success
        assert len(result.steps) == 0

    def test_skip_signature_check(self):
        """skip_signature_check=True allows unsigned plans (for testing only)."""
        mock_local = MagicMock(spec=LocalBridge)
        mock_local.call.return_value = _make_bridge_response(output="ok")

        executor = PlanExecutor(
            signing_key=SIGNING_KEY,
            skip_signature_check=True,
            local_bridge=mock_local,
        )

        plan = _make_plan(sign=False, steps=[_make_step()])
        plan.signature = ""

        result = _run_async(executor.execute(plan))

        assert result.success


# ---------------------------------------------------------------------------
# TTL rejection
# ---------------------------------------------------------------------------

class TestTTLRejection:
    """Expired plans must be rejected."""

    def test_expired_plan_rejected(self):
        executor = PlanExecutor(signing_key=SIGNING_KEY)
        plan = _make_plan(ttl=60, sign=True)
        plan.created_at = time.time() - 120  # Expired 60 seconds ago
        # Re-sign with the old created_at
        plan.signature = sign_plan(plan, SIGNING_KEY)

        result = _run_async(executor.execute(plan))

        assert not result.success
        assert "expired" in result.error.lower() or "ttl" in result.error.lower()
        assert len(result.steps) == 0

    def test_skip_ttl_check(self):
        """skip_ttl_check=True allows expired plans (for testing only)."""
        mock_local = MagicMock(spec=LocalBridge)
        mock_local.call.return_value = _make_bridge_response(output="ok")

        executor = PlanExecutor(
            signing_key=SIGNING_KEY,
            skip_ttl_check=True,
            local_bridge=mock_local,
        )

        plan = _make_plan(ttl=60, steps=[_make_step()])
        plan.created_at = time.time() - 120  # Expired
        plan.signature = sign_plan(plan, SIGNING_KEY)

        result = _run_async(executor.execute(plan))

        assert result.success


# ---------------------------------------------------------------------------
# Error handling
# ---------------------------------------------------------------------------

class TestErrorHandling:
    """Step failures should stop execution and return partial results."""

    def test_first_step_failure(self):
        mock_local = MagicMock(spec=LocalBridge)
        mock_local.call.return_value = _make_bridge_response(
            output="", success=False, error="Connection refused"
        )

        executor = PlanExecutor(
            signing_key=SIGNING_KEY,
            local_bridge=mock_local,
        )

        steps = [
            _make_step(step_id="step_1"),
            _make_step(step_id="step_2"),
        ]
        plan = _make_plan(steps=steps)

        result = _run_async(executor.execute(plan))

        assert not result.success
        assert len(result.steps) == 1  # Only step_1 attempted
        assert "step_1" in result.error
        assert result.final_output == ""  # No successful output

    def test_middle_step_failure_returns_partial(self):
        mock_local = MagicMock(spec=LocalBridge)
        mock_local.call.side_effect = [
            _make_bridge_response(output="step 1 ok"),
            _make_bridge_response(output="", success=False, error="Model crashed"),
            _make_bridge_response(output="step 3 should not run"),
        ]

        executor = PlanExecutor(
            signing_key=SIGNING_KEY,
            local_bridge=mock_local,
        )

        steps = [
            _make_step(step_id="step_1"),
            _make_step(step_id="step_2"),
            _make_step(step_id="step_3"),
        ]
        plan = _make_plan(steps=steps)

        result = _run_async(executor.execute(plan))

        assert not result.success
        assert len(result.steps) == 2  # step_1 + step_2, step_3 skipped
        assert result.steps[0].success
        assert not result.steps[1].success
        assert result.final_output == "step 1 ok"  # Last successful output

    def test_empty_plan_returns_error(self):
        executor = PlanExecutor(signing_key=SIGNING_KEY)
        plan = _make_plan(steps=[])

        result = _run_async(executor.execute(plan))

        assert not result.success
        assert result.error == "Plan has no steps to execute"
        assert len(result.steps) == 0
        assert result.final_output == ""


# ---------------------------------------------------------------------------
# Partial result recovery (non-critical steps)
# ---------------------------------------------------------------------------

class TestPartialResultRecovery:
    """Non-critical step failures should continue execution."""

    def test_non_critical_step_failure_continues(self):
        """A non-critical step failing should not abort the plan."""
        mock_local = MagicMock(spec=LocalBridge)
        mock_local.call.side_effect = [
            _make_bridge_response(output="step 1 ok"),
            _make_bridge_response(output="", success=False, error="Model crashed"),
            _make_bridge_response(output="step 3 ok"),
        ]

        executor = PlanExecutor(
            signing_key=SIGNING_KEY,
            local_bridge=mock_local,
        )

        steps = [
            _make_step(step_id="step_1"),
            _make_step(step_id="step_2", critical=False),
            _make_step(step_id="step_3"),
        ]
        plan = _make_plan(steps=steps)

        result = _run_async(executor.execute(plan))

        assert result.success
        assert len(result.steps) == 3
        assert result.steps[0].success
        assert not result.steps[1].success
        assert result.steps[1].error == "Model crashed"
        assert result.steps[2].success
        assert result.final_output == "step 3 ok"

    def test_critical_step_failure_still_aborts(self):
        """Critical steps (default) still abort on failure."""
        mock_local = MagicMock(spec=LocalBridge)
        mock_local.call.side_effect = [
            _make_bridge_response(output="step 1 ok"),
            _make_bridge_response(output="", success=False, error="Model crashed"),
            _make_bridge_response(output="step 3 should not run"),
        ]

        executor = PlanExecutor(
            signing_key=SIGNING_KEY,
            local_bridge=mock_local,
        )

        steps = [
            _make_step(step_id="step_1"),
            _make_step(step_id="step_2", critical=True),
            _make_step(step_id="step_3"),
        ]
        plan = _make_plan(steps=steps)

        result = _run_async(executor.execute(plan))

        assert not result.success
        assert len(result.steps) == 2  # step_3 skipped
        assert "step_2" in result.error

    def test_non_critical_failure_error_in_step_result(self):
        """The error from a non-critical failure is preserved in the step result."""
        mock_local = MagicMock(spec=LocalBridge)
        mock_local.call.side_effect = [
            _make_bridge_response(output="", success=False, error="Ollama not running"),
            _make_bridge_response(output="final answer"),
        ]

        executor = PlanExecutor(
            signing_key=SIGNING_KEY,
            local_bridge=mock_local,
        )

        steps = [
            _make_step(step_id="step_1", critical=False),
            _make_step(step_id="step_2"),
        ]
        plan = _make_plan(steps=steps)

        result = _run_async(executor.execute(plan))

        assert result.success
        assert not result.steps[0].success
        assert result.steps[0].error == "Ollama not running"
        assert result.steps[1].success
        assert result.final_output == "final answer"

    def test_non_critical_timeout_continues(self):
        """A non-critical step that times out should not abort."""
        mock_local = MagicMock(spec=LocalBridge)

        call_count = [0]
        def side_effect_fn(model, prompt, config=None):
            call_count[0] += 1
            if call_count[0] == 1:
                import time as _t
                _t.sleep(5)
                return _make_bridge_response(output="too slow")
            return _make_bridge_response(output="step 2 ok")

        mock_local.call.side_effect = side_effect_fn

        executor = PlanExecutor(
            signing_key=SIGNING_KEY,
            local_bridge=mock_local,
        )

        # Patch timeout to 0.1s for test speed
        import runtime.executor.plan_executor as pe_mod
        original_timeout = pe_mod.STEP_TIMEOUT_SECONDS
        pe_mod.STEP_TIMEOUT_SECONDS = 0.1

        try:
            steps = [
                _make_step(step_id="step_1", critical=False),
                _make_step(step_id="step_2"),
            ]
            plan = _make_plan(steps=steps)

            result = _run_async(executor.execute(plan))

            assert result.success
            assert len(result.steps) == 2
            assert not result.steps[0].success
            assert "timed out" in result.steps[0].error
            assert result.steps[1].success
        finally:
            pe_mod.STEP_TIMEOUT_SECONDS = original_timeout

    def test_non_critical_exception_continues(self):
        """A non-critical step that raises an exception should not abort."""
        mock_local = MagicMock(spec=LocalBridge)
        mock_local.call.side_effect = [
            ConnectionError("Ollama refused connection"),
            _make_bridge_response(output="fallback ok"),
        ]

        executor = PlanExecutor(
            signing_key=SIGNING_KEY,
            local_bridge=mock_local,
        )

        steps = [
            _make_step(step_id="step_1", critical=False),
            _make_step(step_id="step_2"),
        ]
        plan = _make_plan(steps=steps)

        result = _run_async(executor.execute(plan))

        assert result.success
        assert len(result.steps) == 2
        assert not result.steps[0].success
        assert "ConnectionError" in result.steps[0].error
        assert result.steps[1].success

    def test_all_non_critical_fail_plan_still_succeeds(self):
        """If only non-critical steps fail, the plan still succeeds."""
        mock_local = MagicMock(spec=LocalBridge)
        mock_local.call.side_effect = [
            _make_bridge_response(output="", success=False, error="fail 1"),
            _make_bridge_response(output="", success=False, error="fail 2"),
            _make_bridge_response(output="the important one"),
        ]

        executor = PlanExecutor(
            signing_key=SIGNING_KEY,
            local_bridge=mock_local,
        )

        steps = [
            _make_step(step_id="step_1", critical=False),
            _make_step(step_id="step_2", critical=False),
            _make_step(step_id="step_3", critical=True),
        ]
        plan = _make_plan(steps=steps)

        result = _run_async(executor.execute(plan))

        assert result.success
        assert len(result.steps) == 3
        assert not result.steps[0].success
        assert not result.steps[1].success
        assert result.steps[2].success
        assert result.final_output == "the important one"

    def test_default_critical_is_true(self):
        """Steps are critical by default (backward compatibility)."""
        step = ExecutionStep()
        assert step.critical is True


# ---------------------------------------------------------------------------
# Cloud metadata safety
# ---------------------------------------------------------------------------

class TestCloudMetadata:
    """Verify that metadata sent to cloud contains NO user content."""

    def test_metadata_has_no_content(self):
        mock_local = MagicMock(spec=LocalBridge)
        mock_local.call.return_value = _make_bridge_response(
            output="This is sensitive user data that must not be leaked"
        )

        executor = PlanExecutor(
            signing_key=SIGNING_KEY,
            local_bridge=mock_local,
        )

        plan = _make_plan(
            steps=[_make_step()],
            original_input="Very private financial documents",
        )

        result = _run_async(executor.execute(plan))

        metadata = result.metadata_for_cloud
        metadata_str = str(metadata)

        # None of the user content should appear in cloud metadata
        assert "sensitive user data" not in metadata_str
        assert "private financial" not in metadata_str
        assert "Very private" not in metadata_str

        # But metadata should contain expected fields
        assert "plan_id" in metadata
        assert "success" in metadata
        assert "total_elapsed" in metadata
        assert "total_tokens" in metadata
        assert "steps" in metadata

    def test_metadata_step_has_timing_and_tokens(self):
        mock_local = MagicMock(spec=LocalBridge)
        mock_local.call.return_value = _make_bridge_response(
            output="reply", elapsed=1.234, tokens=42,
        )

        executor = PlanExecutor(
            signing_key=SIGNING_KEY,
            local_bridge=mock_local,
        )

        plan = _make_plan(steps=[_make_step(step_id="step_1")])
        result = _run_async(executor.execute(plan))

        step_meta = result.metadata_for_cloud["steps"][0]
        assert step_meta["step_id"] == "step_1"
        assert step_meta["success"] is True
        assert "elapsed_seconds" in step_meta
        assert "token_estimate" in step_meta
        assert "output" not in step_meta  # Critically: no output in metadata


# ---------------------------------------------------------------------------
# Bridge response handling
# ---------------------------------------------------------------------------

class TestBridgeResponse:
    def test_default_values(self):
        resp = BridgeResponse()
        assert resp.output == ""
        assert resp.success is True
        assert resp.error is None
        assert resp.elapsed_seconds == 0.0
        assert resp.token_estimate == 0

    def test_error_response(self):
        resp = BridgeResponse(
            success=False,
            error="Connection refused",
        )
        assert not resp.success
        assert resp.error == "Connection refused"
        assert resp.output == ""


# ---------------------------------------------------------------------------
# Plan data classes
# ---------------------------------------------------------------------------

class TestDataClasses:
    def test_execution_step_defaults(self):
        step = ExecutionStep()
        assert step.step_id == ""
        assert step.type == "local"
        assert step.backend == ""
        assert step.model == ""
        assert step.prompt == ""
        assert step.context_refs == []
        assert step.config == {}

    def test_execution_plan_defaults(self):
        plan = ExecutionPlan()
        assert plan.plan_id == ""
        assert plan.steps == []
        assert plan.original_input == ""
        assert plan.signature == ""
        assert plan.ttl_seconds == 300

    def test_step_result_defaults(self):
        result = StepResult()
        assert result.step_id == ""
        assert result.output == ""
        assert result.success is True

    def test_execution_result_defaults(self):
        result = ExecutionResult()
        assert result.plan_id == ""
        assert result.steps == []
        assert result.final_output == ""
        assert result.success is True


# ---------------------------------------------------------------------------
# Integration-style: full plan lifecycle
# ---------------------------------------------------------------------------

class TestFullLifecycle:
    """End-to-end test simulating the complete plan lifecycle."""

    def test_sign_validate_execute(self):
        """Full lifecycle: create plan, sign it, execute it."""
        mock_local = MagicMock(spec=LocalBridge)
        mock_cloud = MagicMock(spec=CloudBridge)

        mock_cloud.call.return_value = _make_bridge_response(output="analysis done")
        mock_local.call.return_value = _make_bridge_response(output="summary done")

        # 1. Create an unsigned plan
        steps = [
            _make_step(
                step_id="step_1",
                step_type="cloud",
                backend="claude",
                model="claude-sonnet-4-20250514",
                prompt="Analyze: {original_input}",
                api_key="sk-test",
            ),
            _make_step(
                step_id="step_2",
                step_type="local",
                backend="ollama",
                model="llama3",
                prompt="Summarize: {step_1_output}",
                context_refs=["step_1"],
            ),
        ]
        plan = ExecutionPlan(
            plan_id="lifecycle-test-001",
            steps=steps,
            original_input="Q4 revenue report",
            created_at=time.time(),
            ttl_seconds=300,
        )

        # 2. Sign the plan (normally done by Brynq Cloud)
        plan.signature = sign_plan(plan, SIGNING_KEY)

        # 3. Validate signature (sanity check)
        assert validate_signature(plan, SIGNING_KEY)

        # 4. Execute the plan
        executor = PlanExecutor(
            signing_key=SIGNING_KEY,
            local_bridge=mock_local,
            cloud_bridge=mock_cloud,
        )
        result = _run_async(executor.execute(plan))

        # 5. Verify
        assert result.success
        assert result.plan_id == "lifecycle-test-001"
        assert result.final_output == "summary done"
        assert len(result.steps) == 2
        assert result.total_tokens > 0
        assert result.total_elapsed > 0

        # 6. Metadata is safe for cloud
        meta = result.metadata_for_cloud
        assert "Q4 revenue" not in str(meta)
        assert "analysis done" not in str(meta)
        assert "summary done" not in str(meta)
