# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.

import hashlib
import importlib.util
import sys
import types
from pathlib import Path
from unittest import mock

from mozunit import main

# Pre-populate sys.modules with stub modules for mardor so the script's
# top-level "from mardor.* import ..." statements resolve without needing
# the real mardor package installed in the test environment. This must
# run before the script is loaded below; a fixture would fire too late.
mardor_pkg = types.ModuleType("mardor")
mardor_reader = types.ModuleType("mardor.reader")
mardor_reader.MarReader = object
mardor_signing = types.ModuleType("mardor.signing")
mardor_signing.get_keysize = lambda _cert: 4096
sys.modules.setdefault("mardor", mardor_pkg)
sys.modules.setdefault("mardor.reader", mardor_reader)
sys.modules.setdefault("mardor.signing", mardor_signing)

# tools/update-packaging is a script directory, not a Python package, so
# `import make_incremental_zucchini` wouldn't find it. Load the file by
# absolute path through importlib instead.
SCRIPT_PATH = Path(__file__).resolve().parent.parent / "make_incremental_zucchini.py"
spec = importlib.util.spec_from_file_location("make_incremental_zucchini", SCRIPT_PATH)
mz = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mz)


def _write(path, content):
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_bytes(content)


def _sha256(path):
    """Compute sha256 of a file via stdlib so test fixtures stay independent
    of the script's own get_hash helper."""
    return hashlib.sha256(Path(path).read_bytes()).hexdigest()


def test_hash_dir_walks_files(tmp_path):
    _write(tmp_path / "a.txt", b"alpha")
    _write(tmp_path / "sub/b.txt", b"beta")
    _write(tmp_path / "sub/nested/c.txt", b"gamma")

    hashes = mz._hash_dir(str(tmp_path))

    assert hashes == {
        "a.txt": _sha256(tmp_path / "a.txt"),
        "sub/b.txt": _sha256(tmp_path / "sub/b.txt"),
        "sub/nested/c.txt": _sha256(tmp_path / "sub/nested/c.txt"),
    }


def test_hash_dir_empty(tmp_path):
    assert mz._hash_dir(str(tmp_path)) == {}


def _make_cache_setup(tmp_path, rel_path, from_bytes, to_bytes, cache_state):
    """Build a fixture: returns (cache_entry, partials_dir, manifest_file,
    cache_extracted_dir) and writes the local from/to files.
    cache_state is "patch", "full", or "missing"."""
    local_from = tmp_path / "from" / rel_path
    local_to = tmp_path / "to" / rel_path
    _write(local_from, from_bytes)
    _write(local_to, to_bytes)

    cache_extracted_dir = tmp_path / "cache_extracted"
    cache_extracted_dir.mkdir()
    if cache_state == "patch":
        _write(cache_extracted_dir / f"{rel_path}.patch", b"PATCH-BYTES")
    elif cache_state == "full":
        _write(cache_extracted_dir / rel_path, to_bytes)
    # "missing" leaves the cache dir empty

    partials_dir = tmp_path / "partials"
    partials_dir.mkdir()
    manifest_file = tmp_path / "updatev3.manifest"
    manifest_file.write_text("")

    cache_entry = {
        "from_mar_files": {rel_path: _sha256(local_from)},
        "to_mar_files": {rel_path: _sha256(local_to)},
    }
    return (
        cache_entry,
        str(local_from),
        str(local_to),
        str(cache_extracted_dir),
        str(partials_dir),
        str(manifest_file),
    )


def test_cache_lookup_patch_hit(tmp_path):
    rel = "firefox.exe"
    (
        cache_entry,
        local_from,
        local_to,
        cache_extracted_dir,
        partials_dir,
        manifest_file,
    ) = _make_cache_setup(tmp_path, rel, b"OLD", b"NEW", "patch")

    result = mz._maybe_use_cached_file(
        cache_entry,
        rel,
        cache_entry["from_mar_files"][rel],
        cache_entry["to_mar_files"][rel],
        cache_extracted_dir,
        partials_dir,
        manifest_file,
    )

    assert result == f"{rel}.patch"
    assert (Path(partials_dir) / f"{rel}.patch").read_bytes() == b"PATCH-BYTES"
    assert Path(manifest_file).read_text() == f'patch "{rel}.patch" "{rel}"\n'


def test_cache_lookup_full_hit(tmp_path):
    rel = "libxul.so"
    (
        cache_entry,
        local_from,
        local_to,
        cache_extracted_dir,
        partials_dir,
        manifest_file,
    ) = _make_cache_setup(tmp_path, rel, b"OLD", b"NEW", "full")

    result = mz._maybe_use_cached_file(
        cache_entry,
        rel,
        cache_entry["from_mar_files"][rel],
        cache_entry["to_mar_files"][rel],
        cache_extracted_dir,
        partials_dir,
        manifest_file,
    )

    assert result == rel
    assert (Path(partials_dir) / rel).read_bytes() == b"NEW"
    assert Path(manifest_file).read_text() == f'add "{rel}"\n'


def test_cache_lookup_from_hash_miss(tmp_path):
    rel = "firefox.exe"
    (
        cache_entry,
        local_from,
        local_to,
        cache_extracted_dir,
        partials_dir,
        manifest_file,
    ) = _make_cache_setup(tmp_path, rel, b"OLD", b"NEW", "patch")

    result = mz._maybe_use_cached_file(
        cache_entry,
        rel,
        "deadbeef" * 8,
        cache_entry["to_mar_files"][rel],
        cache_extracted_dir,
        partials_dir,
        manifest_file,
    )

    assert result is None
    assert Path(manifest_file).read_text() == ""
    assert not (Path(partials_dir) / f"{rel}.patch").exists()


def test_cache_lookup_to_hash_miss(tmp_path):
    rel = "firefox.exe"
    (
        cache_entry,
        local_from,
        local_to,
        cache_extracted_dir,
        partials_dir,
        manifest_file,
    ) = _make_cache_setup(tmp_path, rel, b"OLD", b"NEW", "patch")

    result = mz._maybe_use_cached_file(
        cache_entry,
        rel,
        cache_entry["from_mar_files"][rel],
        "deadbeef" * 8,
        cache_extracted_dir,
        partials_dir,
        manifest_file,
    )

    assert result is None
    assert Path(manifest_file).read_text() == ""


def test_cache_lookup_relpath_absent_from_index(tmp_path):
    rel = "firefox.exe"
    (
        cache_entry,
        local_from,
        local_to,
        cache_extracted_dir,
        partials_dir,
        manifest_file,
    ) = _make_cache_setup(tmp_path, rel, b"OLD", b"NEW", "patch")

    cache_entry["from_mar_files"].pop(rel)
    cache_entry["to_mar_files"].pop(rel)

    result = mz._maybe_use_cached_file(
        cache_entry,
        rel,
        "deadbeef" * 8,
        "deadbeef" * 8,
        cache_extracted_dir,
        partials_dir,
        manifest_file,
    )

    assert result is None


def test_cache_lookup_hashes_match_but_file_missing(tmp_path):
    rel = "firefox.exe"
    (
        cache_entry,
        local_from,
        local_to,
        cache_extracted_dir,
        partials_dir,
        manifest_file,
    ) = _make_cache_setup(tmp_path, rel, b"OLD", b"NEW", "missing")

    result = mz._maybe_use_cached_file(
        cache_entry,
        rel,
        cache_entry["from_mar_files"][rel],
        cache_entry["to_mar_files"][rel],
        cache_extracted_dir,
        partials_dir,
        manifest_file,
    )

    assert result is None
    assert Path(manifest_file).read_text() == ""


def test_cache_lookup_none_local_hash(tmp_path):
    rel = "firefox.exe"
    (
        cache_entry,
        local_from,
        local_to,
        cache_extracted_dir,
        partials_dir,
        manifest_file,
    ) = _make_cache_setup(tmp_path, rel, b"OLD", b"NEW", "patch")

    result = mz._maybe_use_cached_file(
        cache_entry,
        rel,
        None,
        cache_entry["to_mar_files"][rel],
        cache_extracted_dir,
        partials_dir,
        manifest_file,
    )

    assert result is None


def test_download_file_succeeds_first_try(tmp_path):
    url = "https://archive.mozilla.org/firefox.mar"
    dest = str(tmp_path / "out.mar")

    with mock.patch.object(mz.urllib.request, "urlretrieve") as urlretrieve:
        with mock.patch.object(mz.time, "sleep") as sleep:
            mz.download_file(url, dest, allow_staging=False)

    urlretrieve.assert_called_once_with(url, dest)
    sleep.assert_not_called()


def test_download_file_retries_then_succeeds(tmp_path):
    url = "https://archive.mozilla.org/firefox.mar"
    dest = str(tmp_path / "out.mar")

    with mock.patch.object(
        mz.urllib.request,
        "urlretrieve",
        side_effect=[OSError("boom"), OSError("boom"), None],
    ) as urlretrieve:
        with mock.patch.object(mz.time, "sleep") as sleep:
            mz.download_file(url, dest, allow_staging=False)

    assert urlretrieve.call_count == 3
    assert sleep.call_count == 2


def test_download_file_raises_after_exhausting_retries(tmp_path):
    url = "https://archive.mozilla.org/firefox.mar"
    dest = str(tmp_path / "out.mar")

    with mock.patch.object(
        mz.urllib.request,
        "urlretrieve",
        side_effect=OSError("boom"),
    ) as urlretrieve:
        with mock.patch.object(mz.time, "sleep"):
            try:
                mz.download_file(url, dest, allow_staging=False)
            except mz.DownloadError:
                pass
            else:
                raise AssertionError("Expected DownloadError to be raised")

    assert urlretrieve.call_count == mz.DOWNLOAD_ATTEMPTS


if __name__ == "__main__":
    main()
