"""Tests for CLI authentication, token management, and push functionality."""

from __future__ import annotations

import json
import os
import tempfile
from unittest.mock import MagicMock, patch

import pytest
from click.testing import CliRunner

from orbit.cli import main, _credentials_path, _save_credentials, _load_credentials, _remove_credentials


# ---------------------------------------------------------------------------
# CLI credential helpers
# ---------------------------------------------------------------------------


class TestCredentials:
    """Test credential file read/write/delete."""

    def test_save_and_load(self, tmp_path, monkeypatch):
        creds_file = str(tmp_path / "credentials.json")
        monkeypatch.setattr("orbit.cli._credentials_path", lambda: creds_file)

        _save_credentials("orbit_cli_abc123", "https://api.example.com")
        creds = _load_credentials()
        assert creds is not None
        assert creds["token"] == "orbit_cli_abc123"
        assert creds["api_url"] == "https://api.example.com"

        # Check file permissions (owner read/write only)
        mode = os.stat(creds_file).st_mode & 0o777
        assert mode == 0o600

    def test_load_missing(self, tmp_path, monkeypatch):
        monkeypatch.setattr("orbit.cli._credentials_path", lambda: str(tmp_path / "nope.json"))
        assert _load_credentials() is None

    def test_remove(self, tmp_path, monkeypatch):
        creds_file = str(tmp_path / "credentials.json")
        monkeypatch.setattr("orbit.cli._credentials_path", lambda: creds_file)

        _save_credentials("tok", "https://api.example.com")
        assert os.path.exists(creds_file)

        _remove_credentials()
        assert not os.path.exists(creds_file)

    def test_remove_missing(self, tmp_path, monkeypatch):
        monkeypatch.setattr("orbit.cli._credentials_path", lambda: str(tmp_path / "nope.json"))
        _remove_credentials()  # should not raise


# ---------------------------------------------------------------------------
# orbit login
# ---------------------------------------------------------------------------


class TestLogin:
    def test_login_success(self, tmp_path, monkeypatch):
        creds_file = str(tmp_path / "credentials.json")
        monkeypatch.setattr("orbit.cli._credentials_path", lambda: creds_file)

        mock_resp = MagicMock()
        mock_resp.status_code = 200
        mock_resp.json.return_value = {"hf_username": "testuser", "id": 1}

        with patch("httpx.get", return_value=mock_resp):
            runner = CliRunner()
            result = runner.invoke(main, ["login", "--token", "orbit_cli_test123", "--api-url", "http://localhost:8080"])

        assert result.exit_code == 0
        assert "@testuser" in result.output
        assert "synced" in result.output.lower()

        creds = json.loads(open(creds_file).read())
        assert creds["token"] == "orbit_cli_test123"

    def test_login_invalid_token(self, tmp_path, monkeypatch):
        creds_file = str(tmp_path / "credentials.json")
        monkeypatch.setattr("orbit.cli._credentials_path", lambda: creds_file)

        mock_resp = MagicMock()
        mock_resp.status_code = 401

        with patch("httpx.get", return_value=mock_resp):
            runner = CliRunner()
            result = runner.invoke(main, ["login", "--token", "bad_token", "--api-url", "http://localhost:8080"])

        assert result.exit_code != 0
        assert "invalid" in result.output.lower() or "error" in result.output.lower()

    def test_login_network_error(self, tmp_path, monkeypatch):
        monkeypatch.setattr("orbit.cli._credentials_path", lambda: str(tmp_path / "creds.json"))

        import httpx
        with patch("httpx.get", side_effect=httpx.ConnectError("Connection refused")):
            runner = CliRunner()
            result = runner.invoke(main, ["login", "--token", "tok", "--api-url", "http://localhost:9999"])

        assert result.exit_code != 0
        assert "error" in result.output.lower()


# ---------------------------------------------------------------------------
# orbit whoami
# ---------------------------------------------------------------------------


class TestWhoami:
    def test_whoami_logged_in(self, tmp_path, monkeypatch):
        creds_file = str(tmp_path / "credentials.json")
        monkeypatch.setattr("orbit.cli._credentials_path", lambda: creds_file)
        _save_credentials("orbit_cli_tok", "http://localhost:8080")

        mock_resp = MagicMock()
        mock_resp.status_code = 200
        mock_resp.json.return_value = {"hf_username": "alice", "id": 42}

        with patch("httpx.get", return_value=mock_resp):
            runner = CliRunner()
            result = runner.invoke(main, ["whoami"])

        assert result.exit_code == 0
        assert "@alice" in result.output

    def test_whoami_not_logged_in(self, tmp_path, monkeypatch):
        monkeypatch.setattr("orbit.cli._credentials_path", lambda: str(tmp_path / "nope.json"))

        runner = CliRunner()
        result = runner.invoke(main, ["whoami"])

        assert result.exit_code != 0
        assert "not logged in" in result.output.lower()

    def test_whoami_expired_token(self, tmp_path, monkeypatch):
        creds_file = str(tmp_path / "credentials.json")
        monkeypatch.setattr("orbit.cli._credentials_path", lambda: creds_file)
        _save_credentials("orbit_cli_expired", "http://localhost:8080")

        mock_resp = MagicMock()
        mock_resp.status_code = 401

        with patch("httpx.get", return_value=mock_resp):
            runner = CliRunner()
            result = runner.invoke(main, ["whoami"])

        assert result.exit_code != 0
        assert "invalid" in result.output.lower() or "expired" in result.output.lower()


# ---------------------------------------------------------------------------
# orbit logout
# ---------------------------------------------------------------------------


class TestLogout:
    def test_logout(self, tmp_path, monkeypatch):
        creds_file = str(tmp_path / "credentials.json")
        monkeypatch.setattr("orbit.cli._credentials_path", lambda: creds_file)
        _save_credentials("orbit_cli_tok", "http://localhost:8080")

        runner = CliRunner()
        result = runner.invoke(main, ["logout"])

        assert result.exit_code == 0
        assert "logged out" in result.output.lower()
        assert not os.path.exists(creds_file)


# ---------------------------------------------------------------------------
# orbit push
# ---------------------------------------------------------------------------


class TestPush:
    def test_push_success(self, tmp_path, monkeypatch):
        creds_file = str(tmp_path / "credentials.json")
        monkeypatch.setattr("orbit.cli._credentials_path", lambda: creds_file)
        _save_credentials("orbit_cli_tok", "http://localhost:8080")

        results_file = str(tmp_path / "results.json")
        with open(results_file, "w") as f:
            json.dump({
                "dataset_repo": "lerobot/pusht",
                "quality_score": 0.72,
                "estimated_success_rate": 0.65,
            }, f)

        mock_resp = MagicMock()
        mock_resp.status_code = 201
        mock_resp.json.return_value = {
            "run_id": 1,
            "project_id": 10,
            "url": "https://app.orbit-robotics.com/projects/10",
        }

        with patch("httpx.post", return_value=mock_resp):
            runner = CliRunner()
            result = runner.invoke(main, ["push", results_file])

        assert result.exit_code == 0
        assert "synced" in result.output.lower()

    def test_push_not_logged_in(self, tmp_path, monkeypatch):
        monkeypatch.setattr("orbit.cli._credentials_path", lambda: str(tmp_path / "nope.json"))

        results_file = str(tmp_path / "results.json")
        with open(results_file, "w") as f:
            json.dump({"dataset_repo": "x/y"}, f)

        runner = CliRunner()
        result = runner.invoke(main, ["push", results_file])

        assert result.exit_code != 0
        assert "not logged in" in result.output.lower()

    def test_push_bad_file(self, tmp_path, monkeypatch):
        creds_file = str(tmp_path / "credentials.json")
        monkeypatch.setattr("orbit.cli._credentials_path", lambda: creds_file)
        _save_credentials("orbit_cli_tok", "http://localhost:8080")

        runner = CliRunner()
        result = runner.invoke(main, ["push", "/nonexistent/file.json"])

        assert result.exit_code != 0
        assert "error" in result.output.lower()

    def test_push_with_project_id(self, tmp_path, monkeypatch):
        creds_file = str(tmp_path / "credentials.json")
        monkeypatch.setattr("orbit.cli._credentials_path", lambda: creds_file)
        _save_credentials("orbit_cli_tok", "http://localhost:8080")

        results_file = str(tmp_path / "results.json")
        with open(results_file, "w") as f:
            json.dump({"dataset_repo": "lerobot/pusht", "quality_score": 0.8}, f)

        mock_resp = MagicMock()
        mock_resp.status_code = 201
        mock_resp.json.return_value = {"run_id": 2, "project_id": 5, "url": "https://app.orbit-robotics.com/projects/5"}

        with patch("httpx.post", return_value=mock_resp) as mock_post:
            runner = CliRunner()
            result = runner.invoke(main, ["push", results_file, "--project", "5"])

        assert result.exit_code == 0
        # Verify project_id was sent in payload
        call_kwargs = mock_post.call_args
        payload = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json")
        assert payload["project_id"] == 5


# ---------------------------------------------------------------------------
# orbit analyze post-run push prompt
# ---------------------------------------------------------------------------


class TestAnalyzePostPush:
    def test_analyze_offers_push_when_logged_in(self, tmp_path, monkeypatch):
        """Verify that _maybe_push_results calls the API when user confirms."""
        creds_file = str(tmp_path / "credentials.json")
        monkeypatch.setattr("orbit.cli._credentials_path", lambda: creds_file)
        _save_credentials("orbit_cli_tok", "http://localhost:8080")

        mock_resp = MagicMock()
        mock_resp.status_code = 201
        mock_resp.json.return_value = {"run_id": 1, "project_id": 1, "url": "https://app.orbit-robotics.com/projects/1"}

        from orbit.cli import _maybe_push_results

        with patch("httpx.post", return_value=mock_resp) as mock_post, \
             patch("click.confirm", return_value=True), \
             patch("orbit.cli._full_report_to_dict", return_value={"dataset_repo": "x/y", "quality_score": 0.5}):
            _maybe_push_results("x/y", None, None, None, None, None, None, None, None, None, None, None, None)

        mock_post.assert_called_once()

    def test_analyze_no_push_when_not_logged_in(self, tmp_path, monkeypatch):
        monkeypatch.setattr("orbit.cli._credentials_path", lambda: str(tmp_path / "nope.json"))

        from orbit.cli import _maybe_push_results

        with patch("httpx.post") as mock_post:
            _maybe_push_results("x/y", None, None, None, None, None, None, None, None, None, None, None, None)

        mock_post.assert_not_called()


# ---------------------------------------------------------------------------
# Backend tests (using httpx.AsyncClient + FastAPI TestClient)
# ---------------------------------------------------------------------------

try:
    from httpx import AsyncClient, ASGITransport
    from app.main import app
    from app.database import engine, get_db, async_session
    from app.models.base import Base

    HAS_BACKEND = True
except ImportError:
    HAS_BACKEND = False


@pytest.fixture
async def async_db():
    """Create fresh tables for each test."""
    if not HAS_BACKEND:
        pytest.skip("Backend dependencies not available")

    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)
    async with async_session() as session:
        yield session
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.drop_all)


@pytest.fixture
async def client():
    """Async test client."""
    if not HAS_BACKEND:
        pytest.skip("Backend dependencies not available")

    transport = ASGITransport(app=app)
    async with AsyncClient(transport=transport, base_url="http://test") as c:
        yield c


@pytest.fixture
async def auth_headers(client, async_db):
    """Create a test user and return auth headers with a session JWT."""
    if not HAS_BACKEND:
        pytest.skip("Backend dependencies not available")

    from app.models.user import User
    from app.routers.auth import create_jwt

    user = User(hf_username="testbot", email="test@orbit.dev")
    async_db.add(user)
    await async_db.commit()
    await async_db.refresh(user)

    token = create_jwt(user.id)
    return {"Authorization": f"Bearer {token}"}


@pytest.mark.skipif(not HAS_BACKEND, reason="Backend deps not available")
class TestBackendCLITokens:
    @pytest.mark.asyncio
    async def test_create_cli_token(self, client, auth_headers):
        resp = await client.post("/auth/cli-token", headers=auth_headers)
        assert resp.status_code == 201
        data = resp.json()
        assert data["token"].startswith("orbit_cli_")
        assert "expires_at" in data

    @pytest.mark.asyncio
    async def test_cli_token_works_for_auth(self, client, auth_headers):
        # Generate CLI token
        resp = await client.post("/auth/cli-token", headers=auth_headers)
        cli_token = resp.json()["token"]

        # Use CLI token to call /auth/me
        resp = await client.get("/auth/me", headers={"Authorization": f"Bearer {cli_token}"})
        assert resp.status_code == 200
        assert resp.json()["hf_username"] == "testbot"

    @pytest.mark.asyncio
    async def test_list_cli_tokens(self, client, auth_headers):
        # Create 2 tokens
        await client.post("/auth/cli-token", headers=auth_headers)
        await client.post("/auth/cli-token", headers=auth_headers)

        resp = await client.get("/auth/cli-tokens", headers=auth_headers)
        assert resp.status_code == 200
        tokens = resp.json()
        assert len(tokens) == 2
        # Should show suffix, not full token
        for t in tokens:
            assert len(t["token_suffix"]) == 8
            assert "created_at" in t

    @pytest.mark.asyncio
    async def test_max_3_tokens(self, client, auth_headers):
        for _ in range(3):
            resp = await client.post("/auth/cli-token", headers=auth_headers)
            assert resp.status_code == 201

        resp = await client.post("/auth/cli-token", headers=auth_headers)
        assert resp.status_code == 400
        assert "Maximum" in resp.json()["detail"]

    @pytest.mark.asyncio
    async def test_revoke_token(self, client, auth_headers):
        resp = await client.post("/auth/cli-token", headers=auth_headers)
        cli_token = resp.json()["token"]

        # List to get ID
        resp = await client.get("/auth/cli-tokens", headers=auth_headers)
        token_id = resp.json()[0]["id"]

        # Revoke
        resp = await client.delete(f"/auth/cli-token/{token_id}", headers=auth_headers)
        assert resp.status_code == 204

        # Revoked token should no longer work
        resp = await client.get("/auth/me", headers={"Authorization": f"Bearer {cli_token}"})
        assert resp.status_code == 401

    @pytest.mark.asyncio
    async def test_revoke_allows_new_token(self, client, auth_headers):
        """After revoking, user can create a new token (under max 3)."""
        tokens = []
        for _ in range(3):
            resp = await client.post("/auth/cli-token", headers=auth_headers)
            tokens.append(resp.json())

        # Get ID of first token
        resp = await client.get("/auth/cli-tokens", headers=auth_headers)
        first_id = resp.json()[-1]["id"]  # oldest is last (desc order)

        # Revoke it
        await client.delete(f"/auth/cli-token/{first_id}", headers=auth_headers)

        # Should be able to create a new one
        resp = await client.post("/auth/cli-token", headers=auth_headers)
        assert resp.status_code == 201


@pytest.mark.skipif(not HAS_BACKEND, reason="Backend deps not available")
class TestBackendAnalysisUpload:
    @pytest.mark.asyncio
    async def test_upload_analysis(self, client, auth_headers):
        payload = {
            "dataset_repo": "lerobot/pusht",
            "quality_score": 0.72,
            "estimated_success_rate": 0.65,
            "coverage_results": {"position_diversity": 0.8},
            "recommendations": {"items": ["collect more episodes"]},
        }
        resp = await client.post("/analysis/upload", json=payload, headers=auth_headers)
        assert resp.status_code == 201
        data = resp.json()
        assert data["run_id"] > 0
        assert data["project_id"] > 0
        assert "url" in data

    @pytest.mark.asyncio
    async def test_upload_creates_project(self, client, auth_headers):
        """Uploading to a new dataset_repo auto-creates a project."""
        payload = {"dataset_repo": "myuser/new_dataset", "quality_score": 0.5}
        resp = await client.post("/analysis/upload", json=payload, headers=auth_headers)
        assert resp.status_code == 201

    @pytest.mark.asyncio
    async def test_upload_requires_auth(self, client):
        payload = {"dataset_repo": "lerobot/pusht"}
        resp = await client.post("/analysis/upload", json=payload)
        assert resp.status_code == 401
