"""Tests for GitHub-authenticated HTTP request helpers."""

import json
import os
from contextlib import contextmanager
from unittest.mock import MagicMock, patch

import pytest

from specify_cli._github_http import (
    build_github_request,
    resolve_github_release_asset_api_url,
)


class TestBuildGitHubRequest:
    """Tests for build_github_request() URL validation and auth handling."""

    # --- URL Validation Tests ---

    def test_empty_url_raises_value_error(self):
        """build_github_request() must reject an empty string URL."""
        with pytest.raises(ValueError, match="url must not be empty"):
            build_github_request("")

    def test_whitespace_url_raises_value_error(self):
        """build_github_request() must reject a whitespace-only URL."""
        with pytest.raises(ValueError, match="url must not be empty"):
            build_github_request("   ")

    def test_non_http_url_raises_value_error(self):
        """build_github_request() must reject URLs without http/https scheme."""
        with pytest.raises(ValueError, match="url must start with http"):
            build_github_request("not-a-url")

    def test_ftp_url_raises_value_error(self):
        """build_github_request() must reject ftp:// URLs."""
        with pytest.raises(ValueError, match="url must start with http"):
            build_github_request("ftp://github.com/file.zip")

    # --- Valid URL Tests ---

    def test_valid_https_url_returns_request(self):
        """build_github_request() must return a Request for a valid https URL."""
        req = build_github_request("https://github.com/github/spec-kit")
        assert req.full_url == "https://github.com/github/spec-kit"

    def test_valid_http_url_returns_request(self):
        """build_github_request() must accept http:// URLs."""
        req = build_github_request("http://example.com/file")
        assert req.full_url == "http://example.com/file"

    # --- Auth Header Tests ---

    def test_github_token_added_for_github_host(self):
        """Authorization header is set when GITHUB_TOKEN is present."""
        with patch.dict(os.environ, {"GITHUB_TOKEN": "test-token", "GH_TOKEN": ""}):
            req = build_github_request("https://github.com/github/spec-kit")
        assert req.get_header("Authorization") == "Bearer test-token"

    def test_gh_token_used_as_fallback(self):
        """GH_TOKEN is used when GITHUB_TOKEN is absent."""
        with patch.dict(os.environ, {"GITHUB_TOKEN": "", "GH_TOKEN": "fallback-token"}):
            req = build_github_request("https://github.com/github/spec-kit")
        assert req.get_header("Authorization") == "Bearer fallback-token"

    def test_no_auth_header_for_non_github_host(self):
        """Authorization header must NOT be set for non-GitHub URLs."""
        with patch.dict(os.environ, {"GITHUB_TOKEN": "test-token"}):
            req = build_github_request("https://example.com/file")
        assert req.get_header("Authorization") is None

    def test_no_auth_header_when_no_token(self):
        """No Authorization header when no token is set in environment."""
        with patch.dict(os.environ, {}, clear=True):
            req = build_github_request("https://github.com/github/spec-kit")
        assert req.get_header("Authorization") is None

    def test_missing_hostname_raises_value_error(self):
        """build_github_request() must reject URLs with valid scheme but no hostname."""
        with pytest.raises(ValueError, match="url must include a hostname"):
            build_github_request("http://")


class TestResolveGitHubReleaseAssetApiUrl:
    """Tests for resolve_github_release_asset_api_url()."""

    def _make_open_url_fn(self, release_json):
        """Create a fake open_url_fn that returns release JSON."""
        @contextmanager
        def fake_open(url, timeout=None, extra_headers=None):
            resp = MagicMock()
            resp.read.return_value = json.dumps(release_json).encode()
            yield resp
        return fake_open

    def test_returns_none_for_non_github_url(self):
        """Non-GitHub URLs should return None."""
        result = resolve_github_release_asset_api_url(
            "https://example.com/file.zip", lambda *a, **kw: None
        )
        assert result is None

    def test_returns_none_for_non_release_github_url(self):
        """GitHub URLs that aren't release downloads return None."""
        result = resolve_github_release_asset_api_url(
            "https://github.com/org/repo/archive/refs/tags/v1.zip",
            lambda *a, **kw: None,
        )
        assert result is None

    def test_passthrough_for_existing_api_asset_url(self):
        """Already-resolved REST API asset URLs are returned as-is."""
        url = "https://api.github.com/repos/org/repo/releases/assets/12345"
        result = resolve_github_release_asset_api_url(url, lambda *a, **kw: None)
        assert result == url

    def test_resolves_browser_url_to_api_url(self):
        """Browser release URL resolves to REST API asset URL."""
        release_json = {
            "assets": [
                {"name": "pack.zip", "url": "https://api.github.com/repos/org/repo/releases/assets/99"}
            ]
        }
        result = resolve_github_release_asset_api_url(
            "https://github.com/org/repo/releases/download/v1.0/pack.zip",
            self._make_open_url_fn(release_json),
        )
        assert result == "https://api.github.com/repos/org/repo/releases/assets/99"

    def test_returns_none_when_asset_not_found(self):
        """Returns None when the release exists but asset name doesn't match."""
        release_json = {"assets": [{"name": "other.zip", "url": "https://api.github.com/repos/org/repo/releases/assets/1"}]}
        result = resolve_github_release_asset_api_url(
            "https://github.com/org/repo/releases/download/v1/missing.zip",
            self._make_open_url_fn(release_json),
        )
        assert result is None

    def test_returns_none_on_network_error(self):
        """Returns None when the API request fails."""
        import urllib.error

        @contextmanager
        def failing_open(url, timeout=None, extra_headers=None):
            raise urllib.error.URLError("network error")
            yield  # noqa: unreachable

        result = resolve_github_release_asset_api_url(
            "https://github.com/org/repo/releases/download/v1/pack.zip",
            failing_open,
        )
        assert result is None

    def test_tag_with_special_characters_is_url_encoded(self):
        """Tags with reserved characters (e.g. '/') are encoded in the API URL."""
        captured_urls = []

        @contextmanager
        def capturing_open(url, timeout=None, extra_headers=None):
            captured_urls.append(url)
            resp = MagicMock()
            resp.read.return_value = json.dumps({"assets": []}).encode()
            yield resp

        resolve_github_release_asset_api_url(
            "https://github.com/org/repo/releases/download/feature%2Fv1/pack.zip",
            capturing_open,
        )
        # The tag "feature/v1" (decoded from %2F) must be re-encoded as "feature%2Fv1"
        assert len(captured_urls) == 1
        assert "releases/tags/feature%2Fv1" in captured_urls[0]

    def test_tag_with_hash_is_url_encoded(self):
        """Tags with '#' character are properly encoded."""
        captured_urls = []

        @contextmanager
        def capturing_open(url, timeout=None, extra_headers=None):
            captured_urls.append(url)
            resp = MagicMock()
            resp.read.return_value = json.dumps({"assets": []}).encode()
            yield resp

        resolve_github_release_asset_api_url(
            "https://github.com/org/repo/releases/download/v1%23beta/pack.zip",
            capturing_open,
        )
        assert len(captured_urls) == 1
        assert "releases/tags/v1%23beta" in captured_urls[0]

    # --- GHES (GitHub Enterprise Server) ---

    def test_resolves_ghes_browser_url_to_api_url(self):
        """A GHES browser release URL resolves to the /api/v3 asset URL."""
        release_json = {
            "assets": [
                {"name": "ext.zip",
                 "url": "https://ghes.example/api/v3/repos/o/r/releases/assets/7"}
            ]
        }
        result = resolve_github_release_asset_api_url(
            "https://ghes.example/o/r/releases/download/v1/ext.zip",
            self._make_open_url_fn(release_json),
            github_hosts=("ghes.example",),
        )
        assert result == "https://ghes.example/api/v3/repos/o/r/releases/assets/7"

    def test_passthrough_for_existing_ghes_api_asset_url(self):
        """An already-resolved GHES /api/v3 asset URL is returned as-is."""
        url = "https://ghes.example/api/v3/repos/o/r/releases/assets/7"
        result = resolve_github_release_asset_api_url(
            url, lambda *a, **kw: None, github_hosts=("ghes.example",)
        )
        assert result == url

    def test_returns_none_for_ghes_host_not_in_allowlist(self):
        """Unlisted hosts get no GHES treatment and trigger no API call (anti-SSRF)."""
        called = []

        @contextmanager
        def recording_open(url, timeout=None, extra_headers=None):
            called.append(url)
            resp = MagicMock()
            resp.read.return_value = b"{}"
            yield resp

        result = resolve_github_release_asset_api_url(
            "https://ghes.example/o/r/releases/download/v1/ext.zip",
            recording_open,
            github_hosts=("other.example",),
        )
        assert result is None
        assert called == []

    def test_returns_none_on_malformed_ghes_port(self):
        """A malformed port on an allowlisted GHES host returns None, not a
        ValueError (contract: resolve or return None, never raise)."""
        called = []

        def open_never(url, timeout=None, extra_headers=None):
            called.append(url)
            raise AssertionError("open_url_fn must not be called")

        result = resolve_github_release_asset_api_url(
            "https://ghes.example:notaport/o/r/releases/download/v1/ext.zip",
            open_never,
            github_hosts=("ghes.example",),
        )
        assert result is None
        assert called == []

    def test_passthrough_for_unlisted_ghes_api_asset_url(self):
        """A direct GHES /api/v3 asset URL passes through even when the host is
        not allowlisted: passthrough issues no API request, and the download
        helper gates the token independently, so octet-stream resolution must
        not be withheld."""
        called = []

        @contextmanager
        def recording_open(url, timeout=None, extra_headers=None):
            called.append(url)
            resp = MagicMock()
            resp.read.return_value = b"{}"
            yield resp

        url = "https://ghes.example/api/v3/repos/o/r/releases/assets/7"
        result = resolve_github_release_asset_api_url(
            url, recording_open, github_hosts=("other.example",)
        )
        assert result == url
        assert called == []

    def test_ghes_api_base_preserves_scheme_and_port(self):
        """The GHES API base mirrors the URL scheme and keeps a non-standard port."""
        captured = []

        @contextmanager
        def capturing_open(url, timeout=None, extra_headers=None):
            captured.append(url)
            resp = MagicMock()
            resp.read.return_value = json.dumps({"assets": []}).encode()
            yield resp

        resolve_github_release_asset_api_url(
            "http://localhost:8000/o/r/releases/download/v1/ext.zip",
            capturing_open,
            github_hosts=("localhost",),
        )
        assert captured == ["http://localhost:8000/api/v3/repos/o/r/releases/tags/v1"]

    def test_ghes_wildcard_does_not_match_bare_host(self):
        """A '*.suffix' pattern does not match the bare host (must list it explicitly)."""
        result = resolve_github_release_asset_api_url(
            "https://ghes.example/o/r/releases/download/v1/ext.zip",
            lambda *a, **kw: None,
            github_hosts=("*.ghes.example",),
        )
        assert result is None

    def test_public_github_url_unaffected_by_github_hosts(self):
        """Public github.com still resolves via api.github.com even with github_hosts set."""
        captured = []

        @contextmanager
        def capturing_open(url, timeout=None, extra_headers=None):
            captured.append(url)
            resp = MagicMock()
            resp.read.return_value = json.dumps({
                "assets": [{"name": "pack.zip",
                            "url": "https://api.github.com/repos/org/repo/releases/assets/99"}]
            }).encode()
            yield resp

        result = resolve_github_release_asset_api_url(
            "https://github.com/org/repo/releases/download/v1.0/pack.zip",
            capturing_open,
            github_hosts=("ghes.example",),
        )
        assert result == "https://api.github.com/repos/org/repo/releases/assets/99"
        assert captured == ["https://api.github.com/repos/org/repo/releases/tags/v1.0"]
