from __future__ import annotations

import asyncio
from pathlib import Path

import pytest

from outline_agent.clients.model_client import ModelClientError
from outline_agent.clients.outline_models import OutlineCollection, OutlineDocument
from outline_agent.core.config import AppSettings
from outline_agent.planning.execution_loop import (
    UnifiedExecutionLoop,
    _hydrate_document_action_args,
    _hydrate_drafting_context_args,
    _validate_document_action_args,
)
from outline_agent.planning.tool_plan_schema import (
    ToolPlanValidationError,
    UnifiedToolPlan,
    UnifiedToolPlanStep,
    sanitize_unified_tool_plan,
)
from outline_agent.processing.processor_progress import (
    describe_tool_result_for_progress,
    describe_tool_start_for_progress,
)
from outline_agent.runtime.tool_runtime import ToolStepResult
from outline_agent.tools import (
    ApplyDocumentUpdateTool,
    CreateDocumentTool,
    GetCurrentDocumentTool,
    ToolApprovalDecision,
    ToolContext,
    ToolRegistry,
    build_default_extract_text_tools,
    build_workspace_tools,
)


class DummyOutlineClient:
    def __init__(self) -> None:
        self.downloads: list[dict[str, str]] = []
        self.created_documents: list[dict[str, str | bool | None]] = []
        self.updated_documents: list[dict[str, str | None]] = []

    async def download_attachment(self, url_or_path: str, file_path: Path) -> dict:
        self.downloads.append({"url_or_path": url_or_path, "file_path": str(file_path)})
        file_path.parent.mkdir(parents=True, exist_ok=True)
        file_path.write_text("Line one\nLine two\n", encoding="utf-8")
        return {
            "ok": True,
            "url": url_or_path,
            "file_path": str(file_path),
            "size": file_path.stat().st_size,
            "content_type": "text/plain",
        }

    async def create_document(
        self,
        *,
        title: str,
        text: str,
        collection_id: str,
        parent_document_id: str | None = None,
        publish: bool = True,
    ) -> OutlineDocument:
        created_id = f"created-{len(self.created_documents) + 1}"
        self.created_documents.append(
            {
                "title": title,
                "text": text,
                "collection_id": collection_id,
                "parent_document_id": parent_document_id,
                "publish": publish,
            }
        )
        return OutlineDocument(
            id=created_id,
            title=title,
            collection_id=collection_id,
            url=f"/doc/{created_id}",
            text=text,
        )

    async def update_document(
        self,
        document_id: str,
        *,
        title: str | None = None,
        text: str | None = None,
    ) -> None:
        self.updated_documents.append(
            {
                "document_id": document_id,
                "title": title,
                "text": text,
            }
        )


def _build_registry() -> ToolRegistry:
    registry = ToolRegistry()
    registry.register(GetCurrentDocumentTool())
    registry.register_many(build_workspace_tools())
    registry.register(CreateDocumentTool())
    registry.register_many(build_default_extract_text_tools())
    return registry


def _build_context(tmp_path: Path, outline_client: DummyOutlineClient | None = None) -> ToolContext:
    settings = AppSettings(
        outline_api_key="ol_api_test",
        outline_webhook_signing_secret="ol_whs_test",
        workspace_root=tmp_path / "agents",
        dedupe_store_path=tmp_path / "processed.json",
        dry_run=False,
    )
    work_dir = tmp_path / "work"
    work_dir.mkdir(parents=True, exist_ok=True)
    return ToolContext(
        settings=settings,
        outline_client=outline_client,
        work_dir=work_dir,
        collection=OutlineCollection(id="collection-1", name="Test", description=None, url=None),
        document=OutlineDocument(
            id="doc-1",
            title="Current Doc",
            collection_id="collection-1",
            url="/doc/doc-1",
            text="# Current\n\nHello world",
        ),
    )


def test_get_current_document_tool_returns_document_text(tmp_path: Path) -> None:
    registry = _build_registry()
    context = _build_context(tmp_path)

    result = asyncio.run(registry.execute("get_current_document", {}, context))

    assert result.ok is True
    assert result.data["document_id"] == "doc-1"
    assert result.data["text"] == "# Current\n\nHello world"


def test_tool_registry_blocks_execution_when_approval_policy_denies(tmp_path: Path) -> None:
    class DenyAllPolicy:
        async def authorize(self, request, context):
            del context
            return ToolApprovalDecision(approved=False, reason=f"blocked by test policy: {request.tool_name}")

    class ExplodingTool:
        @property
        def spec(self):
            from outline_agent.tools.base import ToolSpec

            return ToolSpec(name="dangerous_tool", description="x", side_effect_level="write")

        async def run(self, args, context):
            del args, context
            raise AssertionError("tool body should not run when approval is denied")

    registry = ToolRegistry()
    registry.register(ExplodingTool())
    context = _build_context(tmp_path)
    context.tool_approval_policy = DenyAllPolicy()

    result = asyncio.run(registry.execute("dangerous_tool", {"x": 1}, context))

    assert result.ok is False
    assert result.error == "approval-denied"
    assert result.data["approval_reason"] == "blocked by test policy: dangerous_tool"
    assert result.summary == "dangerous_tool: approval denied: blocked by test policy: dangerous_tool"
    assert result.data["approval"]["status"] == "denied"
    assert result.data["approval"]["mode"] == "always_allow"


def test_extract_text_from_pdf_reads_literal_strings(tmp_path: Path) -> None:
    registry = _build_registry()
    context = _build_context(tmp_path)
    pdf_path = context.work_dir / "sample.pdf"
    pdf_path.write_bytes(b"%PDF-1.4\n1 0 obj\n<< /Length 20 >>\nstream\nBT\n(Hello PDF) Tj\nET\nendstream\nendobj\n")

    result = asyncio.run(registry.execute("extract_text_from_pdf", {"path": "sample.pdf"}, context))

    assert result.ok is True
    assert result.data["format"] == "pdf"
    assert "Hello PDF" in result.data["text"]


def test_sanitize_unified_tool_plan_rejects_unknown_tool() -> None:
    plan = UnifiedToolPlan(
        should_act=True,
        steps=[UnifiedToolPlanStep(tool="missing_tool", args={})],
    )

    with pytest.raises(ToolPlanValidationError):
        sanitize_unified_tool_plan(plan, allowed_tools={"get_current_document"}, max_steps=4)


def test_unified_execution_loop_can_compose_download_extract_and_create_document(tmp_path: Path) -> None:
    outline_client = DummyOutlineClient()
    registry = _build_registry()
    context = _build_context(tmp_path, outline_client=outline_client)
    loop = UnifiedExecutionLoop(registry, max_steps=4)
    plan = UnifiedToolPlan(
        should_act=True,
        goal="Read the attachment and create a new document from its text.",
        final_response_strategy="brief_confirmation",
        steps=[
            UnifiedToolPlanStep(
                tool="download_attachment",
                args={
                    "attachment_url": "/api/attachments.redirect?id=attachment-1",
                    "path": "attachments/input.txt",
                },
            ),
            UnifiedToolPlanStep(
                tool="extract_text_from_txt",
                args={"path": "{{steps.1.data.path}}"},
            ),
            UnifiedToolPlanStep(
                tool="create_document",
                args={
                    "title": "Imported Attachment",
                    "text": "{{steps.2.data.text}}",
                },
            ),
        ],
    )

    summary = asyncio.run(loop.execute(plan, context))

    assert summary.status == "applied"
    assert [step.tool for step in summary.steps] == [
        "download_attachment",
        "extract_text_from_txt",
        "create_document",
    ]
    assert outline_client.downloads == [
        {
            "url_or_path": "/api/attachments.redirect?id=attachment-1",
            "file_path": str(context.work_dir / "attachments" / "input.txt"),
        }
    ]
    assert outline_client.created_documents == [
        {
            "title": "Imported Attachment",
            "text": "Line one\nLine two",
            "collection_id": "collection-1",
            "parent_document_id": None,
            "publish": True,
        }
    ]
    assert summary.preview is not None
    assert "Created document 'Imported Attachment'." in summary.preview


def test_create_document_tool_blocks_invalid_mermaid_before_outline_write(tmp_path: Path, monkeypatch) -> None:
    outline_client = DummyOutlineClient()
    context = _build_context(tmp_path, outline_client=outline_client)
    tool = CreateDocumentTool()
    monkeypatch.setattr(
        "outline_agent.tools.outline_tools.build_mermaid_validation_failure",
        lambda **kwargs: (
            "create_document: MERMAID_VALIDATION_FAILED: block 1, markdown line 1, mermaid line 2: Parse error",
            {"mermaid_validation": {"status": "invalid"}},
        ),
    )

    result = asyncio.run(
        tool.run(
            {
                "title": "Diagram Doc",
                "text": "```mermaid\ngraph TD\n  A --> B[broken\n```",
            },
            context,
        )
    )

    assert result.ok is False
    assert "MERMAID_VALIDATION_FAILED" in (result.error or "")
    assert outline_client.created_documents == []


def test_apply_document_update_tool_blocks_invalid_mermaid_before_outline_write(tmp_path: Path, monkeypatch) -> None:
    outline_client = DummyOutlineClient()
    context = _build_context(tmp_path, outline_client=outline_client)
    tool = ApplyDocumentUpdateTool()
    monkeypatch.setattr(
        "outline_agent.tools.document_actions._require_document_context",
        lambda context: (None, None, context.collection, context.document),
    )
    monkeypatch.setattr(
        "outline_agent.tools.document_actions.build_mermaid_validation_failure",
        lambda **kwargs: (
            "apply_document_update: MERMAID_VALIDATION_FAILED: block 1, markdown line 1, mermaid line 2: Parse error",
            {"mermaid_validation": {"status": "invalid"}},
        ),
    )

    result = asyncio.run(
        tool.run(
            {
                "text": "```mermaid\ngraph TD\n  A --> B[broken\n```",
            },
            context,
        )
    )

    assert result.ok is False
    assert "MERMAID_VALIDATION_FAILED" in (result.error or "")
    assert outline_client.updated_documents == []


def test_create_document_tool_can_bypass_mermaid_validation_after_retry_exhaustion(
    tmp_path: Path, monkeypatch
) -> None:
    outline_client = DummyOutlineClient()
    context = _build_context(tmp_path, outline_client=outline_client)
    context.extra["mermaid_validation_failures"] = context.settings.mermaid_validation_max_retries
    tool = CreateDocumentTool()

    captured: dict[str, object] = {}

    def fake_validation(**kwargs):
        captured.update(kwargs)
        return None

    monkeypatch.setattr("outline_agent.tools.outline_tools.build_mermaid_validation_failure", fake_validation)

    result = asyncio.run(
        tool.run(
            {
                "title": "Diagram Doc",
                "text": "```mermaid\ngraph TD\n  A --> B[broken\n```",
            },
            context,
        )
    )

    assert result.ok is True
    assert captured["bypass_validation"] is True
    assert len(outline_client.created_documents) == 1


def test_unified_execution_loop_stops_when_tool_approval_is_denied(tmp_path: Path) -> None:
    class DenyShellPolicy:
        async def authorize(self, request, context):
            del context
            if request.tool_name == "run_shell":
                return ToolApprovalDecision(approved=False, reason="shell execution is awaiting approval")
            return ToolApprovalDecision(approved=True)

    registry = _build_registry()
    context = _build_context(tmp_path)
    context.tool_approval_policy = DenyShellPolicy()
    loop = UnifiedExecutionLoop(registry, max_steps=2)
    plan = UnifiedToolPlan(
        should_act=True,
        steps=[UnifiedToolPlanStep(tool="run_shell", args={"command": "echo should-not-run"})],
    )

    summary = asyncio.run(loop.execute(plan, context))

    assert summary.status == "failed"
    assert len(summary.steps) == 1
    assert summary.steps[0].tool == "run_shell"
    assert summary.steps[0].result.ok is False
    assert summary.steps[0].result.error == "approval-denied"
    assert summary.error == "approval-denied"


def test_progress_messages_surface_auto_approved_steps() -> None:
    start = describe_tool_start_for_progress("run `echo hi`", requires_confirmation=True)

    assert "auto-approved by current policy" in start

    progress = describe_tool_result_for_progress(
        ToolStepResult(
            tool="run_shell",
            ok=True,
            summary="run_shell[echo hi] -> exit 0 ; stdout=hi",
            target="echo hi",
            stdout="hi",
            requires_confirmation=True,
            approval_status="approved",
            approval_mode="always_allow",
        )
    )

    assert "auto-approved by current policy" in progress


def test_progress_message_surfaces_approval_denial() -> None:
    progress = describe_tool_result_for_progress(
        ToolStepResult(
            tool="run_shell",
            ok=False,
            summary="run_shell: approval denied: waiting for approval",
            target="echo hi",
            requires_confirmation=True,
            approval_status="denied",
            approval_reason="waiting for approval",
        )
    )

    assert progress == "Paused: `echo hi` is awaiting approval."


def test_validate_document_update_args_blocks_apply_after_blocked_draft() -> None:
    step_context = [
        {
            "tool": "draft_document_update",
            "ok": True,
            "data": {
                "decision": "blocked",
                "reason": "Reliable extracted paper text is still unavailable.",
            },
        }
    ]

    error = _validate_document_action_args("apply_document_update", {}, step_context, {})

    assert error == (
        "apply_document_update blocked by draft_document_update decision=blocked: "
        "Reliable extracted paper text is still unavailable."
    )


def test_hydrate_drafting_context_args_collects_local_workspace_observations() -> None:
    step_context = [
        {
            "tool": "download_attachment",
            "ok": True,
            "data": {"path": "attachments/paper.pdf", "size": 4096, "content_type": "application/pdf"},
        },
        {
            "tool": "extract_text_from_pdf",
            "ok": True,
            "data": {
                "path": "attachments/paper.pdf",
                "text": "Method overview\\nThe paper uses a two-stage routing design.",
            },
        },
    ]

    hydrated = _hydrate_drafting_context_args(
        "draft_document_update",
        {"user_comment": "请把这篇论文总结写进文档"},
        step_context,
        {"prior_round_observations": ["round 1 (status=applied): download + extract succeeded"]},
    )

    assert "local_workspace_context" in hydrated
    assert "Prior round observations:" in hydrated["local_workspace_context"]
    assert "extract_text_from_pdf[attachments/paper.pdf] extracted text:" in hydrated["local_workspace_context"]
    assert "two-stage routing design" in hydrated["local_workspace_context"]


def test_tool_registry_surfaces_model_client_error_without_unexpected_wrapper(tmp_path: Path) -> None:
    class ModelFailingTool:
        @property
        def spec(self):
            from outline_agent.tools.base import ToolSpec

            return ToolSpec(name="draft_document_update", description="x", side_effect_level="read")

        async def run(self, args, context):
            del args, context
            raise ModelClientError("Model request failed (openai-responses/ReadTimeout) during POST https://x.test")

    registry = ToolRegistry()
    registry.register(ModelFailingTool())
    context = _build_context(tmp_path)

    result = asyncio.run(registry.execute("draft_document_update", {}, context))

    assert result.ok is False
    assert result.summary == (
        "draft_document_update: Model request failed (openai-responses/ReadTimeout) during POST https://x.test"
    )
    assert "unexpected error" not in result.summary

def test_hydrate_document_update_args_uses_prior_round_draft_when_current_round_has_only_apply() -> None:
    hydrated = _hydrate_document_action_args(
        "apply_document_update",
        {},
        [],
        {
            "prior_draft_update_data": {
                "title": "Updated Title",
                "text": "Updated body",
            }
        },
    )

    assert hydrated == {"title": "Updated Title", "text": "Updated body", "content": "Updated body"}
