"""Native Anthropic provider — message/tool conversion, complete(), stream(). SDK-free:
the fake client mimics the `anthropic` SDK's `messages.create` surface with SimpleNamespace
objects, the same pattern test_providers.py uses for the OpenAI SDK."""

from __future__ import annotations

from types import SimpleNamespace

import pytest

from coworker.providers import AnthropicProvider, capabilities_for
from coworker.providers.anthropic_provider import (
    DEFAULT_MAX_TOKENS,
    convert_messages,
    convert_tools,
)

# -- fakes ------------------------------------------------------------------------


class _FakeClient:
    """Records the kwargs passed to messages.create and returns a canned response
    (or an event iterator when stream=True)."""

    def __init__(self, response=None, events=None):
        self.kwargs: dict = {}

        def create(**kwargs):
            self.kwargs = kwargs
            if kwargs.get("stream"):
                return iter(events or [])
            return response

        self.messages = SimpleNamespace(create=create)
        # Fable/Mythos route through the beta endpoint (server-side refusal fallback).
        self.beta = SimpleNamespace(messages=SimpleNamespace(create=create))


def _text_response(text="hello", stop_reason="end_turn"):
    return SimpleNamespace(
        content=[SimpleNamespace(type="text", text=text)],
        stop_reason=stop_reason,
    )


# -- message conversion -------------------------------------------------------------


def test_convert_extracts_leading_system():
    system, msgs = convert_messages(
        [
            {"role": "system", "content": "be helpful"},
            {"role": "user", "content": "hi"},
        ]
    )
    assert system == "be helpful"
    assert msgs == [{"role": "user", "content": [{"type": "text", "text": "hi"}]}]


def test_convert_assistant_tool_turn_skips_empty_text():
    # The engine persists pure tool turns with content="" — no empty text block may leak.
    _, msgs = convert_messages(
        [
            {"role": "user", "content": "do it"},
            {
                "role": "assistant",
                "content": "",
                "tool_calls": [
                    {
                        "id": "c1",
                        "type": "function",
                        "function": {"name": "f", "arguments": '{"x": 1}'},
                    }
                ],
            },
        ]
    )
    blocks = msgs[1]["content"]
    assert blocks == [{"type": "tool_use", "id": "c1", "name": "f", "input": {"x": 1}}]


def test_convert_merges_tool_result_run_into_one_user_message():
    # N parallel calls → N consecutive role:"tool" messages → ONE Anthropic user message.
    _, msgs = convert_messages(
        [
            {"role": "user", "content": "go"},
            {
                "role": "assistant",
                "content": "",
                "tool_calls": [
                    {
                        "id": "c1",
                        "type": "function",
                        "function": {"name": "a", "arguments": "{}"},
                    },
                    {
                        "id": "c2",
                        "type": "function",
                        "function": {"name": "b", "arguments": "{}"},
                    },
                ],
            },
            {"role": "tool", "tool_call_id": "c1", "content": "r1"},
            {"role": "tool", "tool_call_id": "c2", "content": "r2"},
        ]
    )
    assert [m["role"] for m in msgs] == ["user", "assistant", "user"]
    results = msgs[2]["content"]
    assert [b["type"] for b in results] == ["tool_result", "tool_result"]
    assert [b["tool_use_id"] for b in results] == ["c1", "c2"]
    assert [b["content"] for b in results] == ["r1", "r2"]


def test_convert_steering_user_message_merges_after_tool_results():
    # Steering appends a user message after tool results; it must land in the SAME user
    # message, after the tool_result blocks.
    _, msgs = convert_messages(
        [
            {"role": "user", "content": "go"},
            {
                "role": "assistant",
                "content": "",
                "tool_calls": [
                    {
                        "id": "c1",
                        "type": "function",
                        "function": {"name": "a", "arguments": "{}"},
                    }
                ],
            },
            {"role": "tool", "tool_call_id": "c1", "content": "r1"},
            {"role": "user", "content": "actually, stop"},
        ]
    )
    blocks = msgs[2]["content"]
    assert [b["type"] for b in blocks] == ["tool_result", "text"]
    assert blocks[1]["text"] == "actually, stop"


def test_convert_image_data_url_to_base64_source():
    _, msgs = convert_messages(
        [
            {
                "role": "user",
                "content": [
                    {"type": "text", "text": "what is this"},
                    {
                        "type": "image_url",
                        "image_url": {"url": "data:image/png;base64,iVBORw0KGgo="},
                    },
                ],
            }
        ]
    )
    img = msgs[0]["content"][1]
    assert img == {
        "type": "image",
        "source": {"type": "base64", "media_type": "image/png", "data": "iVBORw0KGgo="},
    }


def test_convert_image_http_url_and_malformed():
    _, msgs = convert_messages(
        [
            {
                "role": "user",
                "content": [
                    {"type": "image_url", "image_url": {"url": "https://x/y.png"}},
                    {"type": "image_url", "image_url": {"url": "not-a-url"}},
                ],
            }
        ]
    )
    blocks = msgs[0]["content"]
    assert blocks[0]["source"] == {"type": "url", "url": "https://x/y.png"}
    assert blocks[1] == {"type": "text", "text": "[unsupported image attachment]"}


def test_convert_drops_empty_assistant_and_guards_first_user():
    _, msgs = convert_messages(
        [
            {"role": "assistant", "content": ""},  # fully empty → dropped
            {"role": "assistant", "content": "hi"},
        ]
    )
    # the surviving assistant message can't be first → "(continued)" user is prepended
    assert msgs[0] == {
        "role": "user",
        "content": [{"type": "text", "text": "(continued)"}],
    }
    assert msgs[1]["role"] == "assistant"


def test_convert_empty_history_raises():
    with pytest.raises(ValueError):
        convert_messages([{"role": "assistant", "content": ""}])


def test_convert_malformed_tool_arguments_fall_back_to_raw():
    _, msgs = convert_messages(
        [
            {"role": "user", "content": "go"},
            {
                "role": "assistant",
                "content": "",
                "tool_calls": [
                    {
                        "id": "c1",
                        "type": "function",
                        "function": {"name": "a", "arguments": "{bad"},
                    }
                ],
            },
        ]
    )
    assert msgs[1]["content"][0]["input"] == {"_raw": "{bad"}


# -- tool schema conversion ----------------------------------------------------------


def test_convert_tools_handles_missing_description_and_parameters():
    tools = convert_tools(
        [
            {"type": "function", "function": {"name": "bare"}},
            {
                "type": "function",
                "function": {
                    "name": "full",
                    "description": "does things",
                    "parameters": {
                        "type": "object",
                        "properties": {"x": {"type": "integer"}},
                    },
                },
            },
        ]
    )
    assert tools[0] == {
        "name": "bare",
        "input_schema": {"type": "object", "properties": {}},
    }
    assert tools[1]["description"] == "does things"
    assert tools[1]["input_schema"]["properties"] == {"x": {"type": "integer"}}


# -- complete() ----------------------------------------------------------------------


def test_complete_text_turn_with_defaults():
    fake = _FakeClient(response=_text_response("hello"))
    provider = AnthropicProvider(client=fake)
    turn = provider.complete(
        model="claude-sonnet-4-6",
        messages=[
            {"role": "system", "content": "sys"},
            {"role": "user", "content": "hi"},
        ],
    )
    assert (
        turn.text == "hello"
        and turn.finish_reason == "stop"
        and not turn.has_tool_calls
    )
    assert fake.kwargs["model"] == "claude-sonnet-4-6"
    # System rides as a block list so the last block can carry the prompt-cache
    # breakpoint (see _add_cache_breakpoints).
    assert fake.kwargs["system"] == [
        {"type": "text", "text": "sys", "cache_control": {"type": "ephemeral"}}
    ]
    assert fake.kwargs["max_tokens"] == DEFAULT_MAX_TOKENS  # required param, injected
    assert "tools" not in fake.kwargs


def test_complete_parses_tool_use_blocks():
    response = SimpleNamespace(
        content=[
            SimpleNamespace(type="text", text="on it"),
            SimpleNamespace(
                type="tool_use", id="c1", name="write_file", input={"path": "a.txt"}
            ),
        ],
        stop_reason="tool_use",
    )
    provider = AnthropicProvider(client=_FakeClient(response=response))
    turn = provider.complete(model="m", messages=[{"role": "user", "content": "go"}])
    assert turn.text == "on it"
    assert turn.finish_reason == "tool_calls"
    assert turn.tool_calls[0].id == "c1"
    assert turn.tool_calls[0].name == "write_file"
    assert turn.tool_calls[0].arguments == {"path": "a.txt"}


@pytest.mark.parametrize(
    "stop_reason,expected",
    [
        ("end_turn", "stop"),
        ("tool_use", "tool_calls"),
        ("max_tokens", "length"),
        ("stop_sequence", "stop"),
        # "refusal" no longer maps — it raises (see test_whole_chain_refusal_raises…)
        ("something_new", "something_new"),  # unknown passes through
    ],
)
def test_complete_maps_stop_reasons(stop_reason, expected):
    provider = AnthropicProvider(
        client=_FakeClient(response=_text_response(stop_reason=stop_reason))
    )
    turn = provider.complete(model="m", messages=[{"role": "user", "content": "x"}])
    assert turn.finish_reason == expected


def test_complete_filters_and_aliases_settings():
    fake = _FakeClient(response=_text_response())
    provider = AnthropicProvider(client=fake)
    provider.complete(
        model="m",
        messages=[{"role": "user", "content": "x"}],
        temperature=0.2,
        max_tokens=512,  # explicit beats the default
        stop="END",  # OpenAI alias → stop_sequences
        frequency_penalty=0.5,  # not a Messages API param → dropped
    )
    assert fake.kwargs["temperature"] == 0.2
    assert fake.kwargs["max_tokens"] == 512
    assert fake.kwargs["stop_sequences"] == ["END"]
    assert "frequency_penalty" not in fake.kwargs and "stop" not in fake.kwargs


def test_complete_converts_tools():
    fake = _FakeClient(response=_text_response())
    provider = AnthropicProvider(client=fake)
    provider.complete(
        model="m",
        messages=[{"role": "user", "content": "x"}],
        tools=[
            {
                "type": "function",
                "function": {
                    "name": "f",
                    "description": "d",
                    "parameters": {"type": "object"},
                },
            }
        ],
    )
    assert fake.kwargs["tools"] == [
        {"name": "f", "description": "d", "input_schema": {"type": "object"}}
    ]


def test_ensure_client_without_key_raises(monkeypatch):
    monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
    with pytest.raises(RuntimeError, match="Anthropic"):
        AnthropicProvider()._ensure_client()


# -- stream() ------------------------------------------------------------------------


def _delta(index, **delta_attrs):
    return SimpleNamespace(
        type="content_block_delta", index=index, delta=SimpleNamespace(**delta_attrs)
    )


def test_stream_yields_text_deltas_then_final_turn():
    events = [
        SimpleNamespace(type="message_start"),
        SimpleNamespace(
            type="content_block_start",
            index=0,
            content_block=SimpleNamespace(type="text"),
        ),
        _delta(0, type="text_delta", text="hel"),
        _delta(0, type="text_delta", text="lo"),
        SimpleNamespace(type="content_block_stop", index=0),
        SimpleNamespace(
            type="message_delta", delta=SimpleNamespace(stop_reason="end_turn")
        ),
        SimpleNamespace(type="message_stop"),
    ]
    provider = AnthropicProvider(client=_FakeClient(events=events))
    chunks = list(
        provider.stream(model="m", messages=[{"role": "user", "content": "x"}])
    )
    assert [c.text_delta for c in chunks[:-1]] == ["hel", "lo"]
    final = chunks[-1].turn
    assert (
        final.text == "hello"
        and final.finish_reason == "stop"
        and not final.has_tool_calls
    )


def test_stream_accumulates_split_tool_json():
    events = [
        SimpleNamespace(
            type="content_block_start",
            index=0,
            content_block=SimpleNamespace(type="tool_use", id="c1", name="write_file"),
        ),
        _delta(0, type="input_json_delta", partial_json='{"path": "a'),
        _delta(0, type="input_json_delta", partial_json='.txt", "content": "hi"}'),
        SimpleNamespace(type="content_block_stop", index=0),
        SimpleNamespace(
            type="message_delta", delta=SimpleNamespace(stop_reason="tool_use")
        ),
    ]
    provider = AnthropicProvider(client=_FakeClient(events=events))
    chunks = list(
        provider.stream(model="m", messages=[{"role": "user", "content": "x"}])
    )
    final = chunks[-1].turn
    assert final.finish_reason == "tool_calls"
    assert final.tool_calls[0].id == "c1"
    assert final.tool_calls[0].arguments == {"path": "a.txt", "content": "hi"}


def test_stream_mixed_text_and_tool_blocks():
    events = [
        SimpleNamespace(
            type="content_block_start",
            index=0,
            content_block=SimpleNamespace(type="text"),
        ),
        _delta(0, type="text_delta", text="working"),
        SimpleNamespace(
            type="content_block_start",
            index=1,
            content_block=SimpleNamespace(type="tool_use", id="c1", name="f"),
        ),
        _delta(1, type="input_json_delta", partial_json=""),  # no-args tool call
        SimpleNamespace(
            type="message_delta", delta=SimpleNamespace(stop_reason="tool_use")
        ),
    ]
    provider = AnthropicProvider(client=_FakeClient(events=events))
    chunks = list(
        provider.stream(model="m", messages=[{"role": "user", "content": "x"}])
    )
    assert chunks[0].text_delta == "working"
    final = chunks[-1].turn
    assert final.text == "working"
    assert final.tool_calls[0].arguments == {}  # empty json → {}


def test_stream_passes_stream_flag():
    fake = _FakeClient(events=[])
    provider = AnthropicProvider(client=fake)
    list(provider.stream(model="m", messages=[{"role": "user", "content": "x"}]))
    assert fake.kwargs["stream"] is True
    assert fake.kwargs["max_tokens"] == DEFAULT_MAX_TOKENS


# -- registry / capabilities ----------------------------------------------------------


def test_registry_builds_native_anthropic_provider():
    from coworker.providers.registry import build_provider_client

    provider = build_provider_client("anthropic", {"api_key": "sk-ant-x"}, None)
    assert isinstance(provider, AnthropicProvider)
    assert provider._api_key == "sk-ant-x"
    # no key in the profile is fine at build time — resolution is deferred to first call
    assert isinstance(build_provider_client("anthropic", {}, None), AnthropicProvider)


def test_resolve_api_key_env_then_secrets(monkeypatch):
    from coworker.providers.anthropic_provider import resolve_api_key

    monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-env")
    assert resolve_api_key() == "sk-ant-env"
    monkeypatch.delenv("ANTHROPIC_API_KEY")

    class _Secrets:
        def get(self, name):
            return (
                {"api_key": "sk-ant-stored"} if name == "provider:anthropic" else None
            )

    assert resolve_api_key(_Secrets()) == "sk-ant-stored"
    assert resolve_api_key(None) is None


def test_anthropic_capabilities_parallel_tool_calls():
    caps = capabilities_for("anthropic:claude-sonnet-4-6")
    assert caps.tools and caps.vision and caps.streaming
    assert caps.parallel_tool_calls is True  # native provider folds results correctly


def test_convert_pdf_file_part_to_document_block():
    _, msgs = convert_messages(
        [
            {
                "role": "user",
                "content": [
                    {"type": "text", "text": "summarize"},
                    {
                        "type": "file",
                        "file": {
                            "filename": "report.pdf",
                            "file_data": "data:application/pdf;base64,JVBERi0=",
                        },
                    },
                ],
            }
        ]
    )
    doc = msgs[0]["content"][1]
    assert doc == {
        "type": "document",
        "source": {
            "type": "base64",
            "media_type": "application/pdf",
            "data": "JVBERi0=",
        },
        "title": "report.pdf",
    }


def test_convert_malformed_file_part_becomes_text_note():
    _, msgs = convert_messages(
        [
            {
                "role": "user",
                "content": [{"type": "file", "file": {"file_data": "not-a-url"}}],
            }
        ]
    )
    assert msgs[0]["content"][0] == {
        "type": "text",
        "text": "[unsupported file attachment]",
    }


# -- extended thinking (model-layer roadmap item 4, phase 3) ------------------------


def test_thinking_config_is_model_family_aware():
    """API drift (owner-hit 2026-07-23): budget_tokens 400s on the Claude 5/4.7+ family
    ('use thinking.type.adaptive'); older models still need enabled+budget. display
    must be summarized on the new family or the trace text arrives empty."""
    for model in ("claude-fable-5", "claude-opus-4-8", "claude-sonnet-4-6"):
        client = _FakeClient(response=_text_response())
        AnthropicProvider(client=client, thinking_budget=8192).complete(
            model=model,
            messages=[{"role": "user", "content": "x"}],
            temperature=0.2,
            top_p=0.9,
        )
        assert client.kwargs["thinking"] == {"type": "adaptive", "display": "summarized"}, model
        assert "budget_tokens" not in str(client.kwargs["thinking"]), model
        assert "temperature" not in client.kwargs and "top_p" not in client.kwargs, model

    client = _FakeClient(response=_text_response())
    AnthropicProvider(client=client, thinking_budget=8192).complete(
        model="claude-haiku-4-5",
        messages=[{"role": "user", "content": "x"}],
        temperature=0.2,
    )
    assert client.kwargs["thinking"] == {"type": "enabled", "budget_tokens": 8192}
    assert client.kwargs["max_tokens"] > 8192
    assert "temperature" not in client.kwargs

    # Off (hidden override 0): no thinking param on any family.
    client2 = _FakeClient(response=_text_response())
    AnthropicProvider(client=client2).complete(
        model="claude-fable-5", messages=[{"role": "user", "content": "x"}]
    )
    assert "thinking" not in client2.kwargs


def test_complete_parses_thinking_blocks_into_reasoning_and_sidecar():
    response = SimpleNamespace(
        content=[
            SimpleNamespace(type="thinking", thinking="pondering...", signature="SIG1"),
            SimpleNamespace(type="redacted_thinking", data="OPAQUE"),
            SimpleNamespace(type="text", text="the answer"),
        ],
        stop_reason="end_turn",
    )
    provider = AnthropicProvider(client=_FakeClient(response=response), thinking_budget=4096)
    turn = provider.complete(model="claude-fable-5", messages=[{"role": "user", "content": "x"}])
    assert turn.text == "the answer"
    assert turn.reasoning == "pondering..."  # redacted stays out of the display text
    assert turn.extras["_anthropic"]["blocks"] == [
        {"type": "thinking", "thinking": "pondering...", "signature": "SIG1"},
        {"type": "redacted_thinking", "data": "OPAQUE"},
    ]


def test_stream_accumulates_thinking_and_signature():
    events = [
        SimpleNamespace(
            type="content_block_start",
            index=0,
            content_block=SimpleNamespace(type="thinking", thinking="", signature=""),
        ),
        _delta(0, type="thinking_delta", thinking="step one. "),
        _delta(0, type="thinking_delta", thinking="step two."),
        _delta(0, type="signature_delta", signature="SIGSTREAM"),
        SimpleNamespace(type="content_block_stop", index=0),
        SimpleNamespace(
            type="content_block_start",
            index=1,
            content_block=SimpleNamespace(type="text"),
        ),
        _delta(1, type="text_delta", text="done"),
        SimpleNamespace(
            type="message_delta", delta=SimpleNamespace(stop_reason="end_turn")
        ),
    ]
    provider = AnthropicProvider(client=_FakeClient(events=events), thinking_budget=4096)
    chunks = list(provider.stream(model="m", messages=[{"role": "user", "content": "x"}]))
    assert [c.reasoning_delta for c in chunks if c.reasoning_delta] == ["step one. ", "step two."]
    final = chunks[-1].turn
    assert final.text == "done" and final.reasoning == "step one. step two."
    assert final.extras["_anthropic"]["blocks"] == [
        {"type": "thinking", "thinking": "step one. step two.", "signature": "SIGSTREAM"}
    ]


def test_convert_replays_thinking_blocks_ahead_of_tool_use():
    _, msgs = convert_messages(
        [
            {"role": "user", "content": "go"},
            {
                "role": "assistant",
                "content": "",
                "_anthropic": {
                    "blocks": [
                        {"type": "thinking", "thinking": "plan", "signature": "S"},
                    ]
                },
                "tool_calls": [
                    {"id": "t1", "type": "function", "function": {"name": "a", "arguments": "{}"}},
                ],
            },
            {"role": "tool", "tool_call_id": "t1", "content": "ok"},
        ]
    )
    assistant = msgs[1]["content"]
    assert assistant[0] == {"type": "thinking", "thinking": "plan", "signature": "S"}
    assert assistant[1]["type"] == "tool_use"


def test_thinking_defaults_on_with_hidden_profile_override():
    """No user-facing setting (owner call 2026-07-23): thinking is ON by default; the
    profile's thinking_budget stays a hidden override, 0 = off."""
    from coworker.providers.anthropic_provider import DEFAULT_THINKING_BUDGET
    from coworker.providers.registry import build_provider_client

    assert build_provider_client("anthropic", {}, None).thinking_budget == DEFAULT_THINKING_BUDGET
    assert build_provider_client("anthropic", {"thinking_budget": "2048"}, None).thinking_budget == 2048
    assert build_provider_client("anthropic", {"thinking_budget": "0"}, None).thinking_budget == 0


def test_fable_requests_carry_server_side_fallback():
    """Fable/Mythos classifiers can decline benign-adjacent requests — every call opts
    into the server-side fallback so Opus 4.8 re-serves declines in the same call."""
    client = _FakeClient(response=_text_response())
    AnthropicProvider(client=client, thinking_budget=8192).complete(
        model="claude-fable-5", messages=[{"role": "user", "content": "x"}]
    )
    assert client.kwargs["betas"] == ["server-side-fallback-2026-06-01"]
    assert client.kwargs["fallbacks"] == [{"model": "claude-opus-4-8"}]

    # Other models stay on the plain endpoint with no fallback params.
    client2 = _FakeClient(response=_text_response())
    AnthropicProvider(client=client2).complete(
        model="claude-opus-4-8", messages=[{"role": "user", "content": "x"}]
    )
    assert "betas" not in client2.kwargs and "fallbacks" not in client2.kwargs


def test_whole_chain_refusal_raises_friendly_error():
    """A refusal that survives the fallback chain must surface as an error (notice +
    Retry in the GUI) — not a silent blank reply (owner-hit 2026-07-23)."""
    refused = SimpleNamespace(
        content=[],
        stop_reason="refusal",
        stop_details=SimpleNamespace(category="cyber", explanation="blocked"),
    )
    provider = AnthropicProvider(client=_FakeClient(response=refused), thinking_budget=8192)
    with pytest.raises(RuntimeError) as err:
        provider.complete(model="claude-fable-5", messages=[{"role": "user", "content": "x"}])
    assert "safety filter" in str(err.value) and "cyber" in str(err.value)
