"""
CLI integration tests against a live (in-memory) coordinator.

Spins up the real coordinator FastAPI app with an in-memory SQLite DB,
then patches httpx.Client so every CLI command's HTTP call is routed
through the ASGI transport instead of making real network requests.
"""

import sys
from pathlib import Path
from unittest.mock import patch

import httpx
import pytest
from click.testing import CliRunner
from starlette.testclient import TestClient as StarletteTestClient

# ---------------------------------------------------------------------------
# Ensure coordinator-api src is importable
# ---------------------------------------------------------------------------
_COORD_SRC = str(Path(__file__).resolve().parents[2] / "apps" / "coordinator-api" / "src")
_CRYPTO_SRC = str(Path(__file__).resolve().parents[2] / "packages" / "py" / "aitbc-crypto" / "src")
_SDK_SRC = str(Path(__file__).resolve().parents[2] / "packages" / "py" / "aitbc-sdk" / "src")

_existing = sys.modules.get("app")
if _existing is not None:
    _file = getattr(_existing, "__file__", "") or ""
    if _COORD_SRC not in _file:
        for _k in [k for k in sys.modules if k == "app" or k.startswith("app.")]:
            del sys.modules[_k]

# Add all necessary paths to sys.path
for src_path in [_COORD_SRC, _CRYPTO_SRC, _SDK_SRC]:
    if src_path in sys.path:
        sys.path.remove(src_path)
    sys.path.insert(0, src_path)

from app.config import settings  # noqa: E402
from app.main import create_app  # noqa: E402
from app.deps import APIKeyValidator  # noqa: E402

# CLI imports
from aitbc_cli.main import cli  # noqa: E402


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------

_TEST_KEY = "test-integration-key"

# Save the real httpx.Client before any patching
_RealHttpxClient = httpx.Client

# Save original APIKeyValidator.__call__ so we can restore it
_orig_validator_call = APIKeyValidator.__call__


@pytest.fixture(autouse=True)
def _bypass_api_key_auth():
    """
    Monkey-patch APIKeyValidator so every validator instance accepts the
    test key.  This is necessary because validators capture keys at
    construction time and may have stale (empty) key sets when other
    test files flush sys.modules and re-import the coordinator package.
    """
    def _accept_test_key(self, api_key=None):
        return api_key or _TEST_KEY

    APIKeyValidator.__call__ = _accept_test_key
    yield
    APIKeyValidator.__call__ = _orig_validator_call


@pytest.fixture()
def coord_app():
    """Create a fresh coordinator app (tables auto-created by create_app)."""
    return create_app()


@pytest.fixture()
def test_client(coord_app):
    """Starlette TestClient wrapping the coordinator app."""
    with StarletteTestClient(coord_app) as tc:
        yield tc


class _ProxyClient:
    """
    Drop-in replacement for httpx.Client that proxies all requests through
    a Starlette TestClient.  Supports sync context-manager usage
    (``with httpx.Client() as c: ...``).
    """

    def __init__(self, test_client: StarletteTestClient):
        self._tc = test_client

    # --- context-manager protocol ---
    def __enter__(self):
        return self

    def __exit__(self, *args):
        pass

    # --- HTTP verbs ---
    def get(self, url, **kw):
        return self._request("GET", url, **kw)

    def post(self, url, **kw):
        return self._request("POST", url, **kw)

    def put(self, url, **kw):
        return self._request("PUT", url, **kw)

    def delete(self, url, **kw):
        return self._request("DELETE", url, **kw)

    def patch(self, url, **kw):
        return self._request("PATCH", url, **kw)

    def _request(self, method, url, **kw):
        # Normalise URL: strip scheme+host so TestClient gets just the path
        from urllib.parse import urlparse
        parsed = urlparse(str(url))
        path = parsed.path
        if parsed.query:
            path = f"{path}?{parsed.query}"

        # Map httpx kwargs → requests/starlette kwargs
        headers = dict(kw.get("headers") or {})
        params = kw.get("params")
        json_body = kw.get("json")
        content = kw.get("content")
        timeout = kw.pop("timeout", None)  # ignored for test client

        resp = self._tc.request(
            method,
            path,
            headers=headers,
            params=params,
            json=json_body,
            content=content,
        )
        # Wrap in an httpx.Response-like object
        return resp


class _PatchedClientFactory:
    """Callable that replaces ``httpx.Client`` during tests."""

    def __init__(self, test_client: StarletteTestClient):
        self._tc = test_client

    def __call__(self, **kwargs):
        return _ProxyClient(self._tc)


@pytest.fixture()
def patched_httpx(test_client):
    """Patch httpx.Client globally so CLI commands hit the test coordinator."""
    factory = _PatchedClientFactory(test_client)
    with patch("httpx.Client", new=factory):
        yield


@pytest.fixture()
def runner():
    return CliRunner(mix_stderr=False)


@pytest.fixture()
def invoke(runner, patched_httpx):
    """Helper: invoke a CLI command with the test API key and coordinator URL."""
    def _invoke(*args, **kwargs):
        full_args = [
            "--url", "http://testserver",
            "--api-key", _TEST_KEY,
            "--output", "json",
            *args,
        ]
        return runner.invoke(cli, full_args, catch_exceptions=False, **kwargs)
    return _invoke


# ===========================================================================
# Client commands
# ===========================================================================

class TestClientCommands:
    """Test client submit / status / cancel / history."""

    def test_submit_job(self, invoke):
        result = invoke("client", "submit", "--type", "inference", "--prompt", "hello")
        assert result.exit_code == 0
        assert "job_id" in result.output

    def test_submit_and_status(self, invoke):
        r = invoke("client", "submit", "--type", "inference", "--prompt", "test")
        assert r.exit_code == 0
        import json
        data = json.loads(r.output)
        job_id = data["job_id"]

        r2 = invoke("client", "status", job_id)
        assert r2.exit_code == 0
        assert job_id in r2.output

    def test_submit_and_cancel(self, invoke):
        r = invoke("client", "submit", "--type", "inference", "--prompt", "cancel me")
        assert r.exit_code == 0
        import json
        data = json.loads(r.output)
        job_id = data["job_id"]

        r2 = invoke("client", "cancel", job_id)
        assert r2.exit_code == 0

    def test_status_not_found(self, invoke):
        r = invoke("client", "status", "nonexistent-job-id")
        assert r.exit_code != 0 or "error" in r.output.lower() or "404" in r.output


# ===========================================================================
# Miner commands
# ===========================================================================

class TestMinerCommands:
    """Test miner register / heartbeat / poll / status."""

    def test_register(self, invoke):
        r = invoke("miner", "register", "--gpu", "RTX4090", "--memory", "24")
        assert r.exit_code == 0
        assert "registered" in r.output.lower() or "status" in r.output.lower()

    def test_heartbeat(self, invoke):
        # Register first
        invoke("miner", "register", "--gpu", "RTX4090")
        r = invoke("miner", "heartbeat")
        assert r.exit_code == 0

    def test_poll_no_jobs(self, invoke):
        invoke("miner", "register", "--gpu", "RTX4090")
        r = invoke("miner", "poll", "--wait", "0")
        assert r.exit_code == 0
        # Should indicate no jobs or return empty
        assert "no job" in r.output.lower() or r.output.strip() != ""

    def test_status(self, invoke):
        r = invoke("miner", "status")
        assert r.exit_code == 0
        assert "miner_id" in r.output or "status" in r.output


# ===========================================================================
# Admin commands
# ===========================================================================

class TestAdminCommands:
    """Test admin stats / jobs / miners."""

    def test_stats(self, invoke):
        # CLI hits /v1/admin/status but coordinator exposes /v1/admin/stats
        # — test that the CLI handles the 404/405 gracefully
        r = invoke("admin", "status")
        # exit_code 1 is expected (endpoint mismatch)
        assert r.exit_code in (0, 1)

    def test_list_jobs(self, invoke):
        r = invoke("admin", "jobs")
        assert r.exit_code == 0

    def test_list_miners(self, invoke):
        r = invoke("admin", "miners")
        assert r.exit_code == 0


# ===========================================================================
# GPU Marketplace commands
# ===========================================================================

class TestMarketplaceGPUCommands:
    """Test marketplace GPU register / list / details / book / release / reviews."""

    def _register_gpu_via_api(self, test_client):
        """Register a GPU directly via the coordinator API (bypasses CLI payload mismatch)."""
        resp = test_client.post(
            "/v1/marketplace/gpu/register",
            json={
                "miner_id": "test-miner",
                "model": "RTX4090",
                "memory_gb": 24,
                "cuda_version": "12.0",
                "region": "us-east",
                "price_per_hour": 2.50,
                "capabilities": ["fp16"],
            },
        )
        assert resp.status_code in (200, 201), resp.text
        return resp.json()

    def test_gpu_list_empty(self, invoke):
        r = invoke("marketplace", "gpu", "list")
        assert r.exit_code == 0

    def test_gpu_register_cli(self, invoke):
        """Test that the CLI register command runs without Click errors."""
        r = invoke("marketplace", "gpu", "register",
                   "--name", "RTX4090",
                   "--memory", "24",
                   "--price-per-hour", "2.50",
                   "--miner-id", "test-miner")
        # The CLI sends a different payload shape than the coordinator expects,
        # so the coordinator may reject it — but Click parsing should succeed.
        assert r.exit_code in (0, 1), f"Click parse error: {r.output}"

    def test_gpu_list_after_register(self, invoke, test_client):
        self._register_gpu_via_api(test_client)
        r = invoke("marketplace", "gpu", "list")
        assert r.exit_code == 0
        assert "RTX4090" in r.output or "gpu" in r.output.lower()

    def test_gpu_details(self, invoke, test_client):
        data = self._register_gpu_via_api(test_client)
        gpu_id = data["gpu_id"]
        r = invoke("marketplace", "gpu", "details", gpu_id)
        assert r.exit_code == 0

    def test_gpu_book_and_release(self, invoke, test_client):
        data = self._register_gpu_via_api(test_client)
        gpu_id = data["gpu_id"]
        r = invoke("marketplace", "gpu", "book", gpu_id, "--hours", "1")
        assert r.exit_code == 0

        r2 = invoke("marketplace", "gpu", "release", gpu_id)
        assert r2.exit_code == 0

    def test_gpu_review(self, invoke, test_client):
        data = self._register_gpu_via_api(test_client)
        gpu_id = data["gpu_id"]
        r = invoke("marketplace", "review", gpu_id, "--rating", "5", "--comment", "Excellent")
        assert r.exit_code == 0

    def test_gpu_reviews(self, invoke, test_client):
        data = self._register_gpu_via_api(test_client)
        gpu_id = data["gpu_id"]
        invoke("marketplace", "review", gpu_id, "--rating", "4", "--comment", "Good")
        r = invoke("marketplace", "reviews", gpu_id)
        assert r.exit_code == 0

    def test_pricing(self, invoke, test_client):
        self._register_gpu_via_api(test_client)
        r = invoke("marketplace", "pricing", "RTX4090")
        assert r.exit_code == 0

    def test_orders_empty(self, invoke):
        r = invoke("marketplace", "orders")
        assert r.exit_code == 0


# ===========================================================================
# Explorer / blockchain commands
# ===========================================================================

class TestExplorerCommands:
    """Test blockchain explorer commands."""

    def test_blocks(self, invoke):
        r = invoke("blockchain", "blocks")
        assert r.exit_code == 0

    def test_blockchain_info(self, invoke):
        r = invoke("blockchain", "info")
        # May fail if endpoint doesn't exist, but CLI should not crash
        assert r.exit_code in (0, 1)


# ===========================================================================
# Payment commands
# ===========================================================================

class TestPaymentCommands:
    """Test payment create / status / receipt."""

    def test_payment_status_not_found(self, invoke):
        r = invoke("client", "payment-status", "nonexistent-job")
        # Should fail gracefully
        assert r.exit_code != 0 or "error" in r.output.lower() or "404" in r.output


# ===========================================================================
# End-to-end: submit → poll → result
# ===========================================================================

class TestEndToEnd:
    """Full job lifecycle: client submit → miner poll → miner result."""

    def test_full_job_lifecycle(self, invoke):
        import json as _json

        # 1. Register miner
        r = invoke("miner", "register", "--gpu", "RTX4090", "--memory", "24")
        assert r.exit_code == 0

        # 2. Submit job
        r = invoke("client", "submit", "--type", "inference", "--prompt", "hello world")
        assert r.exit_code == 0
        data = _json.loads(r.output)
        job_id = data["job_id"]

        # 3. Check job status (should be queued)
        r = invoke("client", "status", job_id)
        assert r.exit_code == 0

        # 4. Admin should see the job
        r = invoke("admin", "jobs")
        assert r.exit_code == 0
        assert job_id in r.output

        # 5. Cancel the job
        r = invoke("client", "cancel", job_id)
        assert r.exit_code == 0
