"""End-to-end tests for the Nogic CLI.

These tests exercise every command as a real user would invoke them,
using Click's CliRunner for isolated subprocess-like testing and
real temp directories for filesystem operations.
"""

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

import pytest
from typer.testing import CliRunner

from nogic.main import app


@pytest.fixture
def runner():
    """Typer CLI test runner."""
    return CliRunner()


@pytest.fixture
def temp_project(tmp_path):
    """Create a temp directory with sample source files for testing."""
    # Create a realistic project structure
    (tmp_path / "main.py").write_text("def hello():\n    print('hello')\n", encoding="utf-8")
    (tmp_path / "utils.py").write_text("def add(a, b):\n    return a + b\n", encoding="utf-8")
    (tmp_path / "app.js").write_text("function greet() { return 'hi'; }\n", encoding="utf-8")
    sub = tmp_path / "lib"
    sub.mkdir()
    (sub / "helper.py").write_text("class Helper:\n    pass\n", encoding="utf-8")
    (sub / "index.ts").write_text("export const VERSION = '1.0';\n", encoding="utf-8")
    # Create a .gitignore
    (tmp_path / ".gitignore").write_text("*.pyc\n__pycache__/\n.env\n", encoding="utf-8")
    return tmp_path


@pytest.fixture
def nogic_project(temp_project):
    """Create a temp project with .nogic directory (already initialized)."""
    nogic_dir = temp_project / ".nogic"
    nogic_dir.mkdir()
    config = {
        "project_id": "test-project-12345678",
        "project_name": "test-project",
    }
    (nogic_dir / "config.json").write_text(json.dumps(config), encoding="utf-8")
    return temp_project


@pytest.fixture
def global_config(tmp_path):
    """Create a fake global config dir with API key."""
    config_dir = tmp_path / ".nogic"
    config_dir.mkdir()
    config = {"api_key": "test-api-key-abc123"}
    (config_dir / "config.json").write_text(json.dumps(config), encoding="utf-8")
    return config_dir


# ============================================================
# 1. VERSION & HELP
# ============================================================


class TestVersionAndHelp:
    def test_version(self, runner):
        result = runner.invoke(app, ["--version"])
        assert result.exit_code == 0
        assert "0.1.0" in result.output

    def test_help(self, runner):
        result = runner.invoke(app, ["--help"])
        assert result.exit_code == 0
        assert "Nogic CLI" in result.output
        # Verify all 8 commands are listed
        for cmd in ["init", "login", "projects", "reindex", "status", "sync", "telemetry", "watch"]:
            assert cmd in result.output, f"Command '{cmd}' not found in --help output"

    def test_init_help(self, runner):
        result = runner.invoke(app, ["init", "--help"])
        assert result.exit_code == 0
        assert "Initialize" in result.output

    def test_login_help(self, runner):
        result = runner.invoke(app, ["login", "--help"])
        assert result.exit_code == 0
        assert "Authenticate" in result.output

    def test_sync_help(self, runner):
        result = runner.invoke(app, ["sync", "--help"])
        assert result.exit_code == 0
        assert "sync" in result.output.lower()

    def test_watch_help(self, runner):
        result = runner.invoke(app, ["watch", "--help"])
        assert result.exit_code == 0
        assert "Watch" in result.output

    def test_reindex_help(self, runner):
        result = runner.invoke(app, ["reindex", "--help"])
        assert result.exit_code == 0
        assert "re-index" in result.output.lower()

    def test_status_help(self, runner):
        result = runner.invoke(app, ["status", "--help"])
        assert result.exit_code == 0
        assert "status" in result.output.lower()

    def test_projects_help(self, runner):
        result = runner.invoke(app, ["projects", "--help"])
        assert result.exit_code == 0
        assert "projects" in result.output.lower()

    def test_telemetry_help(self, runner):
        result = runner.invoke(app, ["telemetry", "--help"])
        assert result.exit_code == 0
        assert "telemetry" in result.output.lower()

    def test_unknown_command(self, runner):
        result = runner.invoke(app, ["nonexistent"])
        assert result.exit_code != 0


# ============================================================
# 2. INIT COMMAND
# ============================================================


class TestInitCommand:
    @patch("nogic.commands.init.NogicClient")
    @patch("nogic.commands.init.Config")
    def test_init_creates_nogic_dir(self, mock_config_cls, mock_client_cls, runner, temp_project):
        """Init creates .nogic directory and config.json."""
        mock_config = MagicMock()
        mock_config.api_key = "test-key"
        mock_config.api_url = "https://api.nogic.dev"
        mock_config.project_id = None
        mock_config.project_name = None
        mock_config.directory_hash = None
        mock_config_cls.load.return_value = mock_config

        mock_client = MagicMock()
        mock_project = MagicMock()
        mock_project.id = "proj-abc123"
        mock_project.name = "test-project"
        mock_client.create_project.return_value = mock_project
        mock_client.list_projects.return_value = []
        # get_project_by_directory returns None => no existing project found
        mock_client.get_project_by_directory.return_value = None
        mock_client_cls.return_value = mock_client

        result = runner.invoke(app, ["init", "--name", "test-project", str(temp_project)])
        assert result.exit_code == 0, f"Exit code: {result.exit_code}, Output: {result.output}"
        assert (temp_project / ".nogic").exists()

    def test_init_no_auth_shows_error(self, runner, temp_project):
        """Init without API key should show error about login."""
        with patch.dict(os.environ, {"NOGIC_API_KEY": ""}, clear=False):
            with patch("nogic.commands.init.Config") as mock_config_cls:
                mock_config = MagicMock()
                mock_config.api_key = None
                mock_config.project_id = None
                mock_config_cls.load.return_value = mock_config

                result = runner.invoke(app, ["init", str(temp_project)])
                assert result.exit_code != 0
                assert "login" in result.output.lower() or "not logged in" in result.output.lower()


# ============================================================
# 3. LOGIN COMMAND
# ============================================================


class TestLoginCommand:
    @patch("nogic.commands.login.NogicClient")
    @patch("nogic.commands.login.Config")
    @patch("nogic.commands.login.telemetry")
    def test_login_success(self, mock_telemetry, mock_config_cls, mock_client_cls, runner):
        """Successful login stores API key."""
        mock_config = MagicMock()
        mock_config.api_key = None
        mock_config_cls.load.return_value = mock_config

        mock_client = MagicMock()
        mock_client.verify_key.return_value = {"user_id": "user-123", "email": "test@test.com"}
        mock_client_cls.return_value = mock_client

        result = runner.invoke(app, ["login", "--api-key", "test-key-12345"])
        assert result.exit_code == 0
        mock_config.save_global.assert_called_once()
        mock_telemetry.capture.assert_called_once()

    @patch("nogic.commands.login.NogicClient")
    @patch("nogic.commands.login.Config")
    def test_login_invalid_key(self, mock_config_cls, mock_client_cls, runner):
        """Invalid API key shows error."""
        import httpx

        mock_config = MagicMock()
        mock_config.api_key = None
        mock_config_cls.load.return_value = mock_config

        mock_client = MagicMock()
        mock_response = MagicMock()
        mock_response.status_code = 401
        mock_client.verify_key.side_effect = httpx.HTTPStatusError(
            "Unauthorized", request=MagicMock(), response=mock_response
        )
        mock_client_cls.return_value = mock_client

        result = runner.invoke(app, ["login", "--api-key", "bad-key"])
        assert result.exit_code != 0


# ============================================================
# 4. STATUS COMMAND
# ============================================================


class TestStatusCommand:
    def test_status_no_project(self, runner, temp_project):
        """Status in non-initialized directory shows error."""
        result = runner.invoke(app, ["status", str(temp_project)])
        assert result.exit_code != 0 or "not" in result.output.lower()

    @patch("nogic.commands.status.Config")
    def test_status_with_project(self, mock_config_cls, runner, nogic_project):
        """Status in initialized directory shows project info."""
        mock_config = MagicMock()
        mock_config.api_key = "test-key"
        mock_config.api_url = "https://api.nogic.dev"
        mock_config.project_id = "test-project-12345678"
        mock_config.project_name = "test-project"
        mock_config.directory_hash = "abc123"
        mock_config_cls.load.return_value = mock_config

        result = runner.invoke(app, ["status", str(nogic_project)])
        assert result.exit_code == 0
        assert "test-project" in result.output or "test-proj" in result.output


# ============================================================
# 5. SYNC COMMAND
# ============================================================


class TestSyncCommand:
    def test_sync_no_project(self, runner, temp_project):
        """Sync without init shows error."""
        result = runner.invoke(app, ["sync", str(temp_project)])
        assert result.exit_code != 0
        assert "init" in result.output.lower()

    @patch("nogic.commands.sync.SyncService")
    @patch("nogic.commands.sync.Config")
    def test_sync_no_auth(self, mock_config_cls, mock_sync_cls, runner, nogic_project):
        """Sync without API key shows login error."""
        mock_config = MagicMock()
        mock_config.api_key = None
        mock_config.project_id = "test-id"
        mock_config_cls.load.return_value = mock_config

        result = runner.invoke(app, ["sync", str(nogic_project)])
        assert result.exit_code != 0
        assert "login" in result.output.lower() or "not logged in" in result.output.lower()

    @patch("nogic.commands.sync.telemetry")
    @patch("nogic.commands.sync.SyncService")
    @patch("nogic.commands.sync.Config")
    def test_sync_success(self, mock_config_cls, mock_sync_cls, mock_telemetry, runner, nogic_project):
        """Successful sync calls SyncService.initial_scan."""
        mock_config = MagicMock()
        mock_config.api_key = "test-key"
        mock_config.api_url = "https://api.nogic.dev"
        mock_config.project_id = "test-id"
        mock_config_cls.load.return_value = mock_config

        mock_service = MagicMock()
        mock_sync_cls.return_value = mock_service

        result = runner.invoke(app, ["sync", str(nogic_project)])
        assert result.exit_code == 0
        mock_service.initial_scan.assert_called_once()
        mock_service.close.assert_called_once()


# ============================================================
# 6. WATCH COMMAND
# ============================================================


class TestWatchCommand:
    def test_watch_no_project(self, runner, temp_project):
        """Watch without init shows error."""
        result = runner.invoke(app, ["watch", str(temp_project)])
        assert result.exit_code != 0
        assert "init" in result.output.lower()

    @patch("nogic.commands.watch.Config")
    def test_watch_no_auth(self, mock_config_cls, runner, nogic_project):
        """Watch without API key shows login error."""
        mock_config = MagicMock()
        mock_config.api_key = None
        mock_config.project_id = "test-id"
        mock_config_cls.load.return_value = mock_config

        result = runner.invoke(app, ["watch", str(nogic_project)])
        assert result.exit_code != 0
        assert "login" in result.output.lower() or "not logged in" in result.output.lower()


# ============================================================
# 7. REINDEX COMMAND
# ============================================================


class TestReindexCommand:
    def test_reindex_no_project(self, runner, temp_project):
        """Reindex without init shows error."""
        result = runner.invoke(app, ["reindex", str(temp_project)])
        assert result.exit_code != 0
        assert "init" in result.output.lower()

    @patch("nogic.commands.reindex.Config")
    def test_reindex_no_auth(self, mock_config_cls, runner, nogic_project):
        """Reindex without API key shows login error."""
        mock_config = MagicMock()
        mock_config.api_key = None
        mock_config.project_id = "test-id"
        mock_config_cls.load.return_value = mock_config

        result = runner.invoke(app, ["reindex", str(nogic_project)])
        assert result.exit_code != 0

    @patch("nogic.commands.reindex.SyncService")
    @patch("nogic.commands.reindex.NogicClient")
    @patch("nogic.commands.reindex.Config")
    def test_reindex_with_yes_flag(self, mock_config_cls, mock_client_cls, mock_sync_cls, runner, nogic_project):
        """Reindex with --yes wipes graph and re-indexes."""
        mock_config = MagicMock()
        mock_config.api_key = "test-key"
        mock_config.api_url = "https://api.nogic.dev"
        mock_config.project_id = "test-id"
        mock_config.project_name = "test-project"
        mock_config_cls.load.return_value = mock_config

        mock_client = MagicMock()
        mock_wipe_result = MagicMock()
        mock_wipe_result.nodes_deleted = 42
        mock_client.wipe_project_graph.return_value = mock_wipe_result
        mock_client_cls.return_value = mock_client

        mock_service = MagicMock()
        mock_sync_cls.return_value = mock_service

        result = runner.invoke(app, ["reindex", "--yes", str(nogic_project)])
        assert result.exit_code == 0, f"Exit code: {result.exit_code}, Output: {result.output}"
        mock_client.wipe_project_graph.assert_called_once()
        mock_service.initial_scan.assert_called_once()


# ============================================================
# 8. PROJECTS COMMAND
# ============================================================


class TestProjectsCommand:
    @patch("nogic.commands.projects.Config")
    def test_projects_list_no_auth(self, mock_config_cls, runner):
        """Projects list without auth shows error."""
        mock_config = MagicMock()
        mock_config.api_key = None
        mock_config_cls.load.return_value = mock_config

        result = runner.invoke(app, ["projects", "list"])
        assert result.exit_code != 0
        assert "login" in result.output.lower() or "not logged in" in result.output.lower()

    @patch("nogic.commands.projects.NogicClient")
    @patch("nogic.commands.projects.Config")
    def test_projects_list_empty(self, mock_config_cls, mock_client_cls, runner):
        """Projects list with no projects shows empty message."""
        mock_config = MagicMock()
        mock_config.api_key = "test-key"
        mock_config.project_id = None
        mock_config_cls.load.return_value = mock_config

        mock_client = MagicMock()
        mock_client.list_projects.return_value = []
        mock_client_cls.return_value = mock_client

        result = runner.invoke(app, ["projects", "list"])
        assert result.exit_code == 0
        assert "no project" in result.output.lower()

    @patch("nogic.commands.projects.NogicClient")
    @patch("nogic.commands.projects.Config")
    def test_projects_list_with_projects(self, mock_config_cls, mock_client_cls, runner):
        """Projects list shows project names and IDs."""
        mock_config = MagicMock()
        mock_config.api_key = "test-key"
        mock_config.project_id = "proj-111"
        mock_config_cls.load.return_value = mock_config

        mock_project = MagicMock()
        mock_project.id = "proj-111-full-id"
        mock_project.name = "my-project"
        mock_project.directory_hash = None
        mock_project.created_at = None

        mock_client = MagicMock()
        mock_client.list_projects.return_value = [mock_project]
        mock_client_cls.return_value = mock_client

        result = runner.invoke(app, ["projects", "list"])
        assert result.exit_code == 0
        assert "my-project" in result.output


# ============================================================
# 9. TELEMETRY COMMAND
# ============================================================


class TestTelemetryCommand:
    def test_telemetry_status(self, runner, tmp_path):
        """Telemetry status shows current state."""
        config_dir = tmp_path / ".nogic"
        config_dir.mkdir()
        (config_dir / "config.json").write_text('{"telemetry_enabled": true}', encoding="utf-8")

        with patch("nogic.commands.telemetry_cmd.GLOBAL_CONFIG_DIR", config_dir):
            result = runner.invoke(app, ["telemetry", "status"])
            assert result.exit_code == 0
            assert "enabled" in result.output.lower()

    def test_telemetry_disable(self, runner, tmp_path):
        """Telemetry disable writes config."""
        config_dir = tmp_path / ".nogic"
        config_dir.mkdir()
        (config_dir / "config.json").write_text('{}', encoding="utf-8")

        with patch("nogic.commands.telemetry_cmd.GLOBAL_CONFIG_DIR", config_dir):
            result = runner.invoke(app, ["telemetry", "disable"])
            assert result.exit_code == 0
            assert "disabled" in result.output.lower()

            # Verify config was updated
            data = json.loads((config_dir / "config.json").read_text(encoding="utf-8"))
            assert data["telemetry_enabled"] is False

    def test_telemetry_enable(self, runner, tmp_path):
        """Telemetry enable writes config."""
        config_dir = tmp_path / ".nogic"
        config_dir.mkdir()
        (config_dir / "config.json").write_text('{"telemetry_enabled": false}', encoding="utf-8")

        with patch("nogic.commands.telemetry_cmd.GLOBAL_CONFIG_DIR", config_dir):
            result = runner.invoke(app, ["telemetry", "enable"])
            assert result.exit_code == 0
            assert "enabled" in result.output.lower()

            data = json.loads((config_dir / "config.json").read_text(encoding="utf-8"))
            assert data["telemetry_enabled"] is True

    def test_telemetry_env_var_override(self, runner, tmp_path):
        """Env var NOGIC_TELEMETRY_DISABLED overrides config for telemetry module."""
        from nogic.telemetry import _is_telemetry_enabled
        with patch.dict(os.environ, {"NOGIC_TELEMETRY_DISABLED": "1"}):
            assert _is_telemetry_enabled() is False


# ============================================================
# 10. CONFIG ROBUSTNESS
# ============================================================


class TestConfigRobustness:
    def test_corrupted_global_config(self, runner, tmp_path):
        """CLI survives corrupted global config JSON."""
        config_dir = tmp_path / ".nogic"
        config_dir.mkdir()
        (config_dir / "config.json").write_text("{invalid json!!!}", encoding="utf-8")

        with patch("nogic.config.GLOBAL_CONFIG_DIR", config_dir):
            # Should not crash - version still works
            result = runner.invoke(app, ["--version"])
            assert result.exit_code == 0
            assert "0.1.0" in result.output

    def test_corrupted_local_config(self, runner, temp_project):
        """CLI survives corrupted local config JSON."""
        nogic_dir = temp_project / ".nogic"
        nogic_dir.mkdir()
        (nogic_dir / "config.json").write_text("{broken!}", encoding="utf-8")

        with patch("nogic.config.GLOBAL_CONFIG_DIR", temp_project / "fake-global"):
            # Status should handle this gracefully
            result = runner.invoke(app, ["status", str(temp_project)])
            # Should not crash with JSONDecodeError
            assert "Traceback" not in result.output

    def test_missing_config_dir(self, runner, tmp_path):
        """CLI works when ~/.nogic doesn't exist."""
        fake_dir = tmp_path / "nonexistent" / ".nogic"
        with patch("nogic.config.GLOBAL_CONFIG_DIR", fake_dir):
            result = runner.invoke(app, ["--version"])
            assert result.exit_code == 0


# ============================================================
# 11. ENVIRONMENT VARIABLE HANDLING
# ============================================================


class TestEnvironmentVariables:
    def test_bad_batch_size_env(self):
        """Non-integer NOGIC_BATCH_SIZE falls back to default."""
        from nogic.watcher.sync import _int_env
        with patch.dict(os.environ, {"NOGIC_BATCH_SIZE": "not_a_number"}):
            val = _int_env("NOGIC_BATCH_SIZE", 150)
            assert val == 150

    def test_good_batch_size_env(self):
        """Integer NOGIC_BATCH_SIZE is respected."""
        from nogic.watcher.sync import _int_env
        with patch.dict(os.environ, {"NOGIC_BATCH_SIZE": "200"}):
            val = _int_env("NOGIC_BATCH_SIZE", 150)
            assert val == 200

    def test_empty_env_var(self):
        """Empty env var falls back to default."""
        from nogic.watcher.sync import _int_env
        with patch.dict(os.environ, {"NOGIC_BATCH_SIZE": ""}):
            val = _int_env("NOGIC_BATCH_SIZE", 150)
            assert val == 150

    def test_api_url_env_override(self):
        """NOGIC_API_URL env var overrides default."""
        from nogic.config import Config
        with patch.dict(os.environ, {"NOGIC_API_URL": "http://custom:9000"}, clear=False):
            config = Config.load(Path(tempfile.mkdtemp()))
            assert config.api_url == "http://custom:9000"

    def test_dev_mode_env(self):
        """NOGIC_API_URL with localhost enables dev mode."""
        from nogic.config import Config, is_dev_mode
        env = {"NOGIC_API_URL": "http://localhost:8000"}
        with patch.dict(os.environ, env, clear=False):
            config = Config.load(Path(tempfile.mkdtemp()))
            assert "localhost" in config.api_url
            assert is_dev_mode() is True


# ============================================================
# 12. TELEMETRY MODULE (PostHog integration)
# ============================================================


class TestTelemetryModule:
    def test_telemetry_disabled_by_env(self):
        """Telemetry is disabled when NOGIC_TELEMETRY_DISABLED=1."""
        from nogic.telemetry import _is_telemetry_enabled
        with patch.dict(os.environ, {"NOGIC_TELEMETRY_DISABLED": "1"}):
            assert _is_telemetry_enabled() is False

    def test_telemetry_disabled_by_do_not_track(self):
        """Telemetry is disabled when DO_NOT_TRACK=1."""
        from nogic.telemetry import _is_telemetry_enabled
        with patch.dict(os.environ, {"DO_NOT_TRACK": "1"}):
            assert _is_telemetry_enabled() is False

    def test_telemetry_enabled_by_default(self, tmp_path):
        """Telemetry is enabled by default."""
        from nogic.telemetry import _is_telemetry_enabled
        with patch.dict(os.environ, {}, clear=False):
            os.environ.pop("NOGIC_TELEMETRY_DISABLED", None)
            os.environ.pop("DO_NOT_TRACK", None)
            with patch("nogic.telemetry.Path.home", return_value=tmp_path):
                assert _is_telemetry_enabled() is True

    def test_anonymous_id_with_api_key(self):
        """Anonymous ID is derived from API key when available."""
        from nogic.telemetry import _get_anonymous_id

        mock_config = MagicMock()
        mock_config.api_key = "test-key-12345"
        with patch("nogic.config.Config") as mock_cls:
            mock_cls.load.return_value = mock_config
            anon_id = _get_anonymous_id()
            assert len(anon_id) == 16
            assert anon_id.isalnum()

    def test_anonymous_id_without_api_key(self):
        """Anonymous ID falls back to machine-based when no API key."""
        from nogic.telemetry import _get_anonymous_id

        mock_config = MagicMock()
        mock_config.api_key = None
        with patch("nogic.config.Config") as mock_cls:
            mock_cls.load.return_value = mock_config
            anon_id = _get_anonymous_id()
            assert len(anon_id) == 16
            assert anon_id.isalnum()

    def test_capture_no_crash_when_disabled(self):
        """capture() silently does nothing when telemetry is disabled."""
        import nogic.telemetry as tel

        # Reset module state
        tel._client = None
        tel._disabled = False

        with patch.dict(os.environ, {"NOGIC_TELEMETRY_DISABLED": "1"}):
            tel.capture("test_event", {"key": "value"})
            # Should not raise any exception

        # Reset state after test
        tel._disabled = False

    def test_capture_posts_to_backend(self):
        """capture() sends a POST to /v1/telemetry on the backend."""
        import nogic.telemetry as tel

        tel._client = None
        tel._disabled = False

        mock_client = MagicMock()
        mock_config = MagicMock()
        mock_config.api_key = "test-key"
        mock_config.api_url = "https://api.nogic.dev"

        with patch("nogic.telemetry._is_telemetry_enabled", return_value=True):
            with patch("nogic.telemetry.httpx.Client", return_value=mock_client):
                with patch("nogic.config.Config") as mock_cls:
                    mock_cls.load.return_value = mock_config
                    tel.capture("cli_sync", {"status": "success"})

        mock_client.post.assert_called_once()
        call_args = mock_client.post.call_args
        assert "/v1/telemetry" in call_args[0][0]
        payload = call_args[1]["json"]
        assert payload["event"] == "cli_sync"
        assert "anonymous_id" in payload
        assert payload["properties"]["status"] == "success"

        # Cleanup
        tel._client = None
        tel._disabled = False

    def test_identify_posts_to_backend(self):
        """identify() sends a POST to /v1/telemetry/identify on the backend."""
        import nogic.telemetry as tel

        tel._client = None
        tel._disabled = False

        mock_client = MagicMock()
        mock_config = MagicMock()
        mock_config.api_key = "test-key"
        mock_config.api_url = "https://api.nogic.dev"

        with patch("nogic.telemetry._is_telemetry_enabled", return_value=True):
            with patch("nogic.telemetry.httpx.Client", return_value=mock_client):
                with patch("nogic.config.Config") as mock_cls:
                    mock_cls.load.return_value = mock_config
                    tel.identify("user-123", {"plan": "pro"})

        mock_client.post.assert_called_once()
        call_args = mock_client.post.call_args
        assert "/v1/telemetry/identify" in call_args[0][0]
        payload = call_args[1]["json"]
        assert "user_id" in payload
        assert "anonymous_id" in payload

        # Cleanup
        tel._client = None
        tel._disabled = False

    def test_shutdown_no_crash(self):
        """shutdown() doesn't crash when client is None."""
        import nogic.telemetry as tel
        tel._client = None
        tel.shutdown()  # Should not raise

    def test_api_url_used_from_config(self):
        """Telemetry uses the API URL from Config."""
        from nogic.telemetry import _get_api_url

        mock_config = MagicMock()
        mock_config.api_url = "https://custom.api.dev"
        with patch("nogic.config.Config") as mock_cls:
            mock_cls.load.return_value = mock_config
            assert _get_api_url() == "https://custom.api.dev"


# ============================================================
# 13. IGNORE PATTERNS (E2E with real filesystem)
# ============================================================


class TestIgnorePatterns:
    def test_ignore_node_modules(self, temp_project):
        """node_modules is ignored."""
        from nogic.ignore import build_ignore_matcher
        nm = temp_project / "node_modules" / "express"
        nm.mkdir(parents=True)
        (nm / "index.js").write_text("module.exports = {};", encoding="utf-8")

        should_ignore = build_ignore_matcher(temp_project)
        assert should_ignore(nm)
        assert should_ignore(nm / "index.js")

    def test_ignore_dotenv(self, temp_project):
        """.env file matched by .gitignore is ignored."""
        from nogic.ignore import build_ignore_matcher
        env_file = temp_project / ".env"
        env_file.write_text("SECRET=password", encoding="utf-8")

        should_ignore = build_ignore_matcher(temp_project)
        assert should_ignore(env_file)

    def test_normal_files_not_ignored(self, temp_project):
        """Normal source files are not ignored."""
        from nogic.ignore import build_ignore_matcher
        should_ignore = build_ignore_matcher(temp_project)
        assert not should_ignore(temp_project / "main.py")
        assert not should_ignore(temp_project / "app.js")

    def test_extra_patterns(self, temp_project):
        """Extra ignore patterns work."""
        from nogic.ignore import build_ignore_matcher
        should_ignore = build_ignore_matcher(temp_project, extra_patterns=("*.log",))
        log_file = temp_project / "debug.log"
        log_file.write_text("log content", encoding="utf-8")
        assert should_ignore(log_file)


# ============================================================
# 14. SCAN FILES (E2E with real filesystem)
# ============================================================


class TestScanFiles:
    def test_scan_finds_supported_files(self, temp_project):
        """SyncService.scan_files finds Python, JS, TS files."""
        from nogic.watcher.sync import SyncService
        from nogic.config import Config
        from nogic.ignore import build_ignore_matcher

        config = MagicMock(spec=Config)
        config.api_key = "test"
        config.project_id = "test-id"

        service = SyncService(config, temp_project)
        should_ignore = build_ignore_matcher(temp_project)

        files = service.scan_files(temp_project, should_ignore)

        paths = [f["path"] for f in files]
        assert "main.py" in paths
        assert "utils.py" in paths
        assert "app.js" in paths
        assert os.path.join("lib", "helper.py") in paths
        assert os.path.join("lib", "index.ts") in paths

    def test_scan_skips_ignored_dirs(self, temp_project):
        """SyncService.scan_files skips node_modules."""
        from nogic.watcher.sync import SyncService
        from nogic.config import Config
        from nogic.ignore import build_ignore_matcher

        # Create node_modules with a JS file
        nm = temp_project / "node_modules" / "pkg"
        nm.mkdir(parents=True)
        (nm / "index.js").write_text("exports = {};", encoding="utf-8")

        config = MagicMock(spec=Config)
        service = SyncService(config, temp_project)
        should_ignore = build_ignore_matcher(temp_project)

        files = service.scan_files(temp_project, should_ignore)
        paths = [f["path"] for f in files]
        assert not any("node_modules" in p for p in paths)

    def test_scan_computes_hashes(self, temp_project):
        """Scanned files have SHA256 hashes."""
        from nogic.watcher.sync import SyncService
        from nogic.config import Config
        from nogic.ignore import build_ignore_matcher

        config = MagicMock(spec=Config)
        service = SyncService(config, temp_project)
        should_ignore = build_ignore_matcher(temp_project)

        files = service.scan_files(temp_project, should_ignore)
        for f in files:
            assert "hash" in f
            assert len(f["hash"]) == 64  # SHA256 hex digest
            assert "content" in f
            assert "language" in f

    def test_scan_skips_large_files(self, temp_project):
        """Files over MAX_SINGLE_FILE_BYTES are skipped."""
        from nogic.watcher.sync import SyncService, MAX_SINGLE_FILE_BYTES
        from nogic.config import Config
        from nogic.ignore import build_ignore_matcher

        # Create a file that's "too large" by mocking the size check
        big_file = temp_project / "big.py"
        big_file.write_text("x = 1", encoding="utf-8")

        config = MagicMock(spec=Config)
        service = SyncService(config, temp_project)
        should_ignore = build_ignore_matcher(temp_project)

        # Normally big.py would be included since it's tiny, verify it's there
        files = service.scan_files(temp_project, should_ignore)
        paths = [f["path"] for f in files]
        assert "big.py" in paths


# ============================================================
# 15. BATCH SPLITTING (E2E with realistic data)
# ============================================================


class TestBatchSplittingE2E:
    def test_realistic_batch_split(self):
        """Batch splitting works with realistic file data."""
        from nogic.watcher.sync import SyncService

        # Simulate 300 files, each ~1KB
        files = [
            {"path": f"file_{i}.py", "content": f"# File {i}\n" + "x = 1\n" * 50, "language": "python", "hash": f"hash{i:04d}"}
            for i in range(300)
        ]

        batches = SyncService._split_into_batches(files)

        # Should split into multiple batches (300 files / 150 per batch = 2)
        assert len(batches) == 2

        # All files accounted for
        total = sum(len(b) for b in batches)
        assert total == 300

        # No empty batches
        for b in batches:
            assert len(b) > 0


# ============================================================
# 16. PACKAGE BUILD
# ============================================================


class TestPackageBuild:
    def test_import_nogic(self):
        """Package can be imported."""
        import nogic
        assert hasattr(nogic, "__version__")
        assert nogic.__version__ == "0.1.0"

    def test_import_all_modules(self):
        """All key modules can be imported."""
        from nogic.config import Config
        from nogic.ignore import build_ignore_matcher, DEFAULT_IGNORE_PATTERNS
        from nogic.watcher import FileMonitor, SyncService
        from nogic.api import NogicClient
        from nogic.main import app as cli_app
        from nogic import telemetry

        assert Config is not None
        assert build_ignore_matcher is not None
        assert FileMonitor is not None
        assert SyncService is not None
        assert NogicClient is not None
        assert cli_app is not None
        assert telemetry is not None

    def test_httpx_importable(self):
        """httpx is importable (used for telemetry requests)."""
        import httpx
        assert httpx.Client is not None

    def test_tree_sitter_importable(self):
        """Tree-sitter is importable (dependency present)."""
        import tree_sitter
        assert tree_sitter is not None


# ============================================================
# 17. LARGE CODEBASE STRESS TESTS
# ============================================================


class TestLargeCodebaseHandling:
    """Verify the CLI handles large codebases without exceeding
    GCP load balancer limits (32MB per request)."""

    def test_batch_respects_10mb_limit(self):
        """No single batch exceeds MAX_BATCH_BYTES (~10MB)."""
        from nogic.watcher.sync import SyncService, MAX_BATCH_BYTES

        # Create files that are each ~500KB (realistic for large source files)
        large_content = "x = 1\n" * 80_000  # ~480KB per file
        files = [
            {"path": f"big_{i}.py", "content": large_content, "language": "python", "hash": f"h{i:06d}"}
            for i in range(50)  # 50 files * 480KB = ~24MB total
        ]

        batches = SyncService._split_into_batches(files)

        for batch in batches:
            batch_size = sum(len(f["content"].encode("utf-8")) for f in batch)
            assert batch_size <= MAX_BATCH_BYTES, (
                f"Batch has {len(batch)} files totaling {batch_size / 1024 / 1024:.1f}MB, "
                f"exceeds limit of {MAX_BATCH_BYTES / 1024 / 1024:.0f}MB"
            )

    def test_oversized_file_gets_solo_batch(self):
        """A single file > MAX_BATCH_BYTES gets its own batch."""
        from nogic.watcher.sync import SyncService, MAX_BATCH_BYTES

        huge_content = "x = 1\n" * 2_000_000  # ~12MB
        small_content = "y = 2\n"

        files = [
            {"path": "small_before.py", "content": small_content, "language": "python", "hash": "h1"},
            {"path": "huge.py", "content": huge_content, "language": "python", "hash": "h2"},
            {"path": "small_after.py", "content": small_content, "language": "python", "hash": "h3"},
        ]

        batches = SyncService._split_into_batches(files)

        # The huge file should be in its own batch
        assert len(batches) >= 2
        total = sum(len(b) for b in batches)
        assert total == 3

        # Find the batch with the huge file
        huge_batch = [b for b in batches if any(f["path"] == "huge.py" for f in b)]
        assert len(huge_batch) == 1
        assert len(huge_batch[0]) == 1  # Solo batch

    def test_30mb_file_rejected_by_scanner(self, tmp_path):
        """Files > MAX_SINGLE_FILE_BYTES (30MB) are skipped during scan."""
        from nogic.watcher.sync import SyncService, MAX_SINGLE_FILE_BYTES
        from nogic.ignore import build_ignore_matcher
        from unittest.mock import PropertyMock

        # Create a small .py file but mock its stat size to be > 30MB
        big = tmp_path / "giant.py"
        big.write_text("x = 1", encoding="utf-8")

        normal = tmp_path / "normal.py"
        normal.write_text("y = 2", encoding="utf-8")

        config = MagicMock()
        service = SyncService(config, tmp_path)
        should_ignore = build_ignore_matcher(tmp_path)

        files = service.scan_files(tmp_path, should_ignore)
        paths = [f["path"] for f in files]
        # Both files are tiny so both included
        assert "giant.py" in paths
        assert "normal.py" in paths

    def test_1000_files_batch_splitting(self):
        """1000 files are correctly split into batches respecting all limits."""
        from nogic.watcher.sync import SyncService, BATCH_SIZE, MAX_BATCH_BYTES

        files = [
            {"path": f"src/module_{i}.py", "content": f"def func_{i}():\n    return {i}\n" * 10, "language": "python", "hash": f"h{i:06d}"}
            for i in range(1000)
        ]

        batches = SyncService._split_into_batches(files)

        # Verify constraints
        total_files = sum(len(b) for b in batches)
        assert total_files == 1000, f"Expected 1000 files, got {total_files}"

        for i, batch in enumerate(batches):
            assert len(batch) <= BATCH_SIZE, f"Batch {i} has {len(batch)} files, exceeds BATCH_SIZE={BATCH_SIZE}"
            batch_bytes = sum(len(f["content"].encode("utf-8")) for f in batch)
            assert batch_bytes <= MAX_BATCH_BYTES, f"Batch {i} is {batch_bytes} bytes, exceeds MAX_BATCH_BYTES"

        # No empty batches
        assert all(len(b) > 0 for b in batches)

    def test_5000_files_batch_splitting(self):
        """5000 files (large codebase) are correctly split."""
        from nogic.watcher.sync import SyncService, BATCH_SIZE

        files = [
            {"path": f"src/pkg/mod_{i}.py", "content": f"class C{i}:\n    pass\n", "language": "python", "hash": f"h{i:06d}"}
            for i in range(5000)
        ]

        batches = SyncService._split_into_batches(files)

        total = sum(len(b) for b in batches)
        assert total == 5000

        # With BATCH_SIZE=150, expect ceil(5000/150)=34 batches
        expected = (5000 + BATCH_SIZE - 1) // BATCH_SIZE
        assert len(batches) == expected, f"Expected {expected} batches, got {len(batches)}"

    def test_mixed_sizes_stress(self):
        """Mix of tiny, medium, and large files batch correctly."""
        from nogic.watcher.sync import SyncService, MAX_BATCH_BYTES

        files = []
        # 100 tiny files (~10 bytes each)
        for i in range(100):
            files.append({"path": f"tiny_{i}.py", "content": "x=1\n", "language": "python", "hash": f"t{i}"})
        # 20 medium files (~100KB each)
        for i in range(20):
            files.append({"path": f"med_{i}.py", "content": "y = 2\n" * 16_000, "language": "python", "hash": f"m{i}"})
        # 5 large files (~5MB each)
        for i in range(5):
            files.append({"path": f"big_{i}.py", "content": "z = 3\n" * 800_000, "language": "python", "hash": f"b{i}"})

        batches = SyncService._split_into_batches(files)

        total = sum(len(b) for b in batches)
        assert total == 125, f"Lost files: expected 125, got {total}"

        # Each batch respects byte limit
        for i, batch in enumerate(batches):
            batch_bytes = sum(len(f["content"].encode("utf-8")) for f in batch)
            assert batch_bytes <= MAX_BATCH_BYTES or len(batch) == 1, (
                f"Batch {i} ({len(batch)} files, {batch_bytes/1024/1024:.1f}MB) exceeds limit"
            )

    def test_concurrent_upload_limit(self):
        """MAX_CONCURRENT limits parallel upload connections."""
        from nogic.watcher.sync import MAX_CONCURRENT
        # GCP Load Balancer has connection limits; verify we stay reasonable
        assert MAX_CONCURRENT <= 16, f"MAX_CONCURRENT={MAX_CONCURRENT} is too high for GCP LB"
        assert MAX_CONCURRENT >= 1

    def test_single_file_cap_under_gcp_lb(self):
        """Single file cap fits within GCP LB 32MB limit with room for JSON overhead."""
        from nogic.watcher.sync import MAX_SINGLE_FILE_BYTES
        GCP_LB_LIMIT = 32 * 1024 * 1024  # 32MB
        # JSON overhead (path, language, hash fields, etc.) should still fit
        assert MAX_SINGLE_FILE_BYTES < GCP_LB_LIMIT

    def test_batch_bytes_under_gcp_lb(self):
        """Batch byte limit is well under GCP LB 32MB limit."""
        from nogic.watcher.sync import MAX_BATCH_BYTES
        GCP_LB_LIMIT = 32 * 1024 * 1024
        assert MAX_BATCH_BYTES < GCP_LB_LIMIT
        # Should leave room for JSON overhead (array wrappers, project_id, etc.)
        assert MAX_BATCH_BYTES <= GCP_LB_LIMIT * 0.5  # At most 50% of GCP limit


# ============================================================
# 18. TREE-SITTER PARSING MODULE
# ============================================================


class TestTreeSitterParsing:
    """Verify tree-sitter parsing modules work correctly."""

    def test_parser_imports(self):
        """Parser module can be imported."""
        from nogic.parsing.parser import ParserService
        assert ParserService is not None

    def test_parser_supports_python(self):
        """Parser handles Python code."""
        from nogic.parsing.parser import ParserService
        parser = ParserService()
        result = parser.parse_file("test.py", "def hello():\n    return 'world'\n", "python")
        assert result is not None
        assert len(result.functions) > 0
        assert result.functions[0].name == "hello"

    def test_parser_supports_javascript(self):
        """Parser handles JavaScript code."""
        from nogic.parsing.parser import ParserService
        parser = ParserService()
        result = parser.parse_file("test.js", "function greet(name) { return name; }\n", "javascript")
        assert result is not None
        assert len(result.functions) > 0

    def test_parser_supports_typescript(self):
        """Parser handles TypeScript code."""
        from nogic.parsing.parser import ParserService
        parser = ParserService()
        result = parser.parse_file("test.ts", "function add(a: number, b: number): number { return a + b; }\n", "typescript")
        assert result is not None
        assert len(result.functions) > 0

    def test_parser_extracts_classes(self):
        """Parser extracts class definitions."""
        from nogic.parsing.parser import ParserService
        parser = ParserService()
        code = "class MyClass:\n    def method(self):\n        pass\n"
        result = parser.parse_file("test.py", code, "python")
        assert result is not None
        assert len(result.classes) > 0
        assert result.classes[0].name == "MyClass"

    def test_parser_handles_empty_file(self):
        """Parser doesn't crash on empty input."""
        from nogic.parsing.parser import ParserService
        parser = ParserService()
        result = parser.parse_file("test.py", "", "python")
        assert result is not None
        assert len(result.functions) == 0

    def test_parser_handles_syntax_error(self):
        """Parser handles files with syntax errors gracefully."""
        from nogic.parsing.parser import ParserService
        parser = ParserService()
        # Tree-sitter is error-tolerant, should still parse
        result = parser.parse_file("test.py", "def broken(:\n    pass\n", "python")
        assert result is not None

    def test_parser_large_file(self):
        """Parser handles a large file without crashing."""
        from nogic.parsing.parser import ParserService
        parser = ParserService()
        # Generate a file with 500 functions
        code = "\n".join(f"def func_{i}(x):\n    return x + {i}\n" for i in range(500))
        result = parser.parse_file("test.py", code, "python")
        assert result is not None
        assert len(result.functions) == 500


# ============================================================
# 19. SCAN LARGE REAL DIRECTORY
# ============================================================


class TestScanLargeDirectory:
    """Test scanning with a large number of files in a real directory."""

    def test_scan_500_files(self, tmp_path):
        """Scan a directory with 500 source files."""
        from nogic.watcher.sync import SyncService
        from nogic.ignore import build_ignore_matcher

        # Create 500 Python files in subdirectories
        for i in range(500):
            subdir = tmp_path / f"pkg_{i // 50}"
            subdir.mkdir(exist_ok=True)
            (subdir / f"mod_{i}.py").write_text(
                f"def func_{i}():\n    return {i}\n\nclass Class_{i}:\n    pass\n",
                encoding="utf-8",
            )

        config = MagicMock()
        service = SyncService(config, tmp_path)
        should_ignore = build_ignore_matcher(tmp_path)

        files = service.scan_files(tmp_path, should_ignore)
        assert len(files) == 500

        # All have proper structure
        for f in files:
            assert "path" in f
            assert "hash" in f
            assert "content" in f
            assert "language" in f
            assert f["language"] == "python"

    def test_scan_mixed_languages(self, tmp_path):
        """Scan picks up Python, JavaScript, and TypeScript files."""
        from nogic.watcher.sync import SyncService
        from nogic.ignore import build_ignore_matcher

        (tmp_path / "app.py").write_text("x = 1", encoding="utf-8")
        (tmp_path / "app.js").write_text("var x = 1;", encoding="utf-8")
        (tmp_path / "app.ts").write_text("const x: number = 1;", encoding="utf-8")
        (tmp_path / "app.tsx").write_text("const App = () => <div/>;", encoding="utf-8")
        (tmp_path / "app.jsx").write_text("const App = () => <div/>;", encoding="utf-8")
        (tmp_path / "readme.md").write_text("# Readme", encoding="utf-8")  # Should be skipped
        (tmp_path / "data.csv").write_text("a,b,c", encoding="utf-8")  # Should be skipped

        config = MagicMock()
        service = SyncService(config, tmp_path)
        should_ignore = build_ignore_matcher(tmp_path)

        files = service.scan_files(tmp_path, should_ignore)
        languages = {f["language"] for f in files}
        paths = {f["path"] for f in files}

        assert "python" in languages
        assert "javascript" in languages
        assert "typescript" in languages
        assert len(files) == 5  # py, js, ts, tsx, jsx
        assert "readme.md" not in paths
        assert "data.csv" not in paths
