"""Tests for SyncService batching and file size handling."""

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

import pytest

# Override env vars BEFORE importing sync module so constants pick them up
# We use small values to make tests fast and predictable
os.environ.setdefault("NOGIC_BATCH_SIZE", "150")


from nogic.watcher.sync import (
    SyncService,
    BATCH_SIZE,
    MAX_BATCH_BYTES,
    MAX_SINGLE_FILE_BYTES,
)


def _make_file(content: str, path: str = "test.py", language: str = "python") -> dict:
    return {
        "path": path,
        "content": content,
        "language": language,
        "hash": hashlib.sha256(content.encode()).hexdigest(),
    }


def _make_file_of_size(size_bytes: int, path: str = "test.py") -> dict:
    """Create a file dict with content of approximately the given byte size."""
    # 'a' is 1 byte in UTF-8
    content = "a" * size_bytes
    return _make_file(content, path=path)


# ---------------------------------------------------------------------------
# _split_into_batches
# ---------------------------------------------------------------------------

class TestSplitIntoBatches:
    """Tests for SyncService._split_into_batches."""

    def test_empty_list(self):
        result = SyncService._split_into_batches([])
        assert result == []

    def test_single_small_file(self):
        files = [_make_file("print('hello')")]
        batches = SyncService._split_into_batches(files)
        assert len(batches) == 1
        assert len(batches[0]) == 1

    def test_multiple_small_files_single_batch(self):
        files = [_make_file(f"print({i})", path=f"f{i}.py") for i in range(10)]
        batches = SyncService._split_into_batches(files)
        assert len(batches) == 1
        assert len(batches[0]) == 10

    def test_count_limit_splits_batches(self):
        """When file count exceeds BATCH_SIZE, a new batch is created."""
        files = [_make_file("x", path=f"f{i}.py") for i in range(BATCH_SIZE + 5)]
        batches = SyncService._split_into_batches(files)
        assert len(batches) == 2
        assert len(batches[0]) == BATCH_SIZE
        assert len(batches[1]) == 5

    def test_byte_limit_splits_batches(self):
        """When cumulative bytes exceed MAX_BATCH_BYTES, a new batch is created."""
        # Each file is ~60% of MAX_BATCH_BYTES, so two won't fit in one batch
        size = int(MAX_BATCH_BYTES * 0.6)
        files = [
            _make_file_of_size(size, path="a.py"),
            _make_file_of_size(size, path="b.py"),
        ]
        batches = SyncService._split_into_batches(files)
        assert len(batches) == 2
        assert len(batches[0]) == 1
        assert len(batches[1]) == 1

    def test_oversized_single_file_gets_solo_batch(self):
        """A file larger than MAX_BATCH_BYTES gets its own solo batch."""
        big_size = MAX_BATCH_BYTES + 1000
        files = [
            _make_file("small", path="small.py"),
            _make_file_of_size(big_size, path="big.py"),
            _make_file("also_small", path="also_small.py"),
        ]
        batches = SyncService._split_into_batches(files)
        assert len(batches) == 3
        # First batch: the small file before the big one
        assert batches[0][0]["path"] == "small.py"
        # Second batch: the oversized file solo
        assert len(batches[1]) == 1
        assert batches[1][0]["path"] == "big.py"
        # Third batch: the small file after
        assert batches[2][0]["path"] == "also_small.py"

    def test_oversized_file_at_start(self):
        """Oversized file as the first file still gets its own batch."""
        big_size = MAX_BATCH_BYTES + 1000
        files = [
            _make_file_of_size(big_size, path="big.py"),
            _make_file("small", path="small.py"),
        ]
        batches = SyncService._split_into_batches(files)
        assert len(batches) == 2
        assert len(batches[0]) == 1
        assert batches[0][0]["path"] == "big.py"
        assert batches[1][0]["path"] == "small.py"

    def test_oversized_file_at_end(self):
        """Oversized file as the last file gets its own batch."""
        big_size = MAX_BATCH_BYTES + 1000
        files = [
            _make_file("small", path="small.py"),
            _make_file_of_size(big_size, path="big.py"),
        ]
        batches = SyncService._split_into_batches(files)
        assert len(batches) == 2
        assert batches[0][0]["path"] == "small.py"
        assert len(batches[1]) == 1
        assert batches[1][0]["path"] == "big.py"

    def test_multiple_oversized_files(self):
        """Multiple oversized files each get their own batch."""
        big_size = MAX_BATCH_BYTES + 1000
        files = [
            _make_file_of_size(big_size, path="big1.py"),
            _make_file_of_size(big_size, path="big2.py"),
        ]
        batches = SyncService._split_into_batches(files)
        assert len(batches) == 2
        assert batches[0][0]["path"] == "big1.py"
        assert batches[1][0]["path"] == "big2.py"

    def test_mixed_sizes_correct_grouping(self):
        """Small files batch together, oversized files stay solo."""
        big_size = MAX_BATCH_BYTES + 1000
        files = [
            _make_file("a", path="a.py"),
            _make_file("b", path="b.py"),
            _make_file_of_size(big_size, path="big.py"),
            _make_file("c", path="c.py"),
            _make_file("d", path="d.py"),
        ]
        batches = SyncService._split_into_batches(files)
        assert len(batches) == 3
        # First batch: small files before big
        assert [f["path"] for f in batches[0]] == ["a.py", "b.py"]
        # Second batch: big file solo
        assert [f["path"] for f in batches[1]] == ["big.py"]
        # Third batch: small files after big
        assert [f["path"] for f in batches[2]] == ["c.py", "d.py"]

    def test_file_exactly_at_batch_limit(self):
        """A file exactly at MAX_BATCH_BYTES is NOT oversized (uses <= not <)."""
        files = [
            _make_file_of_size(MAX_BATCH_BYTES, path="exact.py"),
        ]
        batches = SyncService._split_into_batches(files)
        assert len(batches) == 1
        # Should be in a normal batch, not treated as oversized
        assert batches[0][0]["path"] == "exact.py"

    def test_two_files_exactly_at_limit_split(self):
        """Two files that each equal MAX_BATCH_BYTES can't share a batch."""
        files = [
            _make_file_of_size(MAX_BATCH_BYTES, path="a.py"),
            _make_file_of_size(MAX_BATCH_BYTES, path="b.py"),
        ]
        batches = SyncService._split_into_batches(files)
        assert len(batches) == 2

    def test_preserves_file_order(self):
        """Files maintain their original order across batches."""
        files = [_make_file(f"content_{i}", path=f"f{i}.py") for i in range(10)]
        batches = SyncService._split_into_batches(files)
        flat = [f for batch in batches for f in batch]
        assert [f["path"] for f in flat] == [f["path"] for f in files]

    def test_no_empty_batches(self):
        """No batch should ever be empty."""
        big_size = MAX_BATCH_BYTES + 1
        files = [
            _make_file_of_size(big_size, path="big1.py"),
            _make_file("small", path="small.py"),
            _make_file_of_size(big_size, path="big2.py"),
        ]
        batches = SyncService._split_into_batches(files)
        for i, batch in enumerate(batches):
            assert len(batch) > 0, f"Batch {i} is empty"


# ---------------------------------------------------------------------------
# File size limits in scan_files
# ---------------------------------------------------------------------------

class TestScanFileSize:
    """Tests that scan_files respects MAX_SINGLE_FILE_BYTES."""

    def _make_temp_tree(self, tmp_path: Path, file_specs: dict[str, int]):
        """Create files of given sizes. file_specs: {filename: size_bytes}."""
        for name, size in file_specs.items():
            p = tmp_path / name
            p.write_text("a" * size, encoding="utf-8")

    @pytest.fixture
    def sync_service(self, tmp_path):
        config = MagicMock()
        config.project_id = "test-project"
        config.api_key = "test-key"
        svc = SyncService(config=config, root_path=tmp_path, log=lambda msg: None)
        return svc

    def test_small_file_included(self, sync_service, tmp_path):
        """Files under the limit are included in scan results."""
        (tmp_path / "small.py").write_text("print('hello')", encoding="utf-8")
        result = sync_service.scan_files(tmp_path, ignore_check=lambda p: False)
        assert len(result) == 1
        assert result[0]["path"] == "small.py"

    def test_file_over_hard_cap_skipped(self, sync_service, tmp_path):
        """Files over MAX_SINGLE_FILE_BYTES are skipped."""
        content = "a" * (MAX_SINGLE_FILE_BYTES + 1)
        (tmp_path / "huge.py").write_text(content, encoding="utf-8")
        result = sync_service.scan_files(tmp_path, ignore_check=lambda p: False)
        assert len(result) == 0

    def test_large_but_under_cap_included(self, sync_service, tmp_path):
        """Files between old 1MB limit and new 30MB limit ARE included."""
        # 5MB file — would have been skipped by the old 1MB limit
        content = "a" * (5 * 1024 * 1024)
        (tmp_path / "medium.py").write_text(content, encoding="utf-8")
        result = sync_service.scan_files(tmp_path, ignore_check=lambda p: False)
        assert len(result) == 1
        assert result[0]["path"] == "medium.py"

    def test_mix_of_sizes(self, sync_service, tmp_path):
        """Only files over the hard cap are excluded."""
        (tmp_path / "small.py").write_text("x = 1", encoding="utf-8")
        # 2MB — well under 30MB cap
        (tmp_path / "medium.py").write_text("a" * (2 * 1024 * 1024), encoding="utf-8")
        # Over 30MB — should be skipped
        (tmp_path / "huge.py").write_text("a" * (MAX_SINGLE_FILE_BYTES + 1), encoding="utf-8")

        result = sync_service.scan_files(tmp_path, ignore_check=lambda p: False)
        paths = {f["path"] for f in result}
        assert "small.py" in paths
        assert "medium.py" in paths
        assert "huge.py" not in paths


# ---------------------------------------------------------------------------
# sync_file_immediate size check
# ---------------------------------------------------------------------------

class TestSyncFileImmediateSize:
    """Tests that sync_file_immediate respects MAX_SINGLE_FILE_BYTES."""

    @pytest.fixture
    def sync_service(self, tmp_path):
        config = MagicMock()
        config.project_id = "test-project"
        config.api_key = "test-key"
        svc = SyncService(config=config, root_path=tmp_path, log=lambda msg: None)
        return svc

    def test_small_file_synced(self, sync_service, tmp_path):
        """Small files pass the size check (may fail at API call, that's ok)."""
        p = tmp_path / "small.py"
        p.write_text("print('hello')", encoding="utf-8")
        # Mock the API client so we don't make real HTTP calls
        mock_result = MagicMock()
        mock_result.files_indexed = 1
        sync_service._client = MagicMock()
        sync_service._client.index_files.return_value = mock_result
        assert sync_service.sync_file_immediate(p) is True

    def test_huge_file_rejected(self, sync_service, tmp_path):
        """Files over the hard cap are rejected without hitting the API."""
        p = tmp_path / "huge.py"
        p.write_text("a" * (MAX_SINGLE_FILE_BYTES + 1), encoding="utf-8")
        sync_service._client = MagicMock()
        result = sync_service.sync_file_immediate(p)
        assert result is False
        # Verify no API call was made
        sync_service._client.index_files.assert_not_called()

    def test_file_between_old_and_new_limit_synced(self, sync_service, tmp_path):
        """A 5MB file (above old 1MB limit) should now be synced."""
        p = tmp_path / "medium.py"
        p.write_text("a" * (5 * 1024 * 1024), encoding="utf-8")
        mock_result = MagicMock()
        mock_result.files_indexed = 1
        sync_service._client = MagicMock()
        sync_service._client.index_files.return_value = mock_result
        assert sync_service.sync_file_immediate(p) is True


# ---------------------------------------------------------------------------
# Constants sanity checks
# ---------------------------------------------------------------------------

class TestConstants:
    """Verify the constants are set correctly."""

    def test_max_single_file_is_30mb(self):
        assert MAX_SINGLE_FILE_BYTES == 30 * 1024 * 1024

    def test_max_batch_bytes_is_10mb(self):
        assert MAX_BATCH_BYTES == 10 * 1024 * 1024

    def test_single_file_cap_under_gcp_lb_limit(self):
        GCP_LB_LIMIT = 32 * 1024 * 1024
        assert MAX_SINGLE_FILE_BYTES < GCP_LB_LIMIT

    def test_batch_size_is_150(self):
        assert BATCH_SIZE == 150

    def test_single_file_cap_greater_than_batch_bytes(self):
        """The hard cap should be higher than the batch limit."""
        assert MAX_SINGLE_FILE_BYTES > MAX_BATCH_BYTES
