"""The custom-tools admin screen."""

from __future__ import annotations

import json

import httpx
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select

from lembas.db.models import CustomTool, Group, User
from lembas.security.passwords import hash_password
from lembas.services.crypto import UNCHANGED_SENTINEL, decrypt, encrypt


@pytest.fixture
def plain_user(client: TestClient, db, registered):
    """A second account, which is never an administrator."""
    client.post("/auth/logout")
    client.post(
        "/auth/register",
        data={"name": "Sam", "email": "sam@shire.test", "password": "correct horse battery"},
        follow_redirects=False,
    )
    user = db.scalar(select(User).where(User.email == "sam@shire.test"))
    user.role = "user"
    user.active = True
    db.commit()
    client.post(
        "/auth/login",
        data={"email": "sam@shire.test", "password": "correct horse battery"},
        follow_redirects=False,
    )
    return user


def _form(**overrides) -> dict:
    base = {
        "name": "Weather",
        "slug": "weather",
        "description": "Look up the weather for a city.",
        "parameters": json.dumps(
            {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}
        ),
        "method": "GET",
        "url_template": "https://api.test/v1/{{city}}",
        "headers": "Accept: application/json",
        "secret_placement": "bearer",
        "secret_name": "Authorization",
        "response_mode": "json",
        "max_chars": "8000",
        "timeout": "20",
        "position": "0",
        "enabled": "true",
        "public": "true",
    }
    base.update(overrides)
    return {key: value for key, value in base.items() if value is not None}


def _create(client: TestClient, **overrides):
    return client.post("/admin/tools", data=_form(**overrides), follow_redirects=False)


# --- Guards ------------------------------------------------------------------
def test_the_pages_are_refused_to_a_plain_user(client: TestClient, plain_user):
    assert client.get("/admin/tools").status_code == 403
    assert client.get("/admin/tools/new").status_code == 403
    assert client.post("/admin/tools", data=_form()).status_code == 403


def test_a_tool_that_does_not_exist_is_a_404(client: TestClient, registered):
    assert client.get("/admin/tools/nope/edit").status_code == 404


def test_new_is_not_parsed_as_a_tool_id(client: TestClient, registered):
    """FastAPI matches in registration order; /admin/models has been bitten by
    exactly this."""
    response = client.get("/admin/tools/new")
    assert response.status_code == 200
    assert "New tool" in response.text


# --- Creating and editing ----------------------------------------------------
def test_creating_a_tool_then_editing_it(client: TestClient, db, registered):
    _create(client, secret="s3cret")

    tool = db.scalar(select(CustomTool))
    assert tool.slug == "weather"
    assert tool.headers_json == {"Accept": "application/json"}
    assert tool.parameters_json["properties"]["city"] == {"type": "string"}
    assert decrypt(tool.secret_encrypted) == "s3cret"
    assert tool.enabled is True

    client.post(
        f"/admin/tools/{tool.id}",
        data=_form(name="Forecast", enabled=None, secret=UNCHANGED_SENTINEL),
        follow_redirects=False,
    )
    db.refresh(tool)
    assert tool.name == "Forecast"
    # An unticked checkbox is simply absent from the post, which is the signal.
    assert tool.enabled is False
    assert decrypt(tool.secret_encrypted) == "s3cret", "the dots must keep the secret"


def test_clearing_the_field_removes_the_secret(client: TestClient, db, registered):
    _create(client, secret="s3cret")
    tool = db.scalar(select(CustomTool))

    client.post(f"/admin/tools/{tool.id}", data=_form(secret=""), follow_redirects=False)
    db.refresh(tool)
    assert tool.secret_encrypted == ""


def test_a_secret_is_never_rendered_in_full(client: TestClient, db, registered):
    _create(client, secret="super-secret-value")
    tool = db.scalar(select(CustomTool))

    page = client.get(f"/admin/tools/{tool.id}/edit").text
    assert "super-secret-value" not in page
    assert UNCHANGED_SENTINEL in page


# --- Validation reports back into the form -----------------------------------
def test_bad_parameter_json_is_reported_not_a_422(client: TestClient, db, registered):
    response = _create(client, parameters="{not json")
    assert response.status_code == 200
    assert "not valid JSON" in response.text
    assert db.scalar(select(CustomTool)) is None


def test_parameters_that_are_not_an_object_are_refused(client: TestClient, db, registered):
    response = _create(client, parameters='{"type": "string"}')
    assert "must be a JSON object" in response.text
    assert db.scalar(select(CustomTool)) is None


def test_a_slug_colliding_with_a_builtin_is_refused(client: TestClient, db, registered):
    response = _create(client, slug="web_search")
    assert "built-in tool" in response.text
    assert db.scalar(select(CustomTool)) is None


def test_a_duplicate_slug_is_refused(client: TestClient, db, registered):
    _create(client)
    response = _create(client, name="Other")
    assert "already a tool" in response.text
    assert len(list(db.scalars(select(CustomTool)))) == 1


def test_a_url_whose_host_is_a_variable_is_refused(client: TestClient, db, registered):
    response = _create(client, url_template="https://{{city}}.test/x")
    assert "literal" in response.text
    assert db.scalar(select(CustomTool)) is None


def test_a_rejected_save_keeps_what_was_typed(client: TestClient, db, registered):
    _create(client)
    tool = db.scalar(select(CustomTool))

    response = client.post(
        f"/admin/tools/{tool.id}",
        data=_form(name="Renamed", parameters="{oops"),
        follow_redirects=False,
    )
    assert "Renamed" in response.text, "the form still holds the submitted name"
    db.refresh(tool)
    assert tool.name == "Weather", "and the stored row is untouched"


# --- Access ------------------------------------------------------------------
def test_making_a_tool_public_clears_its_groups(client: TestClient, db, registered):
    group = Group(name="Council")
    db.add(group)
    db.commit()

    _create(client, public=None, group_ids=group.id)
    tool = db.scalar(select(CustomTool))
    assert [g.name for g in tool.groups] == ["Council"]

    client.post(
        f"/admin/tools/{tool.id}",
        data={**_form(public="true"), "group_ids": group.id},
        follow_redirects=False,
    )
    db.refresh(tool)
    assert tool.groups == []


def test_a_restricted_tool_is_hidden_from_a_user_outside_its_groups(db, registered):
    from lembas.services import tool_access

    group = Group(name="Council")
    outsider = User(name="Sam", email="s@shire.test", password_hash=hash_password("x"))
    db.add_all([group, outsider])
    db.add(
        CustomTool(
            slug="weather", name="Weather", url_template="https://api.test/", public=False
        )
    )
    db.commit()

    tool = db.scalar(select(CustomTool))
    tool.groups = [group]
    db.commit()

    assert tool_access.visible_custom_tools(db, outsider) == []
    outsider.groups = [group]
    db.commit()
    assert len(tool_access.visible_custom_tools(db, outsider)) == 1


# --- Running one by hand -----------------------------------------------------
def test_the_test_button_shows_what_the_model_would_read(
    client: TestClient, db, registered, mock_http, monkeypatch
):
    monkeypatch.setattr(
        "socket.getaddrinfo", lambda *a, **k: [(2, 1, 6, "", ("93.184.216.34", 80))]
    )
    mock_http(lambda _r: httpx.Response(200, json={"summary": "Sunny"}))
    _create(client, response_mode="json", response_path="summary")
    tool = db.scalar(select(CustomTool))

    response = client.post(
        f"/admin/tools/{tool.id}/test", data={"arguments": '{"city": "Minas Tirith"}'}
    )
    assert response.status_code == 200
    assert "Sunny" in response.text


def test_a_failing_test_is_recorded_on_the_row(
    client: TestClient, db, registered, mock_http, monkeypatch
):
    monkeypatch.setattr(
        "socket.getaddrinfo", lambda *a, **k: [(2, 1, 6, "", ("93.184.216.34", 80))]
    )
    mock_http(lambda _r: httpx.Response(503, text="down"))
    _create(client)
    tool = db.scalar(select(CustomTool))

    client.post(f"/admin/tools/{tool.id}/test", data={"arguments": "{}"})
    db.refresh(tool)
    assert "503" in tool.last_error
    assert tool.last_checked_at is not None


def test_bad_test_arguments_are_reported(client: TestClient, db, registered):
    _create(client)
    tool = db.scalar(select(CustomTool))
    response = client.post(f"/admin/tools/{tool.id}/test", data={"arguments": "[1, 2]"})
    assert "JSON object" in response.text


# --- The list ----------------------------------------------------------------
def test_the_list_searches_and_filters(client: TestClient, db, registered):
    _create(client)
    _create(client, name="Tickets", slug="tickets", url_template="https://jira.test/{{city}}")
    tool = db.scalar(select(CustomTool).where(CustomTool.slug == "tickets"))
    tool.enabled = False
    db.commit()

    page = client.get("/admin/tools").text
    assert "Weather" in page and "Tickets" in page

    assert "Tickets" not in client.get("/admin/tools?filter=enabled").text
    assert "Weather" not in client.get("/admin/tools?q=tick").text


def test_deleting_a_tool_leaves_its_prompt_override_alone(client: TestClient, db, registered):
    """The override outlives the row, which is what lets a tool be recreated
    under the same slug without losing the wording somebody chose."""
    from lembas.services import prompts as prompts_service

    _create(client, guidance="- Default wording.")
    tool = db.scalar(select(CustomTool))
    prompts_service.save(db, {"tool.custom_weather": "- Edited wording."})

    client.post(f"/admin/tools/{tool.id}/delete", follow_redirects=False)
    assert db.scalar(select(CustomTool)) is None
    assert prompts_service.stored(db)["tool.custom_weather"] == "- Edited wording."


def test_the_secret_survives_a_round_trip_through_the_form(client: TestClient, db, registered):
    """A regression guard on the sentinel: the field is rendered with dots, and
    submitting the page unchanged must not overwrite the key with them."""
    db.add(
        CustomTool(
            slug="weather",
            name="Weather",
            url_template="https://api.test/",
            secret_encrypted=encrypt("s3cret"),
        )
    )
    db.commit()
    tool = db.scalar(select(CustomTool))

    client.post(
        f"/admin/tools/{tool.id}", data=_form(secret=UNCHANGED_SENTINEL), follow_redirects=False
    )
    db.refresh(tool)
    assert decrypt(tool.secret_encrypted) == "s3cret"


# --- MCP servers -------------------------------------------------------------
def _mcp_form(**overrides) -> dict:
    base = {
        "name": "GitHub",
        "slug": "github",
        "url": "https://mcp.test/rpc",
        "headers": "",
        "secret_placement": "none",
        "secret_name": "Authorization",
        "timeout": "30",
        "max_chars": "8000",
        "position": "0",
        "enabled": "true",
        "public": "true",
    }
    base.update(overrides)
    return {key: value for key, value in base.items() if value is not None}


@pytest.fixture
def fake_mcp(mock_http, monkeypatch):
    """A server that answers initialize and tools/list.

    Invented hostnames resolve to a public address; a literal IP is handed back
    as itself, so a test about a private address is still testing one.
    """
    import ipaddress

    def resolve(host, *_args, **_kwargs):
        try:
            ipaddress.ip_address(host)
        except ValueError:
            return [(2, 1, 6, "", ("93.184.216.34", 80))]
        return [(2, 1, 6, "", (host, 80))]

    monkeypatch.setattr("socket.getaddrinfo", resolve)

    def handler(request: httpx.Request) -> httpx.Response:
        if request.method != "POST":
            return httpx.Response(405)
        message = json.loads(request.content)
        if message.get("method") == "notifications/initialized":
            return httpx.Response(202)
        if message.get("method") == "initialize":
            return httpx.Response(
                200,
                json={
                    "jsonrpc": "2.0",
                    "id": message["id"],
                    "result": {"protocolVersion": "2025-06-18", "serverInfo": {"name": "fake"}},
                },
                headers={"mcp-session-id": "s-1"},
            )
        return httpx.Response(
            200,
            json={
                "jsonrpc": "2.0",
                "id": message["id"],
                "result": {"tools": [{"name": "search", "description": "Search."}]},
            },
        )

    mock_http(handler)
    return handler


def test_the_mcp_pages_are_refused_to_a_plain_user(client: TestClient, plain_user):
    assert client.get("/admin/mcp").status_code == 403
    assert client.get("/admin/mcp/new").status_code == 403
    assert client.post("/admin/mcp", data=_mcp_form()).status_code == 403


def test_mcp_new_is_not_parsed_as_a_server_id(client: TestClient, registered):
    response = client.get("/admin/mcp/new")
    assert response.status_code == 200
    assert "New MCP server" in response.text


def test_adding_a_server_discovers_its_tools(client: TestClient, db, registered, fake_mcp):
    from lembas.db.models import McpServer

    client.post("/admin/mcp", data=_mcp_form(), follow_redirects=False)

    server = db.scalar(select(McpServer))
    assert server.slug == "github"
    assert [entry["name"] for entry in server.tools_json] == ["search"]
    assert server.tools_json[0]["offer_name"] == "github_search"
    assert server.protocol_version == "2025-06-18"


def test_refreshing_a_server_swaps_its_row(client: TestClient, db, registered, fake_mcp):
    from lembas.db.models import McpServer

    client.post("/admin/mcp", data=_mcp_form(), follow_redirects=False)
    server = db.scalar(select(McpServer))

    response = client.post(f"/admin/mcp/{server.id}/test")
    assert response.status_code == 200
    assert "found 1 tool." in response.text
    assert f'id="mcp-{server.id}"' in response.text, "the fragment must render standalone"


def test_a_server_that_cannot_be_reached_says_so(
    client: TestClient, db, registered, mock_http, monkeypatch
):
    from lembas.db.models import McpServer

    monkeypatch.setattr(
        "socket.getaddrinfo", lambda *a, **k: [(2, 1, 6, "", ("93.184.216.34", 80))]
    )
    mock_http(lambda _r: httpx.Response(500, text="down"))

    response = client.post("/admin/mcp", data=_mcp_form(), follow_redirects=False)
    assert response.status_code == 303
    server = db.scalar(select(McpServer))
    assert server is not None, "the row is still saved so the URL can be corrected"
    assert server.last_error


def test_a_private_url_is_not_contacted_unless_the_box_is_ticked(
    client: TestClient, db, registered, fake_mcp
):
    """The row still saves, so the URL can be corrected -- but the discovery
    that runs straight after it is refused, and the row says why."""
    from lembas.db.models import McpServer

    client.post(
        "/admin/mcp", data=_mcp_form(url="http://127.0.0.1:9000/rpc"), follow_redirects=False
    )
    server = db.scalar(select(McpServer))
    assert server.allow_private is False
    assert "private or local" in server.last_error
    assert server.tools_json == []

    client.post(
        f"/admin/mcp/{server.id}",
        data=_mcp_form(url="http://127.0.0.1:9000/rpc", allow_private="true"),
        follow_redirects=False,
    )
    client.post(f"/admin/mcp/{server.id}/test")
    db.refresh(server)
    assert server.last_error == ""
    assert [entry["name"] for entry in server.tools_json] == ["search"]


def test_a_duplicate_mcp_slug_is_refused(client: TestClient, db, registered, fake_mcp):
    from lembas.db.models import McpServer

    client.post("/admin/mcp", data=_mcp_form(), follow_redirects=False)
    response = client.post("/admin/mcp", data=_mcp_form(name="Other"), follow_redirects=False)
    assert "already a server" in response.text
    assert len(list(db.scalars(select(McpServer)))) == 1


def test_a_url_that_is_not_http_is_refused(client: TestClient, db, registered):
    from lembas.db.models import McpServer

    response = client.post(
        "/admin/mcp", data=_mcp_form(url="ftp://mcp.test/rpc"), follow_redirects=False
    )
    assert "http" in response.text
    assert db.scalar(select(McpServer)) is None


def test_unticking_a_tool_withholds_it(client: TestClient, db, registered, fake_mcp):
    from lembas.db.models import McpServer, User
    from lembas.services.mcp import registry as mcp_registry

    client.post("/admin/mcp", data=_mcp_form(), follow_redirects=False)
    server = db.scalar(select(McpServer))

    client.post(
        f"/admin/mcp/{server.id}",
        data={**_mcp_form(), "tool_choices": "1", "tool_names": "search"},
        follow_redirects=False,
    )
    db.refresh(server)
    assert server.tool_overrides_json == {"search": False}

    owner = db.scalar(select(User))
    assert mcp_registry.tool_defs(db, owner) == []


def test_deleting_a_server_removes_it(client: TestClient, db, registered, fake_mcp):
    from lembas.db.models import McpServer

    client.post("/admin/mcp", data=_mcp_form(), follow_redirects=False)
    server = db.scalar(select(McpServer))

    client.post(f"/admin/mcp/{server.id}/delete", follow_redirects=False)
    assert db.scalar(select(McpServer)) is None
