from __future__ import annotations

import json
from contextlib import asynccontextmanager
from unittest.mock import AsyncMock, MagicMock, patch

import httpx
import pytest

from mcp_server_propel.propel_client import (
    MAX_DIFF_BYTES,
    PropelAPIError,
    PropelAuthError,
    PropelClient,
    PropelConflictError,
    PropelDiffTooLargeError,
    PropelNotFoundError,
    PropelValidationError,
)
from mcp_server_propel.tools.review_tools import (
    _extract_token,
    _is_http_transport,
    check_review_status,
    get_review,
    submit_comment_feedback,
    submit_review,
)
from tests.conftest import (
    SAMPLE_COMPLETED_NO_COMMENTS_RESPONSE,
    SAMPLE_COMPLETED_RESPONSE,
    SAMPLE_FAILED_RESPONSE,
    SAMPLE_FEEDBACK_RESPONSE,
    SAMPLE_QUEUED_RESPONSE,
    SAMPLE_RUNNING_RESPONSE,
    make_mock_transport,
    make_propel_client,
)


def _make_ctx(client: PropelClient) -> MagicMock:
    ctx = MagicMock()
    ctx.request_context.lifespan_context = {
        "base_url": "https://api.test",
    }
    ctx.request_context.request = None  # simulate stdio mode
    ctx.info = AsyncMock()
    ctx.warning = AsyncMock()
    ctx.report_progress = AsyncMock()
    # Stash the pre-configured client for _mock_get_client
    ctx._test_client = client
    return ctx


@asynccontextmanager
async def _mock_get_client(ctx):
    """Test replacement for _get_client that returns the stashed client."""
    yield ctx._test_client


class TestPropelClientCreateReview:
    @pytest.mark.asyncio
    async def test_success(self):
        def handler(request: httpx.Request) -> httpx.Response:
            body = json.loads(request.content)
            assert body["diff"] == "diff content"
            assert body["repository"] == "myorg/myrepo"
            return httpx.Response(202, json=SAMPLE_QUEUED_RESPONSE)

        client = await make_propel_client(make_mock_transport(handler))
        result = await client.create_review("diff content", "myorg/myrepo")
        assert result["review_id"] == "abc-123"
        assert result["status"] == "queued"
        await client.aclose()

    @pytest.mark.asyncio
    async def test_diff_too_large(self):
        client = await make_propel_client(
            make_mock_transport(lambda r: httpx.Response(200))
        )
        large_diff = "x" * (MAX_DIFF_BYTES + 1)
        with pytest.raises(PropelDiffTooLargeError):
            await client.create_review(large_diff, "myorg/myrepo")
        await client.aclose()

    @pytest.mark.asyncio
    async def test_diff_too_large_unicode(self):
        client = await make_propel_client(
            make_mock_transport(lambda r: httpx.Response(200))
        )
        emoji_count = (MAX_DIFF_BYTES // 4) + 1
        large_diff = "\U0001f600" * emoji_count
        assert len(large_diff) < MAX_DIFF_BYTES  # fewer chars than limit
        assert len(large_diff.encode("utf-8")) > MAX_DIFF_BYTES  # more bytes than limit
        with pytest.raises(PropelDiffTooLargeError):
            await client.create_review(large_diff, "myorg/myrepo")
        await client.aclose()

    @pytest.mark.asyncio
    async def test_repository_too_long(self):
        client = await make_propel_client(
            make_mock_transport(lambda r: httpx.Response(200))
        )
        with pytest.raises(PropelValidationError, match="Repository name too long"):
            await client.create_review("diff", "x" * 256)
        await client.aclose()

    @pytest.mark.asyncio
    async def test_auth_error_401(self):
        def handler(request: httpx.Request) -> httpx.Response:
            return httpx.Response(401, json={"error": "Invalid authorization token"})

        client = await make_propel_client(make_mock_transport(handler))
        with pytest.raises(PropelAuthError, match="Invalid authorization token"):
            await client.create_review("diff", "myorg/myrepo")
        await client.aclose()

    @pytest.mark.asyncio
    async def test_auth_error_403_expired(self):
        def handler(request: httpx.Request) -> httpx.Response:
            return httpx.Response(
                403, json={"error": "Authorization token has expired"}
            )

        client = await make_propel_client(make_mock_transport(handler))
        with pytest.raises(PropelAuthError, match="expired"):
            await client.create_review("diff", "myorg/myrepo")
        await client.aclose()

    @pytest.mark.asyncio
    async def test_not_found_404(self):
        def handler(request: httpx.Request) -> httpx.Response:
            return httpx.Response(404, json={"error": "Repository not found"})

        client = await make_propel_client(make_mock_transport(handler))
        with pytest.raises(PropelNotFoundError):
            await client.create_review("diff", "myorg/myrepo")
        await client.aclose()

    @pytest.mark.asyncio
    async def test_server_error_retries(self, monkeypatch):
        call_count = 0

        def handler(request: httpx.Request) -> httpx.Response:
            nonlocal call_count
            call_count += 1
            if call_count < 3:
                return httpx.Response(500, json={"error": "Internal Error"})
            return httpx.Response(202, json=SAMPLE_QUEUED_RESPONSE)

        client = await make_propel_client(make_mock_transport(handler))
        import mcp_server_propel.propel_client as client_module

        monkeypatch.setattr(client_module.asyncio, "sleep", AsyncMock())
        result = await client.create_review("diff", "myorg/myrepo")
        assert result["status"] == "queued"
        assert call_count == 3
        await client.aclose()

    @pytest.mark.asyncio
    async def test_server_error_exhausts_retries(self, monkeypatch):
        def handler(request: httpx.Request) -> httpx.Response:
            return httpx.Response(502, json={"error": "Bad Gateway"})

        client = await make_propel_client(make_mock_transport(handler))
        import mcp_server_propel.propel_client as client_module

        monkeypatch.setattr(client_module.asyncio, "sleep", AsyncMock())
        with pytest.raises(PropelAPIError, match="please try again later"):
            await client.create_review("diff", "myorg/myrepo")
        await client.aclose()

    @pytest.mark.asyncio
    async def test_400_passes_through_server_message(self):
        def handler(request: httpx.Request) -> httpx.Response:
            return httpx.Response(400, json={"error": "invalid diff format"})

        client = await make_propel_client(make_mock_transport(handler))
        with pytest.raises(PropelValidationError, match="invalid diff format"):
            await client.create_review("some bad diff", "myorg/myrepo")
        await client.aclose()

    @pytest.mark.asyncio
    async def test_empty_diff_rejected(self):
        client = await make_propel_client(
            make_mock_transport(lambda r: httpx.Response(200))
        )
        with pytest.raises(PropelValidationError, match="diff is required"):
            await client.create_review("", "myorg/myrepo")
        await client.aclose()

    @pytest.mark.asyncio
    async def test_whitespace_only_diff_rejected(self):
        client = await make_propel_client(
            make_mock_transport(lambda r: httpx.Response(200))
        )
        with pytest.raises(PropelValidationError, match="diff is required"):
            await client.create_review("   \n\t  ", "myorg/myrepo")
        await client.aclose()

    @pytest.mark.asyncio
    async def test_empty_repository_rejected(self):
        client = await make_propel_client(
            make_mock_transport(lambda r: httpx.Response(200))
        )
        with pytest.raises(PropelValidationError, match="repository is required"):
            await client.create_review("diff content", "")
        await client.aclose()

    @pytest.mark.asyncio
    async def test_whitespace_only_repository_rejected(self):
        client = await make_propel_client(
            make_mock_transport(lambda r: httpx.Response(200))
        )
        with pytest.raises(PropelValidationError, match="repository is required"):
            await client.create_review("diff content", "   ")
        await client.aclose()

    @pytest.mark.asyncio
    async def test_inputs_are_trimmed(self):
        def handler(request: httpx.Request) -> httpx.Response:
            body = json.loads(request.content)
            assert body["diff"] == "diff content"
            assert body["repository"] == "myorg/myrepo"
            assert body["base_commit"] == "abc123"
            return httpx.Response(202, json=SAMPLE_QUEUED_RESPONSE)

        client = await make_propel_client(make_mock_transport(handler))
        result = await client.create_review(
            "  diff content  ", "  myorg/myrepo  ", base_commit="  abc123  "
        )
        assert result["status"] == "queued"
        await client.aclose()

    @pytest.mark.asyncio
    async def test_403_passes_through_scope_message(self):
        def handler(request: httpx.Request) -> httpx.Response:
            return httpx.Response(
                403, json={"error": "Authorization token lacks required scope"}
            )

        client = await make_propel_client(make_mock_transport(handler))
        with pytest.raises(PropelAuthError, match="lacks required scope"):
            await client.create_review("diff", "myorg/myrepo")
        await client.aclose()


class TestPropelClientGetReview:
    @pytest.mark.asyncio
    async def test_success(self):
        def handler(request: httpx.Request) -> httpx.Response:
            assert "/v1/reviews/abc-123" in str(request.url)
            return httpx.Response(200, json=SAMPLE_COMPLETED_RESPONSE)

        client = await make_propel_client(make_mock_transport(handler))
        result = await client.get_review("abc-123")
        assert result["status"] == "completed"
        assert len(result["comments"]) == 2
        await client.aclose()


@patch("mcp_server_propel.tools.review_tools._get_client", _mock_get_client)
class TestSubmitReviewTool:
    @pytest.mark.asyncio
    async def test_success(self):
        def handler(request: httpx.Request) -> httpx.Response:
            return httpx.Response(202, json=SAMPLE_QUEUED_RESPONSE)

        client = await make_propel_client(make_mock_transport(handler))
        ctx = _make_ctx(client)
        result = await submit_review("myorg/myrepo", diff="diff content", ctx=ctx)
        assert "Review submitted successfully!" in result
        assert "abc-123" in result
        assert "get_review" in result
        await client.aclose()

    @pytest.mark.asyncio
    async def test_diff_too_large_returns_error_text(self):
        client = await make_propel_client(
            make_mock_transport(lambda r: httpx.Response(200))
        )
        ctx = _make_ctx(client)
        large_diff = "x" * (MAX_DIFF_BYTES + 1)
        result = await submit_review("myorg/myrepo", diff=large_diff, ctx=ctx)
        assert "Error" in result
        assert "too large" in result
        await client.aclose()

    @pytest.mark.asyncio
    async def test_api_error_returns_error_text(self):
        def handler(request: httpx.Request) -> httpx.Response:
            return httpx.Response(401, json={"error": "Invalid authorization token"})

        client = await make_propel_client(make_mock_transport(handler))
        ctx = _make_ctx(client)
        result = await submit_review("myorg/myrepo", diff="diff", ctx=ctx)
        assert "Error" in result
        assert "Invalid authorization token" in result
        await client.aclose()

    @pytest.mark.asyncio
    async def test_empty_diff_returns_error(self):
        client = await make_propel_client(
            make_mock_transport(lambda r: httpx.Response(200))
        )
        ctx = _make_ctx(client)
        result = await submit_review("myorg/myrepo", ctx=ctx)
        assert "Error" in result
        assert "diff is required" in result
        await client.aclose()


@patch("mcp_server_propel.tools.review_tools._get_client", _mock_get_client)
class TestGetReviewTool:
    @pytest.mark.asyncio
    async def test_completed_with_comments(self):
        def handler(request: httpx.Request) -> httpx.Response:
            return httpx.Response(200, json=SAMPLE_COMPLETED_RESPONSE)

        client = await make_propel_client(make_mock_transport(handler))
        ctx = _make_ctx(client)
        result = await get_review("abc-123", ctx=ctx)
        assert "Review completed" in result
        assert "src/app.py:42" in result
        assert "[medium]" in result
        assert "src/utils.py:15" in result
        assert "[high]" in result
        await client.aclose()

    @pytest.mark.asyncio
    async def test_completed_no_comments(self):
        def handler(request: httpx.Request) -> httpx.Response:
            return httpx.Response(200, json=SAMPLE_COMPLETED_NO_COMMENTS_RESPONSE)

        client = await make_propel_client(make_mock_transport(handler))
        ctx = _make_ctx(client)
        result = await get_review("abc-123", ctx=ctx)
        assert "No issues found" in result
        await client.aclose()

    @pytest.mark.asyncio
    async def test_failed(self):
        def handler(request: httpx.Request) -> httpx.Response:
            return httpx.Response(200, json=SAMPLE_FAILED_RESPONSE)

        client = await make_propel_client(make_mock_transport(handler))
        ctx = _make_ctx(client)
        result = await get_review("abc-123", ctx=ctx)
        assert "Review failed" in result
        assert "generation_failed" in result
        await client.aclose()

    @pytest.mark.asyncio
    async def test_polls_until_complete(self, monkeypatch):
        call_count = 0

        def handler(request: httpx.Request) -> httpx.Response:
            nonlocal call_count
            call_count += 1
            if call_count < 3:
                return httpx.Response(200, json=SAMPLE_RUNNING_RESPONSE)
            return httpx.Response(200, json=SAMPLE_COMPLETED_RESPONSE)

        client = await make_propel_client(make_mock_transport(handler))
        ctx = _make_ctx(client)

        import mcp_server_propel.tools.review_tools as tools_module

        monkeypatch.setattr(tools_module.asyncio, "sleep", AsyncMock())

        result = await get_review("abc-123", ctx=ctx)
        assert "Review completed" in result
        assert call_count == 3
        assert ctx.report_progress.call_count == 3
        await client.aclose()

    @pytest.mark.asyncio
    async def test_api_error_returns_error_text(self):
        def handler(request: httpx.Request) -> httpx.Response:
            return httpx.Response(404, json={"error": "Not found"})

        client = await make_propel_client(make_mock_transport(handler))
        ctx = _make_ctx(client)
        result = await get_review("abc-123", ctx=ctx)
        assert "Error" in result
        await client.aclose()

    @pytest.mark.asyncio
    async def test_http_mode_uses_shorter_poll_limit(self, monkeypatch):
        """HTTP transport should stop polling after HTTP_POLL_MAX_ATTEMPTS (5)."""
        call_count = 0

        def handler(request: httpx.Request) -> httpx.Response:
            nonlocal call_count
            call_count += 1
            return httpx.Response(200, json=SAMPLE_RUNNING_RESPONSE)

        client = await make_propel_client(make_mock_transport(handler))
        ctx = _make_ctx(client)
        # Simulate HTTP mode: request is non-None
        ctx.request_context.request = MagicMock()

        import mcp_server_propel.tools.review_tools as tools_module

        monkeypatch.setattr(tools_module.asyncio, "sleep", AsyncMock())

        result = await get_review("abc-123", ctx=ctx)
        assert call_count == 5  # HTTP_POLL_MAX_ATTEMPTS
        assert "still running" in result
        assert "Call get_review again" in result
        await client.aclose()


@patch("mcp_server_propel.tools.review_tools._get_client", _mock_get_client)
class TestCheckReviewStatusTool:
    @pytest.mark.asyncio
    async def test_running(self):
        def handler(request: httpx.Request) -> httpx.Response:
            return httpx.Response(200, json=SAMPLE_RUNNING_RESPONSE)

        client = await make_propel_client(make_mock_transport(handler))
        ctx = _make_ctx(client)
        result = await check_review_status("abc-123", ctx=ctx)
        assert "running" in result
        assert "abc-123" in result
        assert "src/app.py" not in result
        await client.aclose()

    @pytest.mark.asyncio
    async def test_completed_shows_count_not_details(self):
        def handler(request: httpx.Request) -> httpx.Response:
            return httpx.Response(200, json=SAMPLE_COMPLETED_RESPONSE)

        client = await make_propel_client(make_mock_transport(handler))
        ctx = _make_ctx(client)
        result = await check_review_status("abc-123", ctx=ctx)
        assert "completed" in result
        assert "Comments: 2" in result
        assert "list comprehension" not in result
        await client.aclose()

    @pytest.mark.asyncio
    async def test_failed_shows_error(self):
        def handler(request: httpx.Request) -> httpx.Response:
            return httpx.Response(200, json=SAMPLE_FAILED_RESPONSE)

        client = await make_propel_client(make_mock_transport(handler))
        ctx = _make_ctx(client)
        result = await check_review_status("abc-123", ctx=ctx)
        assert "failed" in result
        assert "generation_failed" in result or "internal error" in result.lower()
        await client.aclose()


class TestPropelClientSubmitCommentFeedback:
    @pytest.mark.asyncio
    async def test_success_with_comment_id(self):
        def handler(request: httpx.Request) -> httpx.Response:
            body = json.loads(request.content)
            assert body["comment_id"] == "cmt-001"
            assert body["incorporated"] is True
            assert "/comments/feedback" in str(request.url)
            return httpx.Response(200, json=SAMPLE_FEEDBACK_RESPONSE)

        client = await make_propel_client(make_mock_transport(handler))
        result = await client.submit_comment_feedback(
            review_id="abc-123",
            incorporated=True,
            comment_id="cmt-001",
        )
        assert result["comment_id"] == "cmt-001"
        assert result["incorporated"] is True
        await client.aclose()

    @pytest.mark.asyncio
    async def test_success_with_comment_object(self):
        def handler(request: httpx.Request) -> httpx.Response:
            body = json.loads(request.content)
            assert body["comment"]["file_path"] == "src/app.py"
            assert body["comment"]["line"] == 42
            assert body["comment"]["severity"] == "high"
            assert "comment_id" not in body
            return httpx.Response(200, json=SAMPLE_FEEDBACK_RESPONSE)

        client = await make_propel_client(make_mock_transport(handler))
        result = await client.submit_comment_feedback(
            review_id="abc-123",
            incorporated=True,
            comment={
                "file_path": "src/app.py",
                "line": 42,
                "message": "Add nil check",
                "severity": "high",
            },
        )
        assert result["review_id"] == "abc-123"
        await client.aclose()

    @pytest.mark.asyncio
    async def test_includes_notes(self):
        def handler(request: httpx.Request) -> httpx.Response:
            body = json.loads(request.content)
            assert body["notes"] == "Good catch, fixed."
            return httpx.Response(200, json=SAMPLE_FEEDBACK_RESPONSE)

        client = await make_propel_client(make_mock_transport(handler))
        await client.submit_comment_feedback(
            review_id="abc-123",
            incorporated=True,
            comment_id="cmt-001",
            notes="Good catch, fixed.",
        )
        await client.aclose()

    @pytest.mark.asyncio
    async def test_omits_notes_when_none(self):
        def handler(request: httpx.Request) -> httpx.Response:
            body = json.loads(request.content)
            assert "notes" not in body
            return httpx.Response(200, json=SAMPLE_FEEDBACK_RESPONSE)

        client = await make_propel_client(make_mock_transport(handler))
        await client.submit_comment_feedback(
            review_id="abc-123",
            incorporated=False,
            comment_id="cmt-001",
        )
        await client.aclose()

    @pytest.mark.asyncio
    async def test_empty_review_id_rejected(self):
        client = await make_propel_client(
            make_mock_transport(lambda r: httpx.Response(200))
        )
        with pytest.raises(PropelValidationError, match="review_id is required"):
            await client.submit_comment_feedback(
                review_id="  ",
                incorporated=True,
                comment_id="cmt-001",
            )
        await client.aclose()

    @pytest.mark.asyncio
    async def test_no_comment_id_or_comment_rejected(self):
        client = await make_propel_client(
            make_mock_transport(lambda r: httpx.Response(200))
        )
        with pytest.raises(PropelValidationError, match="Either comment_id or comment"):
            await client.submit_comment_feedback(
                review_id="abc-123",
                incorporated=True,
            )
        await client.aclose()

    @pytest.mark.asyncio
    async def test_409_conflict(self):
        def handler(request: httpx.Request) -> httpx.Response:
            return httpx.Response(409, json={"error": "Review is not completed"})

        client = await make_propel_client(make_mock_transport(handler))
        with pytest.raises(PropelConflictError, match="Review is not completed"):
            await client.submit_comment_feedback(
                review_id="abc-123",
                incorporated=True,
                comment_id="cmt-001",
            )
        await client.aclose()

    @pytest.mark.asyncio
    async def test_404_comment_not_found(self):
        def handler(request: httpx.Request) -> httpx.Response:
            return httpx.Response(404, json={"error": "Comment not found"})

        client = await make_propel_client(make_mock_transport(handler))
        with pytest.raises(PropelNotFoundError, match="Comment not found"):
            await client.submit_comment_feedback(
                review_id="abc-123",
                incorporated=True,
                comment_id="nonexistent",
            )
        await client.aclose()


@patch("mcp_server_propel.tools.review_tools._get_client", _mock_get_client)
class TestSubmitCommentFeedbackTool:
    @pytest.mark.asyncio
    async def test_success_with_comment_id(self):
        def handler(request: httpx.Request) -> httpx.Response:
            return httpx.Response(200, json=SAMPLE_FEEDBACK_RESPONSE)

        client = await make_propel_client(make_mock_transport(handler))
        ctx = _make_ctx(client)
        result = await submit_comment_feedback(
            review_id="abc-123",
            incorporated=True,
            comment_id="cmt-001",
            ctx=ctx,
        )
        assert "Feedback recorded successfully!" in result
        assert "cmt-001" in result
        assert "incorporated" in result
        await client.aclose()

    @pytest.mark.asyncio
    async def test_success_with_comment_fields(self):
        def handler(request: httpx.Request) -> httpx.Response:
            body = json.loads(request.content)
            assert body["comment"]["file_path"] == "src/app.py"
            assert body["comment"]["line"] == 42
            return httpx.Response(200, json=SAMPLE_FEEDBACK_RESPONSE)

        client = await make_propel_client(make_mock_transport(handler))
        ctx = _make_ctx(client)
        result = await submit_comment_feedback(
            review_id="abc-123",
            incorporated=True,
            file_path="src/app.py",
            line=42,
            message="Add nil check",
            severity="high",
            ctx=ctx,
        )
        assert "Feedback recorded successfully!" in result
        await client.aclose()

    @pytest.mark.asyncio
    async def test_dismissed_status(self):
        dismissed_response = {**SAMPLE_FEEDBACK_RESPONSE, "incorporated": False}

        def handler(request: httpx.Request) -> httpx.Response:
            return httpx.Response(200, json=dismissed_response)

        client = await make_propel_client(make_mock_transport(handler))
        ctx = _make_ctx(client)
        result = await submit_comment_feedback(
            review_id="abc-123",
            incorporated=False,
            comment_id="cmt-001",
            ctx=ctx,
        )
        assert "dismissed" in result
        await client.aclose()

    @pytest.mark.asyncio
    async def test_notes_echoed_in_response(self):
        def handler(request: httpx.Request) -> httpx.Response:
            return httpx.Response(200, json=SAMPLE_FEEDBACK_RESPONSE)

        client = await make_propel_client(make_mock_transport(handler))
        ctx = _make_ctx(client)
        result = await submit_comment_feedback(
            review_id="abc-123",
            incorporated=True,
            comment_id="cmt-001",
            notes="Used list comprehension instead",
            ctx=ctx,
        )
        assert "Notes: Used list comprehension instead" in result
        await client.aclose()

    @pytest.mark.asyncio
    async def test_missing_comment_fields_returns_error(self):
        client = await make_propel_client(
            make_mock_transport(lambda r: httpx.Response(200))
        )
        ctx = _make_ctx(client)
        result = await submit_comment_feedback(
            review_id="abc-123",
            incorporated=True,
            file_path="src/app.py",
            line=42,
            # missing message and severity
            ctx=ctx,
        )
        assert "Error" in result
        assert "comment_id or all four comment fields" in result
        await client.aclose()

    @pytest.mark.asyncio
    async def test_invalid_severity_returns_error(self):
        client = await make_propel_client(
            make_mock_transport(lambda r: httpx.Response(200))
        )
        ctx = _make_ctx(client)
        result = await submit_comment_feedback(
            review_id="abc-123",
            incorporated=True,
            file_path="src/app.py",
            line=42,
            message="Some message",
            severity="invalid",
            ctx=ctx,
        )
        assert "Error" in result
        assert "severity must be one of" in result
        await client.aclose()

    @pytest.mark.asyncio
    async def test_api_error_returns_error_text(self):
        def handler(request: httpx.Request) -> httpx.Response:
            return httpx.Response(409, json={"error": "Review is not completed"})

        client = await make_propel_client(make_mock_transport(handler))
        ctx = _make_ctx(client)
        result = await submit_comment_feedback(
            review_id="abc-123",
            incorporated=True,
            comment_id="cmt-001",
            ctx=ctx,
        )
        assert "Error" in result
        assert "Review is not completed" in result
        await client.aclose()


@patch("mcp_server_propel.tools.review_tools._get_client", _mock_get_client)
class TestSubmitCommentFeedbackToolCommentIdPrecedence:
    @pytest.mark.asyncio
    async def test_comment_id_takes_precedence_over_fields(self):
        """When both comment_id and comment fields are provided, comment_id wins."""

        def handler(request: httpx.Request) -> httpx.Response:
            body = json.loads(request.content)
            assert body["comment_id"] == "cmt-001"
            assert "comment" not in body
            return httpx.Response(200, json=SAMPLE_FEEDBACK_RESPONSE)

        client = await make_propel_client(make_mock_transport(handler))
        ctx = _make_ctx(client)
        result = await submit_comment_feedback(
            review_id="abc-123",
            incorporated=True,
            comment_id="cmt-001",
            file_path="src/app.py",
            line=42,
            message="Some message",
            severity="high",
            ctx=ctx,
        )
        assert "Feedback recorded successfully!" in result
        await client.aclose()


@patch("mcp_server_propel.tools.review_tools._get_client", _mock_get_client)
class TestFormatCompletedReview:
    @pytest.mark.asyncio
    async def test_comment_id_in_output(self):
        def handler(request: httpx.Request) -> httpx.Response:
            return httpx.Response(200, json=SAMPLE_COMPLETED_RESPONSE)

        client = await make_propel_client(make_mock_transport(handler))
        ctx = _make_ctx(client)
        result = await get_review("abc-123", ctx=ctx)
        assert "comment_id: cmt-001" in result
        assert "comment_id: cmt-002" in result
        # ACTION REQUIRED is now sent via ctx.info, not in the output
        assert "ACTION REQUIRED" not in result
        await client.aclose()

    @pytest.mark.asyncio
    async def test_feedback_instruction_sent_via_ctx_info(self):
        def handler(request: httpx.Request) -> httpx.Response:
            return httpx.Response(200, json=SAMPLE_COMPLETED_RESPONSE)

        client = await make_propel_client(make_mock_transport(handler))
        ctx = _make_ctx(client)
        await get_review("abc-123", ctx=ctx)
        # Verify the feedback instruction was sent through ctx.info
        info_messages = [call.args[0] for call in ctx.info.call_args_list]
        info_text = " ".join(info_messages)
        assert "submit_comment_feedback" in info_text
        assert "incorporated=true" in info_text
        await client.aclose()

    @pytest.mark.asyncio
    async def test_no_feedback_instruction_when_no_comments(self):
        def handler(request: httpx.Request) -> httpx.Response:
            return httpx.Response(200, json=SAMPLE_COMPLETED_NO_COMMENTS_RESPONSE)

        client = await make_propel_client(make_mock_transport(handler))
        ctx = _make_ctx(client)
        result = await get_review("abc-123", ctx=ctx)
        assert "No issues found" in result
        # ctx.info should NOT contain feedback instructions
        info_messages = [call.args[0] for call in ctx.info.call_args_list]
        info_text = " ".join(info_messages)
        assert "submit_comment_feedback" not in info_text
        await client.aclose()

    @pytest.mark.asyncio
    async def test_comments_without_comment_id_field(self):
        """Comments missing comment_id should render without parenthetical."""
        response = {
            **SAMPLE_COMPLETED_RESPONSE,
            "comments": [
                {
                    "file_path": "src/app.py",
                    "line": 10,
                    "message": "Test message",
                    "severity": "high",
                },
            ],
        }

        def handler(request: httpx.Request) -> httpx.Response:
            return httpx.Response(200, json=response)

        client = await make_propel_client(make_mock_transport(handler))
        ctx = _make_ctx(client)
        result = await get_review("abc-123", ctx=ctx)
        assert "src/app.py:10 [high]" in result
        assert "comment_id" not in result
        await client.aclose()


class TestClientTimeout:
    def test_timeout_is_30_seconds(self):
        client = PropelClient(api_token="test", base_url="https://api.test")
        timeout = client._client.timeout
        assert timeout.connect == 30.0
        assert timeout.read == 30.0
        assert timeout.write == 30.0


class TestIsHttpTransport:
    def test_none_ctx_returns_false(self):
        assert _is_http_transport(None) is False

    def test_stdio_mode_returns_false(self):
        ctx = MagicMock()
        ctx.request_context.request = None
        assert _is_http_transport(ctx) is False

    def test_http_mode_returns_true(self):
        ctx = MagicMock()
        ctx.request_context.request = MagicMock()  # non-None = HTTP
        assert _is_http_transport(ctx) is True

    def test_missing_request_context_returns_false(self):
        ctx = MagicMock(spec=[])  # no attributes
        assert _is_http_transport(ctx) is False


class TestExtractToken:
    def test_http_valid_bearer_token(self):
        ctx = MagicMock()
        ctx.request_context.request.headers = {
            "authorization": "Bearer my-secret-token"
        }
        assert _extract_token(ctx) == "my-secret-token"

    def test_http_bearer_case_insensitive(self):
        ctx = MagicMock()
        ctx.request_context.request.headers = {"authorization": "bearer my-token"}
        assert _extract_token(ctx) == "my-token"

    def test_http_bearer_with_extra_whitespace(self):
        ctx = MagicMock()
        ctx.request_context.request.headers = {"authorization": "Bearer   my-token  "}
        assert _extract_token(ctx) == "my-token"

    def test_http_missing_header_raises(self):
        ctx = MagicMock()
        ctx.request_context.request.headers = {}
        with pytest.raises(ValueError, match="Missing or invalid Authorization header"):
            _extract_token(ctx)

    def test_http_empty_bearer_raises(self):
        ctx = MagicMock()
        ctx.request_context.request.headers = {"authorization": "Bearer "}
        with pytest.raises(ValueError, match="Missing or invalid Authorization header"):
            _extract_token(ctx)

    def test_http_non_bearer_scheme_raises(self):
        ctx = MagicMock()
        ctx.request_context.request.headers = {"authorization": "Basic abc123"}
        with pytest.raises(ValueError, match="Missing or invalid Authorization header"):
            _extract_token(ctx)

    def test_http_does_not_fall_back_to_env(self, monkeypatch):
        """HTTP mode must never use PROPEL_API_TOKEN env var."""
        monkeypatch.setenv("PROPEL_API_TOKEN", "env-token")
        ctx = MagicMock()
        ctx.request_context.request.headers = {}  # no auth header
        with pytest.raises(ValueError, match="Missing or invalid Authorization header"):
            _extract_token(ctx)

    def test_stdio_uses_env_var(self, monkeypatch):
        monkeypatch.setenv("PROPEL_API_TOKEN", "env-token")
        ctx = MagicMock()
        ctx.request_context.request = None
        assert _extract_token(ctx) == "env-token"

    def test_stdio_strips_env_var(self, monkeypatch):
        monkeypatch.setenv("PROPEL_API_TOKEN", "  env-token  ")
        ctx = MagicMock()
        ctx.request_context.request = None
        assert _extract_token(ctx) == "env-token"

    def test_stdio_no_env_var_raises(self, monkeypatch):
        monkeypatch.delenv("PROPEL_API_TOKEN", raising=False)
        ctx = MagicMock()
        ctx.request_context.request = None
        with pytest.raises(ValueError, match="No API token provided"):
            _extract_token(ctx)

    def test_stdio_empty_env_var_raises(self, monkeypatch):
        monkeypatch.setenv("PROPEL_API_TOKEN", "   ")
        ctx = MagicMock()
        ctx.request_context.request = None
        with pytest.raises(ValueError, match="No API token provided"):
            _extract_token(ctx)
