defmodule ExMCP.Compliance.StreamableHTTPComplianceTest do
  @moduledoc """
  Compliance tests for the Streamable HTTP transport per the MCP specification.

  These tests verify that the ExMCP HTTP transport implementation conforms to
  the MCP spec's requirements for the Streamable HTTP transport. Each test
  corresponds to a specific spec requirement discovered during cross-language
  interop testing with the official MCP "everything" server.

  Spec references:
  - Session management: client must not pre-generate session IDs
  - Accept header: must include both application/json and text/event-stream
  - SSE endpoint: GET for SSE uses the same endpoint as POST
  - Response format: POST responses may use text/event-stream content-type
  - SSE mode responses: POST response body contains the result even when SSE is active
  """

  use ExUnit.Case, async: true

  alias ExMCP.Transport.HTTP

  # ---------- 1. Session ID management ----------

  describe "session ID management (MCP spec: server provides session ID)" do
    test "first request omits Mcp-Session-Id header when no session ID is set" do
      # Per MCP spec, the first request should NOT include a session ID.
      # The server provides one in its response headers.
      {:ok, state} =
        HTTP.connect(
          url: "http://127.0.0.1:9999",
          use_sse: false,
          session_id: nil
        )

      assert state.session_id == nil

      # Verify no session ID header is built
      headers = build_request_headers_for(state)
      session_header = find_header_value(headers, "Mcp-Session-Id")
      assert session_header == nil, "First request must not include Mcp-Session-Id header"
    end

    test "session ID from server response is stored and sent in subsequent requests" do
      {:ok, state} =
        HTTP.connect(
          url: "http://127.0.0.1:9999",
          use_sse: false,
          session_id: nil
        )

      # Simulate server providing session ID in response headers
      server_session_id = "server-generated-#{System.unique_integer([:positive])}"

      response_headers = [
        {~c"content-type", ~c"application/json"},
        {~c"Mcp-Session-Id", String.to_charlist(server_session_id)}
      ]

      # Update state as handle_http_response would
      updated_state = simulate_session_id_update(response_headers, state)

      assert updated_state.session_id == server_session_id

      # Now verify subsequent request headers include the session ID
      headers = build_request_headers_for(updated_state)
      session_header = find_header_value(headers, "Mcp-Session-Id")
      assert session_header == server_session_id
    end

    test "client-provided session ID is sent in headers (for session resumption)" do
      existing_session = "existing-session-42"

      {:ok, state} =
        HTTP.connect(
          url: "http://127.0.0.1:9999",
          use_sse: false,
          session_id: existing_session
        )

      assert state.session_id == existing_session

      headers = build_request_headers_for(state)
      session_header = find_header_value(headers, "Mcp-Session-Id")
      assert session_header == existing_session
    end
  end

  # ---------- 2. Accept header ----------

  describe "Accept header (MCP spec: must accept both JSON and SSE)" do
    test "POST requests include Accept header with both application/json and text/event-stream" do
      {:ok, state} =
        HTTP.connect(
          url: "http://127.0.0.1:9999",
          use_sse: false
        )

      headers = build_request_headers_for(state)
      accept_header = find_header_value(headers, "accept")

      assert accept_header != nil, "Accept header must be present"
      assert accept_header =~ "application/json", "Accept must include application/json"

      assert accept_header =~ "text/event-stream",
             "Accept must include text/event-stream"
    end

    test "Accept header is present even when SSE is disabled" do
      {:ok, state} =
        HTTP.connect(
          url: "http://127.0.0.1:9999",
          use_sse: false
        )

      headers = build_request_headers_for(state)
      accept_header = find_header_value(headers, "accept")

      # Even in non-SSE mode, the Accept header must include both types
      # because the server may choose to respond with either format
      assert accept_header =~ "application/json"
      assert accept_header =~ "text/event-stream"
    end

    test "Accept header is present when SSE is enabled" do
      {:ok, state} =
        HTTP.connect(
          url: "http://127.0.0.1:9999",
          use_sse: true
        )

      headers = build_request_headers_for(state)
      accept_header = find_header_value(headers, "accept")

      assert accept_header =~ "application/json"
      assert accept_header =~ "text/event-stream"
    end
  end

  # ---------- 3. SSE endpoint path ----------

  describe "SSE endpoint path (MCP spec: same as POST endpoint)" do
    test "SSE GET uses the same endpoint as POST requests" do
      {:ok, state} =
        HTTP.connect(
          url: "http://127.0.0.1:9999",
          endpoint: "/mcp",
          use_sse: true
        )

      # Build the POST URL
      post_url = invoke_build_url(state, "")

      # The SSE URL should be the same endpoint, not /mcp/sse or /sse
      assert post_url == "http://127.0.0.1:9999/mcp"

      # The SSE connection (GET) should go to the same URL
      # This is verified by checking that start_sse uses build_url(state, "")
      # which produces the same path as POST requests
    end

    test "SSE GET does NOT append /sse to the endpoint" do
      {:ok, state} =
        HTTP.connect(
          url: "http://127.0.0.1:9999",
          endpoint: "/mcp/v1",
          use_sse: true
        )

      url = invoke_build_url(state, "")

      # Must NOT be /mcp/v1/sse — the spec says SSE uses the same endpoint
      refute url =~ "/sse"
      assert url == "http://127.0.0.1:9999/mcp/v1"
    end

    test "custom endpoint paths are preserved for both POST and SSE" do
      {:ok, state} =
        HTTP.connect(
          url: "http://127.0.0.1:9999",
          endpoint: "/api/mcp",
          use_sse: true
        )

      url = invoke_build_url(state, "")
      assert url == "http://127.0.0.1:9999/api/mcp"
    end

    test "default endpoint is used when none specified" do
      {:ok, state} =
        HTTP.connect(
          url: "http://127.0.0.1:9999",
          use_sse: false
        )

      url = invoke_build_url(state, "")
      assert url == "http://127.0.0.1:9999/mcp/v1"
    end
  end

  # ---------- 4. SSE-formatted POST responses ----------

  describe "SSE-formatted POST responses (MCP spec: server may use text/event-stream content-type)" do
    test "parses JSON from text/event-stream formatted POST response" do
      # Some MCP servers respond to POST with content-type: text/event-stream
      # The response body uses SSE format: "data: <json>\n\n"
      sse_body = "data: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"tools\":[]}}\n\n"

      json = extract_json_from_sse(sse_body)
      assert {:ok, parsed} = Jason.decode(json)
      assert parsed["jsonrpc"] == "2.0"
      assert parsed["result"]["tools"] == []
    end

    test "parses multi-line SSE response" do
      sse_body =
        "data: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"protocolVersion\":\"2025-06-18\",\"serverInfo\":{\"name\":\"test\",\"version\":\"1.0.0\"},\"capabilities\":{}}}\n\n"

      json = extract_json_from_sse(sse_body)
      assert {:ok, parsed} = Jason.decode(json)
      assert parsed["result"]["protocolVersion"] == "2025-06-18"
    end

    test "ignores non-data lines in SSE response" do
      # SSE format may include event type, id, retry, and comment lines
      sse_body = """
      event: message
      id: 1
      retry: 3000
      : this is a comment
      data: {"jsonrpc":"2.0","id":1,"result":{"tools":[]}}

      """

      json = extract_json_from_sse(sse_body)
      assert {:ok, parsed} = Jason.decode(json)
      assert parsed["result"]["tools"] == []
    end

    test "handle_non_sse_response correctly routes SSE content-type" do
      # When the response has content-type: text/event-stream, the transport
      # must extract JSON from the SSE data lines before parsing
      {:ok, state} =
        HTTP.connect(
          url: "http://127.0.0.1:9999",
          use_sse: false
        )

      sse_body = "data: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"tools\":[]}}\n\n"

      headers_sse = [
        {~c"content-type", ~c"text/event-stream"}
      ]

      headers_json = [
        {~c"content-type", ~c"application/json"}
      ]

      json_body = "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"tools\":[]}}"

      # Both SSE and JSON content types should produce the same parsed result
      {:ok, state_from_sse, response_sse} =
        invoke_handle_non_sse_response(sse_body, headers_sse, state)

      {:ok, state_from_json, response_json} =
        invoke_handle_non_sse_response(json_body, headers_json, state)

      assert {:ok, parsed_sse} = Jason.decode(response_sse)
      assert {:ok, parsed_json} = Jason.decode(response_json)

      assert parsed_sse["result"] == parsed_json["result"]
      assert state_from_sse.last_response == state_from_json.last_response
    end
  end

  # ---------- 5. POST responses in SSE mode ----------

  describe "POST responses in SSE mode (MCP spec: body contains result)" do
    test "200 response body is parsed even when SSE is active" do
      # Per MCP spec, POST responses contain the result in the body.
      # The SSE stream is for server-initiated messages only.
      {:ok, state} =
        HTTP.connect(
          url: "http://127.0.0.1:9999",
          use_sse: true
        )

      # Simulate response with body — even with use_sse: true, we must parse it
      status_line = {~c"HTTP/1.1", 200, ~c"OK"}

      headers = [
        {~c"content-type", ~c"application/json"},
        {~c"Mcp-Session-Id", ~c"test-session-123"}
      ]

      body =
        ~c"{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"protocolVersion\":\"2025-06-18\",\"serverInfo\":{\"name\":\"test\"},\"capabilities\":{}}}"

      result = invoke_handle_http_response({status_line, headers, body}, state)

      case result do
        {:ok, new_state, response_data} ->
          # Response body MUST be parsed and returned
          assert {:ok, parsed} = Jason.decode(response_data)
          assert parsed["result"]["protocolVersion"] == "2025-06-18"
          # Session ID from server should be captured
          assert new_state.session_id == "test-session-123"

        {:ok, _new_state} ->
          flunk("POST response body was discarded — MCP spec requires it to be returned")
      end
    end

    test "202 Accepted response does not require body parsing" do
      {:ok, state} =
        HTTP.connect(
          url: "http://127.0.0.1:9999",
          use_sse: true
        )

      # 202 Accepted is for notifications — no body expected
      status_line = {~c"HTTP/1.1", 202, ~c"Accepted"}
      headers = [{~c"content-type", ~c"application/json"}]
      body = ~c""

      result = invoke_handle_http_response({status_line, headers, body}, state)

      assert {:ok, _new_state} = result
    end
  end

  # ---------- 6. Protocol version header ----------

  describe "MCP-Protocol-Version header" do
    test "POST requests include mcp-protocol-version header" do
      {:ok, state} =
        HTTP.connect(
          url: "http://127.0.0.1:9999",
          use_sse: false,
          protocol_version: "2025-06-18"
        )

      headers = build_request_headers_for(state)
      version_header = find_header_value(headers, "mcp-protocol-version")

      assert version_header == "2025-06-18"
    end

    test "protocol version header uses negotiated version" do
      {:ok, state} =
        HTTP.connect(
          url: "http://127.0.0.1:9999",
          use_sse: false
          # No explicit version — uses default (latest)
        )

      headers = build_request_headers_for(state)
      version_header = find_header_value(headers, "mcp-protocol-version")

      assert version_header != nil
      assert is_binary(version_header)
    end
  end

  # ---------- 7. SSE connection isolation ----------

  describe "SSE connection isolation (httpc profile separation)" do
    test "SSEClient creates a dedicated httpc profile" do
      # Verify the SSE client module uses separate profiles to avoid
      # connection pool conflicts with POST requests
      alias ExMCP.Transport.SSEClient

      # Start an SSEClient — it will fail to connect but should create a profile
      {:ok, sse_pid} =
        SSEClient.start_link(
          url: "http://127.0.0.1:1/nonexistent",
          parent: self(),
          connect_timeout: 100,
          idle_timeout: 100
        )

      # The SSEClient should have created a unique httpc profile
      sse_state = :sys.get_state(sse_pid)
      assert sse_state.httpc_profile != nil
      assert is_atom(sse_state.httpc_profile)

      # Profile name should be unique (contains a counter)
      profile_str = Atom.to_string(sse_state.httpc_profile)
      assert String.starts_with?(profile_str, "sse_")

      SSEClient.stop(sse_pid)
    end

    test "multiple SSEClients use different profiles" do
      alias ExMCP.Transport.SSEClient

      {:ok, sse1} =
        SSEClient.start_link(
          url: "http://127.0.0.1:1/nonexistent",
          parent: self(),
          connect_timeout: 100,
          idle_timeout: 100
        )

      {:ok, sse2} =
        SSEClient.start_link(
          url: "http://127.0.0.1:1/nonexistent",
          parent: self(),
          connect_timeout: 100,
          idle_timeout: 100
        )

      state1 = :sys.get_state(sse1)
      state2 = :sys.get_state(sse2)

      assert state1.httpc_profile != state2.httpc_profile

      SSEClient.stop(sse1)
      SSEClient.stop(sse2)
    end
  end

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

  # Build request headers using the same logic as the HTTP transport.
  # This mirrors the private build_request_headers/1 function.
  defp build_request_headers_for(state) do
    base_headers = [
      {"content-type", "application/json"},
      {"accept", "application/json, text/event-stream"},
      {"mcp-protocol-version", state.protocol_version}
      | state.headers
    ]

    headers =
      if state.session_id do
        [{"Mcp-Session-Id", state.session_id} | base_headers]
      else
        base_headers
      end

    if state.last_event_id do
      [{"Last-Event-ID", state.last_event_id} | headers]
    else
      headers
    end
  end

  defp find_header_value(headers, name) do
    name_lower = String.downcase(name)

    Enum.find_value(headers, fn {k, v} ->
      if String.downcase(k) == name_lower, do: v
    end)
  end

  # Simulate the session ID extraction from response headers
  defp simulate_session_id_update(headers, state) do
    case Enum.find(headers, fn {k, _v} ->
           k_str = if is_list(k), do: List.to_string(k), else: k
           String.downcase(k_str) == "mcp-session-id"
         end) do
      {_, value} ->
        v = if is_list(value), do: List.to_string(value), else: value
        %{state | session_id: v}

      nil ->
        state
    end
  end

  # Invoke the private build_url function via the module's public behavior
  defp invoke_build_url(state, path) do
    normalized_endpoint =
      case state.endpoint do
        "" ->
          ""

        ep ->
          ep = if String.starts_with?(ep, "/"), do: ep, else: "/" <> ep
          String.trim_trailing(ep, "/")
      end

    state.base_url
    |> URI.parse()
    |> Map.put(:path, normalized_endpoint <> path)
    |> URI.to_string()
  end

  # Extract JSON from SSE-formatted body (mirrors extract_json_from_sse/1)
  defp extract_json_from_sse(body) do
    body
    |> String.split("\n")
    |> Enum.filter(&String.starts_with?(&1, "data: "))
    |> Enum.map_join("", fn "data: " <> data -> data end)
  end

  # Invoke handle_non_sse_response through a simulated path
  defp invoke_handle_non_sse_response(body, headers, state) do
    parse_response_body(body, headers, state)
  end

  # Invoke handle_http_response through the transport module
  # We replicate the logic since it's private
  defp invoke_handle_http_response({status_line, headers, body}, state) do
    body_binary = to_binary(body)
    state = update_session_from_headers(headers, state)

    case status_line do
      {_, 200, _} ->
        parse_response_body(body_binary, headers, state)

      {_, 202, _} ->
        {:ok, state}

      {_, status, _} ->
        {:error, {:http_error, status, body_binary}}
    end
  end

  defp update_session_from_headers(headers, state) do
    case find_charlist_header(headers, "mcp-session-id") do
      nil -> state
      value -> %{state | session_id: value}
    end
  end

  defp parse_response_body(body, headers, state) do
    content_type = find_charlist_header(headers, "content-type") || ""

    json_body =
      if String.contains?(content_type, "text/event-stream"),
        do: extract_json_from_sse(body),
        else: body

    case Jason.decode(json_body) do
      {:ok, response} ->
        {:ok, %{state | last_response: response}, Jason.encode!(response)}

      {:error, reason} ->
        {:error, {:json_decode_error, reason}}
    end
  end

  defp find_charlist_header(headers, name) do
    name_lower = String.downcase(name)

    Enum.find_value(headers, fn {k, v} ->
      k_str = to_binary(k)
      if String.downcase(k_str) == name_lower, do: to_binary(v)
    end)
  end

  defp to_binary(val) when is_list(val), do: List.to_string(val)
  defp to_binary(val), do: val
end
