import hashlib
import os
from unittest.mock import MagicMock

import pytest

from cli115.fetcher import Fetcher
from tests.client.conftest import make_dir, make_file


def _sha1(content: bytes) -> str:
    return hashlib.sha1(content).hexdigest().upper()


def _make_remote_file(chunks: list[bytes]) -> MagicMock:
    remote = MagicMock()
    remote.__enter__.return_value = remote
    remote.__exit__.return_value = False
    remote.read.side_effect = [*chunks, b""]
    return remote


def _make_directory_client(
    entries_by_parent: dict[str, list],
    chunks_by_file_id: dict[str, list[bytes]] | None = None,
    *,
    failing_ids: set[str] | None = None,
) -> MagicMock:
    client = MagicMock()

    def list_side_effect(directory):
        return entries_by_parent.get(directory.id, [])

    client.file.list.side_effect = list_side_effect

    failing_ids = failing_ids or set()
    chunks_by_file_id = chunks_by_file_id or {}

    def open_side_effect(entry, user_agent=None):
        if entry.id in failing_ids:
            raise RuntimeError("download failed")
        return _make_remote_file(chunks_by_file_id[entry.id])

    client.file.open.side_effect = open_side_effect
    return client


class TestFetchFile:
    def test_fetch_file_downloads_to_target_path(self, tmp_path):
        content = b"hello fetcher"
        remote_file = make_file(
            name="remote.bin",
            path="/remote/remote.bin",
            size=len(content),
            sha1=_sha1(content),
        )

        client = MagicMock()
        client.file.open.return_value = _make_remote_file([content])

        fetcher = Fetcher(client)
        output = tmp_path / "out.bin"
        result = fetcher.fetch(remote_file, str(output), check_integrity=True)

        assert result == str(output.resolve())
        assert output.read_bytes() == content
        client.file.open.assert_called_once_with(remote_file, user_agent=None)
        assert len(fetcher.entries) == 1

    def test_fetch_file_integrity_error_removes_partial_file(self, tmp_path):
        content = b"broken"
        remote_file = make_file(
            name="broken.bin",
            path="/remote/broken.bin",
            size=len(content),
            sha1="BADSHA1",
        )

        client = MagicMock()
        client.file.open.return_value = _make_remote_file([content])

        fetcher = Fetcher(client)
        output = tmp_path / "broken.bin"

        with pytest.raises(ValueError, match="sha1 mismatch"):
            fetcher.fetch(remote_file, str(output), check_integrity=True)

        assert not output.exists()


class TestFetchDirectory:
    def test_fetch_directory_downloads_nested_structure(self, tmp_path):
        root = make_dir(name="root", id="50", path="/remote/root")
        docs = make_dir(name="docs", id="51", parent_id="50", path="/remote/root/docs")
        images = make_dir(
            name="images",
            id="52",
            parent_id="50",
            path="/remote/root/images",
        )
        icons = make_dir(
            name="icons",
            id="53",
            parent_id="52",
            path="/remote/root/images/icons",
        )

        root_file_content = b"root file"
        doc_file_content = b"doc file"
        image_file_content = b"image bytes"
        icon_file_content = b"icon bytes"

        root_file = make_file(
            name="README.md",
            id="60",
            parent_id="50",
            path="/remote/root/README.md",
            size=len(root_file_content),
            sha1=_sha1(root_file_content),
        )
        doc_file = make_file(
            name="guide.txt",
            id="61",
            parent_id="51",
            path="/remote/root/docs/guide.txt",
            size=len(doc_file_content),
            sha1=_sha1(doc_file_content),
        )
        image_file = make_file(
            name="logo.png",
            id="62",
            parent_id="52",
            path="/remote/root/images/logo.png",
            size=len(image_file_content),
            sha1=_sha1(image_file_content),
        )
        icon_file = make_file(
            name="app.ico",
            id="63",
            parent_id="53",
            path="/remote/root/images/icons/app.ico",
            size=len(icon_file_content),
            sha1=_sha1(icon_file_content),
        )

        client = _make_directory_client(
            {
                root.id: [docs, images, root_file],
                docs.id: [doc_file],
                images.id: [icons, image_file],
                icons.id: [icon_file],
            },
            {
                root_file.id: [root_file_content],
                doc_file.id: [doc_file_content],
                image_file.id: [image_file_content],
                icon_file.id: [icon_file_content],
            },
        )

        output_dir = tmp_path / "downloads"

        fetcher = Fetcher(client)
        fetcher.fetch(root, str(output_dir))

        expected_root = output_dir
        assert (expected_root / "README.md").read_bytes() == root_file_content
        assert (expected_root / "docs").is_dir()
        assert (expected_root / "docs" / "guide.txt").read_bytes() == doc_file_content
        assert (expected_root / "images").is_dir()
        assert (
            expected_root / "images" / "logo.png"
        ).read_bytes() == image_file_content
        assert (expected_root / "images" / "icons").is_dir()
        assert (
            expected_root / "images" / "icons" / "app.ico"
        ).read_bytes() == icon_file_content
        assert client.file.open.call_count == 4
        assert len(fetcher.entries) == 4

    def test_fetch_directory_include_and_exclude(self, tmp_path):
        root = make_dir(name="root", id="10", path="/remote/root")
        sub = make_dir(name="sub", id="11", parent_id="10", path="/remote/root/sub")

        keep_content = b"keep"
        skip_content = b"skip"
        nested_content = b"nest"

        keep_file = make_file(
            name="keep.txt",
            id="20",
            parent_id="10",
            path="/remote/root/keep.txt",
            size=len(keep_content),
            sha1=_sha1(keep_content),
        )
        skip_file = make_file(
            name="skip.log",
            id="21",
            parent_id="10",
            path="/remote/root/skip.log",
            size=len(skip_content),
            sha1=_sha1(skip_content),
        )
        nested_file = make_file(
            name="nested.txt",
            id="22",
            parent_id="11",
            path="/remote/root/sub/nested.txt",
            size=len(nested_content),
            sha1=_sha1(nested_content),
        )

        client = _make_directory_client(
            {
                root.id: [sub, keep_file, skip_file],
                sub.id: [nested_file],
            },
            {
                keep_file.id: [keep_content],
                skip_file.id: [skip_content],
                nested_file.id: [nested_content],
            },
        )

        output_dir = tmp_path / "downloads"

        fetcher = Fetcher(client)
        fetcher.fetch(
            root,
            str(output_dir),
            include=["**/*.txt"],
            exclude=["sub/**"],
        )

        expected_root = output_dir
        assert (expected_root / "keep.txt").exists()
        assert not (expected_root / "skip.log").exists()
        assert not (expected_root / "sub" / "nested.txt").exists()
        assert client.file.open.call_count == 1
        assert len(fetcher.entries) == 1

    def test_fetch_directory_error(self, tmp_path):
        root = make_dir(name="root", id="40", path="/remote/root")
        bad_file = make_file(
            name="bad.txt",
            id="41",
            parent_id="40",
            path="/remote/root/bad.txt",
            size=3,
            sha1=_sha1(b"bad"),
        )
        ok_file = make_file(
            name="ok.txt",
            id="42",
            parent_id="40",
            path="/remote/root/ok.txt",
            size=2,
            sha1=_sha1(b"ok"),
        )

        client = _make_directory_client(
            {root.id: [bad_file, ok_file]},
            {ok_file.id: [b"ok"]},
            failing_ids={bad_file.id},
        )

        output_dir = tmp_path / "out"

        fetcher = Fetcher(client)
        fetcher.fetch(root, str(output_dir))

        by_id = {entry.remote_entry.id: entry for entry in fetcher.entries}
        assert str(by_id[bad_file.id].error) == "download failed"
        assert by_id[ok_file.id].error is None
        assert (output_dir / "ok.txt").exists()
