"""Tests for ievo.core.cortex — Cortex download + extract module.

TDD: tests written before core/cortex.py is implemented.
"""

from __future__ import annotations

import contextlib
import io
import tarfile
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch

import httpx
import pytest

from ievo.core.cortex import (
    CortexDownloadError,
    download_cortex_release,
    fetch_latest_cortex_tag,
    get_installed_cortex_version,
)

# ── Helpers ───────────────────────────────────────────────────


def make_tar_gz(members: dict[str, str]) -> bytes:
    """Create an in-memory .tar.gz with the given {arcname: content} members."""
    buf = io.BytesIO()
    with tarfile.open(fileobj=buf, mode="w:gz") as tf:
        for arcname, content in members.items():
            data = content.encode()
            info = tarfile.TarInfo(name=arcname)
            info.size = len(data)
            tf.addfile(info, io.BytesIO(data))
    return buf.getvalue()


FAKE_API_RESPONSE = {
    "tag_name": "v1.0.0",
    "assets": [
        {
            "name": "cortex-v1.0.0.tar.gz",
            "browser_download_url": "https://github.com/ievo-ai/cortex/releases/download/v1.0.0/cortex-v1.0.0.tar.gz",
        }
    ],
}

FAKE_TAR_MEMBERS = {
    "claude/iEVO.md": "# iEVO Kernel\n",
    "claude/agents/spec-writer.md": "---\nname: spec-writer\n---\n",
    "codex/BUILD_TARGET.md": "# Codex placeholder\n",
}


# ── fetch_latest_cortex_tag ──────────────────────────────────


@pytest.mark.asyncio
async def test_fetch_latest_tag_returns_tag_and_url() -> None:
    """Returns (tag, asset_download_url) from the GitHub Releases API."""
    mock_response = MagicMock()
    mock_response.status_code = 200
    mock_response.json.return_value = FAKE_API_RESPONSE
    mock_response.raise_for_status = MagicMock()

    mock_client = AsyncMock()
    mock_client.__aenter__ = AsyncMock(return_value=mock_client)
    mock_client.__aexit__ = AsyncMock(return_value=None)
    mock_client.get = AsyncMock(return_value=mock_response)

    with patch("ievo.core.cortex.httpx.AsyncClient", return_value=mock_client):
        tag, url = await fetch_latest_cortex_tag()

    assert tag == "v1.0.0"
    assert "v1.0.0" in url
    assert url == FAKE_API_RESPONSE["assets"][0]["browser_download_url"]


@pytest.mark.asyncio
async def test_fetch_latest_tag_network_error() -> None:
    """Raises CortexDownloadError on network failure, with message containing releases URL."""
    mock_client = AsyncMock()
    mock_client.__aenter__ = AsyncMock(return_value=mock_client)
    mock_client.__aexit__ = AsyncMock(return_value=None)
    mock_client.get = AsyncMock(side_effect=httpx.ConnectError("connection refused"))

    with (
        patch("ievo.core.cortex.httpx.AsyncClient", return_value=mock_client),
        pytest.raises(CortexDownloadError) as exc_info,
    ):
        await fetch_latest_cortex_tag()

    assert "releases" in str(exc_info.value).lower() or "cortex" in str(exc_info.value).lower()


@pytest.mark.asyncio
async def test_fetch_latest_tag_no_assets() -> None:
    """Raises CortexDownloadError when the API response has no assets."""
    mock_response = MagicMock()
    mock_response.status_code = 200
    mock_response.json.return_value = {"tag_name": "v1.0.0", "assets": []}
    mock_response.raise_for_status = MagicMock()

    mock_client = AsyncMock()
    mock_client.__aenter__ = AsyncMock(return_value=mock_client)
    mock_client.__aexit__ = AsyncMock(return_value=None)
    mock_client.get = AsyncMock(return_value=mock_response)

    with (
        patch("ievo.core.cortex.httpx.AsyncClient", return_value=mock_client),
        pytest.raises(CortexDownloadError),
    ):
        await fetch_latest_cortex_tag()


@pytest.mark.asyncio
async def test_fetch_latest_tag_forwards_github_token(monkeypatch: pytest.MonkeyPatch) -> None:
    """Forwards GITHUB_TOKEN as Authorization header when set."""
    monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")

    mock_response = MagicMock()
    mock_response.status_code = 200
    mock_response.json.return_value = FAKE_API_RESPONSE
    mock_response.raise_for_status = MagicMock()

    captured_headers: dict = {}

    async def fake_get(url: str, **kwargs):
        captured_headers.update(kwargs.get("headers", {}))
        return mock_response

    mock_client = AsyncMock()
    mock_client.__aenter__ = AsyncMock(return_value=mock_client)
    mock_client.__aexit__ = AsyncMock(return_value=None)
    mock_client.get = fake_get

    with patch("ievo.core.cortex.httpx.AsyncClient", return_value=mock_client):
        await fetch_latest_cortex_tag()

    assert "Authorization" in captured_headers
    assert "ghp_testtoken" in captured_headers["Authorization"]


@pytest.mark.asyncio
async def test_fetch_latest_tag_no_github_token(monkeypatch: pytest.MonkeyPatch) -> None:
    """Does not send Authorization header when GITHUB_TOKEN is not set."""
    monkeypatch.delenv("GITHUB_TOKEN", raising=False)

    mock_response = MagicMock()
    mock_response.status_code = 200
    mock_response.json.return_value = FAKE_API_RESPONSE
    mock_response.raise_for_status = MagicMock()

    captured_headers: dict = {}

    async def fake_get(url: str, **kwargs):
        captured_headers.update(kwargs.get("headers", {}))
        return mock_response

    mock_client = AsyncMock()
    mock_client.__aenter__ = AsyncMock(return_value=mock_client)
    mock_client.__aexit__ = AsyncMock(return_value=None)
    mock_client.get = fake_get

    with patch("ievo.core.cortex.httpx.AsyncClient", return_value=mock_client):
        await fetch_latest_cortex_tag()

    assert "Authorization" not in captured_headers


# ── download_cortex_release ──────────────────────────────────


@pytest.mark.asyncio
async def test_download_network_error_raises(tmp_path: Path) -> None:
    """Raises CortexDownloadError when asset download fails with network error."""
    mock_client = AsyncMock()
    mock_client.__aenter__ = AsyncMock(return_value=mock_client)
    mock_client.__aexit__ = AsyncMock(return_value=None)
    mock_client.get = AsyncMock(side_effect=httpx.ConnectError("connection refused"))

    with (
        patch("ievo.core.cortex.httpx.AsyncClient", return_value=mock_client),
        pytest.raises(CortexDownloadError),
    ):
        await download_cortex_release(
            tag="v1.0.0",
            asset_url="https://example.com/cortex-v1.0.0.tar.gz",
            root=tmp_path,
        )


@pytest.mark.asyncio
async def test_download_and_extract(tmp_path: Path) -> None:
    """Downloads tar.gz, extracts claude/ and codex/ to .ievo/cortex/, writes version file."""
    tar_bytes = make_tar_gz(FAKE_TAR_MEMBERS)

    mock_response = MagicMock()
    mock_response.status_code = 200
    mock_response.content = tar_bytes
    mock_response.raise_for_status = MagicMock()

    mock_client = AsyncMock()
    mock_client.__aenter__ = AsyncMock(return_value=mock_client)
    mock_client.__aexit__ = AsyncMock(return_value=None)
    mock_client.get = AsyncMock(return_value=mock_response)

    with patch("ievo.core.cortex.httpx.AsyncClient", return_value=mock_client):
        await download_cortex_release(
            tag="v1.0.0",
            asset_url="https://example.com/cortex-v1.0.0.tar.gz",
            root=tmp_path,
        )

    assert (tmp_path / ".ievo" / "cortex" / "claude" / "iEVO.md").exists()
    assert (tmp_path / ".ievo" / "cortex" / "codex" / "BUILD_TARGET.md").exists()
    version_file = tmp_path / ".ievo" / "cortex-version"
    assert version_file.exists()
    assert version_file.read_text().strip() == "v1.0.0"


@pytest.mark.asyncio
async def test_download_path_traversal_blocked(tmp_path: Path) -> None:
    """Extraction rejects tar members with path traversal (../../evil)."""
    buf = io.BytesIO()
    with tarfile.open(fileobj=buf, mode="w:gz") as tf:
        # Legitimate member
        data = b"safe content"
        info = tarfile.TarInfo(name="claude/iEVO.md")
        info.size = len(data)
        tf.addfile(info, io.BytesIO(data))
        # Malicious member
        evil_data = b"evil"
        evil_info = tarfile.TarInfo(name="../../evil.txt")
        evil_info.size = len(evil_data)
        tf.addfile(evil_info, io.BytesIO(evil_data))
    tar_bytes = buf.getvalue()

    mock_response = MagicMock()
    mock_response.status_code = 200
    mock_response.content = tar_bytes
    mock_response.raise_for_status = MagicMock()

    mock_client = AsyncMock()
    mock_client.__aenter__ = AsyncMock(return_value=mock_client)
    mock_client.__aexit__ = AsyncMock(return_value=None)
    mock_client.get = AsyncMock(return_value=mock_response)

    with (
        patch("ievo.core.cortex.httpx.AsyncClient", return_value=mock_client),
        contextlib.suppress(CortexDownloadError, Exception),
    ):
        # Should either succeed (filtering out the evil member) or raise CortexDownloadError
        await download_cortex_release(
            tag="v1.0.0",
            asset_url="https://example.com/cortex-v1.0.0.tar.gz",
            root=tmp_path,
        )

    # The evil file must NOT exist outside the project root
    evil_path = tmp_path.parent / "evil.txt"
    assert not evil_path.exists(), f"Path traversal succeeded: {evil_path}"


@pytest.mark.asyncio
async def test_download_idempotent(tmp_path: Path) -> None:
    """Running download_cortex_release twice produces identical .ievo/cortex/ contents."""
    tar_bytes = make_tar_gz(FAKE_TAR_MEMBERS)

    mock_response = MagicMock()
    mock_response.status_code = 200
    mock_response.content = tar_bytes
    mock_response.raise_for_status = MagicMock()

    mock_client = AsyncMock()
    mock_client.__aenter__ = AsyncMock(return_value=mock_client)
    mock_client.__aexit__ = AsyncMock(return_value=None)
    mock_client.get = AsyncMock(return_value=mock_response)

    with patch("ievo.core.cortex.httpx.AsyncClient", return_value=mock_client):
        await download_cortex_release(
            tag="v1.0.0", asset_url="https://x.com/a.tar.gz", root=tmp_path
        )

    # Collect first state
    cortex_dir = tmp_path / ".ievo" / "cortex"

    def _collect(d: Path) -> dict[str, str]:
        return {str(p.relative_to(d)): p.read_text() for p in d.rglob("*") if p.is_file()}

    files_first = _collect(cortex_dir)

    # Reset mock for second call
    mock_client.get = AsyncMock(return_value=mock_response)

    with patch("ievo.core.cortex.httpx.AsyncClient", return_value=mock_client):
        await download_cortex_release(
            tag="v1.0.0", asset_url="https://x.com/a.tar.gz", root=tmp_path
        )

    files_second = _collect(cortex_dir)

    assert files_first == files_second


@pytest.mark.asyncio
async def test_floating_latest(tmp_path: Path) -> None:
    """Second download with newer tag updates cortex-version file."""
    tar_bytes_v1 = make_tar_gz(FAKE_TAR_MEMBERS)
    tar_bytes_v2 = make_tar_gz(FAKE_TAR_MEMBERS)  # same content, different tag

    mock_response_v1 = MagicMock()
    mock_response_v1.status_code = 200
    mock_response_v1.content = tar_bytes_v1
    mock_response_v1.raise_for_status = MagicMock()

    mock_response_v2 = MagicMock()
    mock_response_v2.status_code = 200
    mock_response_v2.content = tar_bytes_v2
    mock_response_v2.raise_for_status = MagicMock()

    mock_client = AsyncMock()
    mock_client.__aenter__ = AsyncMock(return_value=mock_client)
    mock_client.__aexit__ = AsyncMock(return_value=None)

    # First call: v1.0.0
    mock_client.get = AsyncMock(return_value=mock_response_v1)
    with patch("ievo.core.cortex.httpx.AsyncClient", return_value=mock_client):
        await download_cortex_release(
            tag="v1.0.0", asset_url="https://x.com/v1.tar.gz", root=tmp_path
        )

    assert (tmp_path / ".ievo" / "cortex-version").read_text().strip() == "v1.0.0"

    # Second call: v1.1.0
    mock_client.get = AsyncMock(return_value=mock_response_v2)
    with patch("ievo.core.cortex.httpx.AsyncClient", return_value=mock_client):
        await download_cortex_release(
            tag="v1.1.0", asset_url="https://x.com/v2.tar.gz", root=tmp_path
        )

    assert (tmp_path / ".ievo" / "cortex-version").read_text().strip() == "v1.1.0"


# ── get_installed_cortex_version ──────────────────────────────


def test_get_installed_version_reads_file(tmp_path: Path) -> None:
    """Returns version string from .ievo/cortex-version file."""
    ievo_dir = tmp_path / ".ievo"
    ievo_dir.mkdir()
    (ievo_dir / "cortex-version").write_text("v1.0.0\n")

    version = get_installed_cortex_version(tmp_path)
    assert version == "v1.0.0"


def test_get_installed_version_missing(tmp_path: Path) -> None:
    """Returns None when .ievo/cortex-version does not exist."""
    version = get_installed_cortex_version(tmp_path)
    assert version is None


def test_get_installed_version_strips_whitespace(tmp_path: Path) -> None:
    """Strips leading/trailing whitespace from the version string."""
    ievo_dir = tmp_path / ".ievo"
    ievo_dir.mkdir()
    (ievo_dir / "cortex-version").write_text("  v2.3.4  \n")

    version = get_installed_cortex_version(tmp_path)
    assert version == "v2.3.4"
