"""Tests for DownloadCollectionProgressBox multi-file parallel downloads.

These tests verify the prefetch-one concurrent download model, aggregate
progress, per-file error handling, cancel-all, and edge cases — all
without a running GTK display (GTK widgets are mocked).

The module is loaded via importlib to avoid the normal lutris.gui package
chain that requires a real GTK installation / display.
"""

from unittest import TestCase
from unittest.mock import MagicMock, patch

from lutris.gui.widgets.download_collection_progress_box import (
    MAX_CONCURRENT_FILES,
    DownloadCollectionProgressBox,
    _ActiveDownload,
)

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


def _make_file(
    filename="game.bin",
    url="https://cdn.example.com/game.bin",
    dest="/tmp/downloads/game.bin",
    size=1000,
    downloader_class=None,
    referer=None,
):
    """Create a mock InstallerFile."""
    f = MagicMock()
    f.filename = filename
    f.url = url
    f.dest_file = dest
    f.tmp_file = None
    f.referer = referer
    if downloader_class:
        f.downloader_class = downloader_class
    else:
        # Simulate no downloader_class attribute: getattr returns None
        del f.downloader_class
    return f


def _make_collection(files, human_url="Test Collection"):
    """Create a mock InstallerFileCollection."""
    coll = MagicMock()
    coll.files_list = files
    coll.human_url = human_url
    coll.num_files = len(files)
    coll.full_size = sum(getattr(f, "_size", 1000) for f in files)
    return coll


def _make_downloader(state="DOWNLOADING", downloaded_size=0):
    """Create a mock Downloader with state constants."""
    dl = MagicMock()
    dl.DOWNLOADING = "DOWNLOADING"
    dl.COMPLETED = "COMPLETED"
    dl.CANCELLED = "CANCELLED"
    dl.ERROR = "ERROR"
    dl.state = state
    dl.downloaded_size = downloaded_size
    dl.error = None
    dl.dest = "/tmp/test.tmp"
    return dl


# ── Fixture ──────────────────────────────────────────────────────────


def three_files():
    """Three files for prefetch boundary test."""
    return [
        _make_file("a.bin", dest="/tmp/dl/a.bin"),
        _make_file("b.bin", dest="/tmp/dl/b.bin"),
        _make_file("c.bin", dest="/tmp/dl/c.bin"),
    ]


# ── Tests: _ActiveDownload ───────────────────────────────────────────


class TestActiveDownload(TestCase):
    def test_init_sets_fields(self):
        f = _make_file()
        dl = _make_downloader()
        ad = _ActiveDownload(f, dl)
        assert ad.file is f
        assert ad.downloader is dl
        assert ad.num_retries == 0

    def test_retry_count_increments(self):
        ad = _ActiveDownload(_make_file(), _make_downloader())
        ad.num_retries += 1
        assert ad.num_retries == 1


# ── Tests: Initialisation ───────────────────────────────────────────


class TestInit(TestCase):
    def test_active_downloads_initially_empty(self):
        box = DownloadCollectionProgressBox.__new__(DownloadCollectionProgressBox)
        box._active_downloads = []
        assert box._active_downloads == []

    def test_completed_sizes_initially_empty(self):
        box = DownloadCollectionProgressBox.__new__(DownloadCollectionProgressBox)
        box._completed_sizes = {}
        assert box._completed_sizes == {}

    def test_max_concurrent_files_is_two(self):
        assert MAX_CONCURRENT_FILES == 2


# ── Tests: _create_downloader ───────────────────────────────────────


class TestCreateDownloader(TestCase):
    @patch("lutris.gui.widgets.download_collection_progress_box.create_cache_lock")
    @patch("lutris.gui.widgets.download_collection_progress_box.SimpleDownloader")
    @patch("lutris.gui.widgets.download_collection_progress_box.os.path.exists", return_value=False)
    def test_creates_downloader_with_correct_args(self, mock_exists, MockDL, mock_lock):
        box = DownloadCollectionProgressBox.__new__(DownloadCollectionProgressBox)
        f = _make_file(url="https://cdn.example.com/f.bin", dest="/tmp/dl/f.bin")

        box._create_downloader(f)

        MockDL.assert_called_once_with(
            "https://cdn.example.com/f.bin",
            "/tmp/dl/f.bin.tmp",
            referer=None,
            overwrite=True,
        )
        assert f.tmp_file == "/tmp/dl/f.bin.tmp"

    @patch("lutris.gui.widgets.download_collection_progress_box.create_cache_lock")
    @patch("lutris.gui.widgets.download_collection_progress_box.SimpleDownloader")
    @patch("lutris.gui.widgets.download_collection_progress_box.os.path.exists", return_value=True)
    @patch("lutris.gui.widgets.download_collection_progress_box.os.remove")
    def test_removes_existing_tmp_file(self, mock_remove, mock_exists, MockDL, mock_lock):
        box = DownloadCollectionProgressBox.__new__(DownloadCollectionProgressBox)
        f = _make_file(dest="/tmp/dl/f.bin")

        box._create_downloader(f)
        mock_remove.assert_called_once_with("/tmp/dl/f.bin.tmp")

    @patch("lutris.gui.widgets.download_collection_progress_box.create_cache_lock")
    @patch("lutris.gui.widgets.download_collection_progress_box.os.path.exists", return_value=False)
    def test_uses_custom_downloader_class(self, mock_exists, mock_lock):
        box = DownloadCollectionProgressBox.__new__(DownloadCollectionProgressBox)
        custom_dl_cls = MagicMock()
        f = _make_file(dest="/tmp/dl/f.bin")
        f.downloader_class = custom_dl_cls

        box._create_downloader(f)
        custom_dl_cls.assert_called_once()

    @patch("lutris.gui.widgets.download_collection_progress_box.display_error")
    @patch("lutris.gui.widgets.download_collection_progress_box.os.path.exists", return_value=False)
    def test_returns_none_on_runtime_error(self, mock_exists, mock_display):
        box = DownloadCollectionProgressBox.__new__(DownloadCollectionProgressBox)
        box.get_toplevel = MagicMock(return_value=None)
        f = _make_file(dest="/tmp/dl/f.bin")
        # Simulate no downloader_class → use default Downloader which will fail
        with patch(
            "lutris.gui.widgets.download_collection_progress_box.SimpleDownloader",
            side_effect=RuntimeError("oops"),
        ):
            result = box._create_downloader(f)
        assert result is None
        mock_display.assert_called_once()


# ── Tests: _pop_next_downloadable_file ───────────────────────────────


class TestPopNextDownloadableFile(TestCase):
    def test_returns_file_when_not_cached(self):
        box = DownloadCollectionProgressBox.__new__(DownloadCollectionProgressBox)
        f = _make_file(dest="/tmp/dl/notexist.bin")
        box._file_queue = [f]
        box.num_files_downloaded = 0
        box._completed_sizes = {}

        with patch("lutris.gui.widgets.download_collection_progress_box.os.path.exists", return_value=False):
            result = box._pop_next_downloadable_file()
        assert result is f

    def test_skips_cached_file(self):
        box = DownloadCollectionProgressBox.__new__(DownloadCollectionProgressBox)
        cached = _make_file("cached.bin", dest="/tmp/dl/cached.bin")
        fresh = _make_file("fresh.bin", dest="/tmp/dl/fresh.bin")
        box._file_queue = [fresh, cached]  # pop() takes from end
        box.num_files_downloaded = 0
        box._completed_sizes = {}

        def exists_side(path):
            return path == "/tmp/dl/cached.bin"

        with patch("lutris.gui.widgets.download_collection_progress_box.os.path.exists", side_effect=exists_side):
            with patch("lutris.gui.widgets.download_collection_progress_box.os.path.getsize", return_value=500):
                result = box._pop_next_downloadable_file()

        assert result is fresh
        assert box.num_files_downloaded == 1
        assert box._completed_sizes["/tmp/dl/cached.bin"] == 500

    def test_returns_none_when_empty(self):
        box = DownloadCollectionProgressBox.__new__(DownloadCollectionProgressBox)
        box._file_queue = []
        box.num_files_downloaded = 0
        box._completed_sizes = {}
        assert box._pop_next_downloadable_file() is None


# ── Tests: Aggregate progress ────────────────────────────────────────


class TestAggregateDownloadedSize(TestCase):
    def test_sums_completed_and_active(self):
        box = DownloadCollectionProgressBox.__new__(DownloadCollectionProgressBox)
        box._completed_sizes = {"/a": 100, "/b": 200}

        dl1 = _make_downloader(downloaded_size=50)
        dl2 = _make_downloader(downloaded_size=75)
        ad1 = _ActiveDownload(_make_file(), dl1)
        ad2 = _ActiveDownload(_make_file(), dl2)
        box._active_downloads = [ad1, ad2]

        assert box._aggregate_downloaded_size() == 100 + 200 + 50 + 75

    def test_empty_state(self):
        box = DownloadCollectionProgressBox.__new__(DownloadCollectionProgressBox)
        box._completed_sizes = {}
        box._active_downloads = []
        assert box._aggregate_downloaded_size() == 0


# ── Tests: File labels ───────────────────────────────────────────────


class TestUpdateActiveFileLabels(TestCase):
    def test_comma_separated_names(self):
        box = DownloadCollectionProgressBox.__new__(DownloadCollectionProgressBox)
        box.file_name_label = MagicMock()

        f1 = _make_file("part1.bin")
        f2 = _make_file("part2.bin")
        box._active_downloads = [
            _ActiveDownload(f1, _make_downloader()),
            _ActiveDownload(f2, _make_downloader()),
        ]

        box._update_active_file_labels()
        box.file_name_label.set_text.assert_called_once_with("part1.bin, part2.bin")

    def test_single_name(self):
        box = DownloadCollectionProgressBox.__new__(DownloadCollectionProgressBox)
        box.file_name_label = MagicMock()

        f = _make_file("only.bin")
        box._active_downloads = [_ActiveDownload(f, _make_downloader())]

        box._update_active_file_labels()
        box.file_name_label.set_text.assert_called_once_with("only.bin")

    def test_empty_when_no_active(self):
        box = DownloadCollectionProgressBox.__new__(DownloadCollectionProgressBox)
        box.file_name_label = MagicMock()
        box._active_downloads = []

        box._update_active_file_labels()
        box.file_name_label.set_text.assert_called_once_with("")


# ── Tests: Start / prefetch ──────────────────────────────────────────


class TestStartPrefetch(TestCase):
    def setUp(self):
        self.three_files = three_files()

    def test_start_launches_two_downloads(self):
        """start() should launch primary + prefetch = 2 concurrent downloads."""
        box = DownloadCollectionProgressBox.__new__(DownloadCollectionProgressBox)
        box._file_queue = self.three_files.copy()
        box.downloader = None
        box.is_complete = False
        box.num_files_downloaded = 0
        box._active_downloads = []
        box._completed_sizes = {}
        box.cancel_button = MagicMock()
        box.file_name_label = MagicMock()
        box.emit = MagicMock()

        mock_dl = _make_downloader()
        with patch.object(box, "_create_downloader", return_value=mock_dl):
            with patch("lutris.gui.widgets.download_collection_progress_box.schedule_repeating_at_idle"):
                box.start()

        assert len(box._active_downloads) == 2
        # One file left in queue
        assert len(box._file_queue) == 1

    def test_start_emits_complete_when_no_files(self):
        """start() with empty queue should emit complete."""
        box = DownloadCollectionProgressBox.__new__(DownloadCollectionProgressBox)
        box._file_queue = []
        box.downloader = None
        box.is_complete = False
        box._active_downloads = []
        box._completed_sizes = {}
        box.cancel_button = MagicMock()
        box.emit = MagicMock()

        with patch.object(box, "_pop_next_downloadable_file", return_value=None):
            box.start()

        box.emit.assert_called_once_with("complete", {})
        assert box.is_complete is True

    def test_prefetch_respects_max_concurrent(self):
        """_start_prefetch() should not exceed MAX_CONCURRENT_FILES."""
        box = DownloadCollectionProgressBox.__new__(DownloadCollectionProgressBox)
        box._file_queue = [_make_file(), _make_file()]
        box._active_downloads = [
            _ActiveDownload(_make_file(), _make_downloader()),
            _ActiveDownload(_make_file(), _make_downloader()),
        ]
        box.file_name_label = MagicMock()

        box._start_prefetch()
        # Should NOT have added more
        assert len(box._active_downloads) == 2

    def test_prefetch_starts_when_room(self):
        """_start_prefetch() launches download when under limit."""
        box = DownloadCollectionProgressBox.__new__(DownloadCollectionProgressBox)
        f = _make_file(dest="/tmp/dl/next.bin")
        box._file_queue = [f]
        box._active_downloads = [_ActiveDownload(_make_file(), _make_downloader())]
        box.file_name_label = MagicMock()
        box.num_files_downloaded = 0
        box._completed_sizes = {}

        mock_dl = _make_downloader()
        with patch.object(box, "_create_downloader", return_value=mock_dl):
            with patch(
                "lutris.gui.widgets.download_collection_progress_box.os.path.exists",
                return_value=False,
            ):
                box._start_prefetch()

        assert len(box._active_downloads) == 2


# ── Tests: Progress callback ─────────────────────────────────────────


class TestProgress(TestCase):
    def _setup_box(self):
        """Create a box ready for _progress() calls."""
        box = DownloadCollectionProgressBox.__new__(DownloadCollectionProgressBox)
        box._active_downloads = []
        box._completed_sizes = {}
        box._file_queue = []
        box.full_size = 2000
        box.num_files_downloaded = 0
        box.is_complete = False
        box.downloader = None
        box.progressbar = MagicMock()
        box.progress_label = MagicMock()
        box.cancel_button = MagicMock()
        box.file_name_label = MagicMock()
        box.emit = MagicMock()
        box.time_left = "00:00:00"
        box.time_left_check_time = 0
        box.last_size = 0
        box.avg_speed = 0
        box.speed_list = []
        return box

    def test_returns_false_when_no_active(self):
        box = self._setup_box()
        assert box._progress() is False

    def test_returns_true_when_downloading(self):
        box = self._setup_box()
        dl = _make_downloader(state="DOWNLOADING", downloaded_size=500)
        ad = _ActiveDownload(_make_file(), dl)
        box._active_downloads = [ad]
        box._file_queue = [_make_file()]  # still more to do

        result = box._progress()
        assert result is True

    def test_completed_file_moves_to_completed_sizes(self):
        box = self._setup_box()
        dl = _make_downloader(state="COMPLETED", downloaded_size=1000)
        f = _make_file(dest="/tmp/dl/done.bin")
        f.tmp_file = "/tmp/dl/done.bin.tmp"
        ad = _ActiveDownload(f, dl)
        box._active_downloads = [ad]

        with patch("lutris.gui.widgets.download_collection_progress_box.os.rename") as mock_rename:
            with patch("lutris.gui.widgets.download_collection_progress_box.update_cache_lock"):
                box._progress()

        assert box._completed_sizes["/tmp/dl/done.bin"] == 1000
        assert box.num_files_downloaded == 1
        mock_rename.assert_called_once_with("/tmp/dl/done.bin.tmp", "/tmp/dl/done.bin")

    def test_completion_triggers_prefetch(self):
        box = self._setup_box()
        dl = _make_downloader(state="COMPLETED", downloaded_size=500)
        f = _make_file(dest="/tmp/dl/done.bin")
        f.tmp_file = "/tmp/dl/done.bin.tmp"
        ad = _ActiveDownload(f, dl)
        box._active_downloads = [ad]
        box._file_queue = [_make_file()]  # one more in queue

        with patch("lutris.gui.widgets.download_collection_progress_box.os.rename"):
            with patch("lutris.gui.widgets.download_collection_progress_box.update_cache_lock"):
                with patch.object(box, "_start_prefetch") as mock_prefetch:
                    box._progress()

        mock_prefetch.assert_called_once()

    def test_all_done_emits_complete(self):
        box = self._setup_box()
        dl = _make_downloader(state="COMPLETED", downloaded_size=1000)
        f = _make_file(dest="/tmp/dl/last.bin")
        f.tmp_file = "/tmp/dl/last.bin.tmp"
        ad = _ActiveDownload(f, dl)
        box._active_downloads = [ad]
        box._file_queue = []  # nothing left

        with patch("lutris.gui.widgets.download_collection_progress_box.os.rename"):
            with patch("lutris.gui.widgets.download_collection_progress_box.update_cache_lock"):
                result = box._progress()

        assert result is False
        box.emit.assert_called_with("complete", {})
        assert box.is_complete is True

    def test_cancelled_state_cancels_all(self):
        box = self._setup_box()
        dl1 = _make_downloader(state="CANCELLED")
        dl2 = _make_downloader(state="DOWNLOADING")
        box._active_downloads = [
            _ActiveDownload(_make_file(), dl1),
            _ActiveDownload(_make_file(), dl2),
        ]

        result = box._progress()
        assert result is False
        # Should cancel remaining downloads
        dl2.cancel.assert_called()

    def test_error_retries_independently(self):
        box = self._setup_box()
        dl = _make_downloader(state="ERROR")
        dl.error = RuntimeError("network failure")
        f = _make_file(dest="/tmp/dl/fail.bin")
        ad = _ActiveDownload(f, dl)
        ad.num_retries = 0
        box._active_downloads = [ad]
        box._file_queue = [_make_file()]  # keep loop going

        new_dl = _make_downloader(state="DOWNLOADING")
        with patch.object(box, "_create_downloader", return_value=new_dl):
            box._progress()

        assert ad.num_retries == 1
        assert ad.downloader is new_dl
        new_dl.start.assert_called_once()

    def test_error_exhausts_retries_emits_error(self):
        box = self._setup_box()
        dl = _make_downloader(state="ERROR")
        dl.error = RuntimeError("permanent failure")
        f = _make_file(dest="/tmp/dl/fail.bin")
        ad = _ActiveDownload(f, dl)
        ad.num_retries = 3  # equals max_retries
        box._active_downloads = [ad]

        result = box._progress()
        assert result is False
        box.emit.assert_called_with("error", dl.error)


# ── Tests: Cancel ────────────────────────────────────────────────────


class TestCancel(TestCase):
    def test_cancel_stops_all_active(self):
        box = DownloadCollectionProgressBox.__new__(DownloadCollectionProgressBox)
        dl1 = _make_downloader()
        dl2 = _make_downloader()
        box._active_downloads = [
            _ActiveDownload(_make_file(), dl1),
            _ActiveDownload(_make_file(), dl2),
        ]
        box.downloader = MagicMock()
        box.cancel_button = MagicMock()
        box.emit = MagicMock()

        box.on_cancel_clicked()

        dl1.cancel.assert_called_once()
        dl2.cancel.assert_called_once()
        assert box._active_downloads == []
        assert box.downloader is None
        box.emit.assert_called_once_with("cancel")

    def test_cancel_all_internal(self):
        box = DownloadCollectionProgressBox.__new__(DownloadCollectionProgressBox)
        dl = _make_downloader()
        box._active_downloads = [_ActiveDownload(_make_file(), dl)]
        box.cancel_button = MagicMock()
        box.downloader = MagicMock()

        box._cancel_all()

        dl.cancel.assert_called_once()
        assert box._active_downloads == []
        assert box.downloader is None


# ── Tests: Edge cases ────────────────────────────────────────────────


class TestEdgeCases(TestCase):
    def test_single_file_collection(self):
        """A single-file collection should work normally without prefetch."""
        box = DownloadCollectionProgressBox.__new__(DownloadCollectionProgressBox)
        f = _make_file(dest="/tmp/dl/only.bin")
        box._file_queue = [f]
        box.downloader = None
        box.is_complete = False
        box.num_files_downloaded = 0
        box._active_downloads = []
        box._completed_sizes = {}
        box.cancel_button = MagicMock()
        box.file_name_label = MagicMock()
        box.emit = MagicMock()

        mock_dl = _make_downloader()
        with patch.object(box, "_create_downloader", return_value=mock_dl):
            with patch(
                "lutris.gui.widgets.download_collection_progress_box.os.path.exists",
                return_value=False,
            ):
                with patch("lutris.gui.widgets.download_collection_progress_box.schedule_repeating_at_idle"):
                    box.start()

        # Only 1 active (no prefetch since queue is empty)
        assert len(box._active_downloads) == 1

    def test_all_cached_emits_complete(self):
        """When all files exist in cache, should immediately complete."""
        box = DownloadCollectionProgressBox.__new__(DownloadCollectionProgressBox)
        box.downloader = None
        box.is_complete = False
        box.num_files_downloaded = 0
        box._active_downloads = []
        box._completed_sizes = {}
        box.cancel_button = MagicMock()
        box.file_name_label = MagicMock()
        box.emit = MagicMock()

        f1 = _make_file(dest="/tmp/dl/cached1.bin")
        f2 = _make_file(dest="/tmp/dl/cached2.bin")
        box._file_queue = [f1, f2]

        with patch(
            "lutris.gui.widgets.download_collection_progress_box.os.path.exists",
            return_value=True,
        ):
            with patch(
                "lutris.gui.widgets.download_collection_progress_box.os.path.getsize",
                return_value=500,
            ):
                box.start()

        box.emit.assert_called_with("complete", {})
        assert box.is_complete is True
        assert box.num_files_downloaded == 2

    def test_retry_button_clears_active_downloads(self):
        """on_retry_clicked should clear active downloads and restart."""
        box = DownloadCollectionProgressBox.__new__(DownloadCollectionProgressBox)
        box._active_downloads = [
            _ActiveDownload(_make_file(), _make_downloader()),
        ]
        box.downloader = _make_downloader()
        box.cancel_cb_id = 123
        box.cancel_button = MagicMock()
        box._file_queue = [_make_file()]
        box.is_complete = False
        box.num_files_downloaded = 0
        box._completed_sizes = {}
        box.file_name_label = MagicMock()
        box.emit = MagicMock()

        mock_dl = _make_downloader()
        button = MagicMock()
        button.connect = MagicMock(return_value=456)

        with patch.object(box, "_create_downloader", return_value=mock_dl):
            with patch(
                "lutris.gui.widgets.download_collection_progress_box.os.path.exists",
                return_value=False,
            ):
                with patch("lutris.gui.widgets.download_collection_progress_box.schedule_repeating_at_idle"):
                    box.on_retry_clicked(button)

        # Active downloads should have been cleared before restart
        assert len(box._active_downloads) >= 1  # re-populated by start()
