# -*- coding: utf-8 -*-
"""Tests for backup restore directory swapping."""
from __future__ import annotations

import errno
import io
import zipfile
from contextlib import nullcontext
from pathlib import Path
from unittest.mock import patch

import pytest

from qwenpaw.backup._utils._mount_swap import (
    OLD_CONTENT_DIR_NAME,
    RESERVED_NAMES,
    STATE_COMMITTED,
    STATE_EVACUATING_OLD,
    STATE_FILE_NAME,
    STATE_INSTALLING_NEW,
    STATE_TMP_FILE_NAME,
)
from qwenpaw.backup._utils import _mount_swap
from qwenpaw.backup._utils import safe_swap as safe_swap_module
from qwenpaw.backup._utils.safe_swap import (
    cleanup_stale_restore_artifacts,
    cleanup_startup_restore_artifacts,
    commit_tmp,
    extract_to_tmp,
)

_RESTORE_TMP_SUFFIX = ".restore_tmp"


def _make_zip(entries: dict[str, str]) -> zipfile.ZipFile:
    buffer = io.BytesIO()
    with zipfile.ZipFile(buffer, "w") as zf:
        for name, content in entries.items():
            zf.writestr(name, content)
    buffer.seek(0)
    return zipfile.ZipFile(buffer, "r")


def _snapshot(root: Path) -> dict[str, str]:
    return {
        path.relative_to(root).as_posix(): path.read_text(encoding="utf-8")
        for path in sorted(root.rglob("*"))
        if path.is_file()
    }


def _tmp_dir(dst: Path) -> Path:
    return dst.with_name(dst.name + _RESTORE_TMP_SUFFIX)


@pytest.fixture(name="secrets_dir")
def _secrets_dir(tmp_path: Path) -> Path:
    dst = tmp_path / "secrets"
    dst.mkdir()
    return dst


def test_normal_directory_uses_rename_swap(secrets_dir: Path) -> None:
    dst = secrets_dir
    (dst / "old.txt").write_text("old", encoding="utf-8")

    zf = _make_zip({"data/secrets/new.txt": "new"})
    with patch(
        "qwenpaw.backup._utils._mount_swap.is_mount_point",
        return_value=False,
    ), patch(
        "qwenpaw.backup._utils._mount_swap.swap_mount_point_contents",
    ) as mount_swap:
        extract_to_tmp(zf, "data/secrets/", dst, zip_slip_base=dst)
        commit_tmp(dst)

    mount_swap.assert_not_called()
    assert _snapshot(dst) == {"new.txt": "new"}
    assert not _tmp_dir(dst).exists()
    assert not dst.with_name("secrets.restore_old").exists()


def test_mount_point_swap_replaces_contents(secrets_dir: Path) -> None:
    dst = secrets_dir
    (dst / "old.txt").write_text("old", encoding="utf-8")
    (dst / "nested").mkdir()
    (dst / "nested" / "old.txt").write_text("old nested", encoding="utf-8")

    zf = _make_zip(
        {
            "data/secrets/new.txt": "new",
            "data/secrets/nested/new.txt": "new nested",
        },
    )
    with patch(
        "qwenpaw.backup._utils._mount_swap.is_mount_point",
        return_value=True,
    ):
        extract_to_tmp(zf, "data/secrets/", dst, zip_slip_base=dst)
        commit_tmp(dst)

    assert _snapshot(dst) == {
        "nested/new.txt": "new nested",
        "new.txt": "new",
    }
    assert not (dst / OLD_CONTENT_DIR_NAME).exists()
    assert not (dst / STATE_FILE_NAME).exists()
    assert not _tmp_dir(dst).exists()


def test_ebusy_rename_falls_back_to_mount_point_swap(
    secrets_dir: Path,
) -> None:
    dst = secrets_dir
    (dst / "old.txt").write_text("old", encoding="utf-8")

    original_rename = Path.rename

    def rename_or_ebusy(self: Path, target: Path) -> Path:
        if self == dst:
            raise OSError(errno.EBUSY, "Device or resource busy")
        return original_rename(self, target)

    zf = _make_zip({"data/secrets/new.txt": "new"})
    with patch(
        "qwenpaw.backup._utils._mount_swap.is_mount_point",
        return_value=False,
    ), patch.object(Path, "rename", rename_or_ebusy):
        extract_to_tmp(zf, "data/secrets/", dst, zip_slip_base=dst)
        commit_tmp(dst)

    assert _snapshot(dst) == {"new.txt": "new"}


def test_mount_point_swap_failure_restores_old_contents(
    secrets_dir: Path,
) -> None:
    dst = secrets_dir
    (dst / "old.txt").write_text("old", encoding="utf-8")

    zf = _make_zip({"data/secrets/new.txt": "new"})
    original_move_children = getattr(_mount_swap, "_move_children")
    move_calls = 0

    def move_or_fail(*args, **kwargs):
        nonlocal move_calls
        move_calls += 1
        if move_calls == 2:
            raise OSError("simulated install failure")
        return original_move_children(*args, **kwargs)

    with patch(
        "qwenpaw.backup._utils._mount_swap.is_mount_point",
        return_value=True,
    ), patch(
        "qwenpaw.backup._utils._mount_swap._move_children",
        move_or_fail,
    ), pytest.raises(
        OSError,
        match="simulated install failure",
    ):
        extract_to_tmp(zf, "data/secrets/", dst, zip_slip_base=dst)
        commit_tmp(dst)

    assert _snapshot(dst) == {"old.txt": "old"}
    assert not (dst / OLD_CONTENT_DIR_NAME).exists()
    assert not (dst / STATE_FILE_NAME).exists()
    assert not _tmp_dir(dst).exists()


def test_cleanup_rolls_back_evacuating_old_state(
    secrets_dir: Path,
) -> None:
    dst = secrets_dir
    old_dir = dst / OLD_CONTENT_DIR_NAME
    old_dir.mkdir()
    (old_dir / "old-a.txt").write_text("old a", encoding="utf-8")
    (dst / "old-b.txt").write_text("old b", encoding="utf-8")
    (dst / STATE_FILE_NAME).write_text(
        STATE_EVACUATING_OLD,
        encoding="utf-8",
    )
    _tmp_dir(dst).mkdir()
    (_tmp_dir(dst) / "new.txt").write_text("new", encoding="utf-8")

    cleanup_stale_restore_artifacts(dst)

    assert _snapshot(dst) == {
        "old-a.txt": "old a",
        "old-b.txt": "old b",
    }
    assert not old_dir.exists()
    assert not _tmp_dir(dst).exists()
    assert not (dst / STATE_FILE_NAME).exists()


def test_cleanup_rolls_back_installing_new_state(
    secrets_dir: Path,
) -> None:
    dst = secrets_dir
    old_dir = dst / OLD_CONTENT_DIR_NAME
    old_dir.mkdir()
    (old_dir / "old.txt").write_text("old", encoding="utf-8")
    (dst / "new-partial.txt").write_text("new", encoding="utf-8")
    (dst / STATE_FILE_NAME).write_text(
        STATE_INSTALLING_NEW,
        encoding="utf-8",
    )
    _tmp_dir(dst).mkdir()
    (_tmp_dir(dst) / "new-rest.txt").write_text("new", encoding="utf-8")

    cleanup_stale_restore_artifacts(dst)

    assert _snapshot(dst) == {"old.txt": "old"}
    assert not old_dir.exists()
    assert not _tmp_dir(dst).exists()
    assert not (dst / STATE_FILE_NAME).exists()


def test_cleanup_finishes_committed_state(secrets_dir: Path) -> None:
    dst = secrets_dir
    old_dir = dst / OLD_CONTENT_DIR_NAME
    old_dir.mkdir()
    (old_dir / "old.txt").write_text("old", encoding="utf-8")
    (dst / "new.txt").write_text("new", encoding="utf-8")
    (dst / STATE_FILE_NAME).write_text(STATE_COMMITTED, encoding="utf-8")
    _tmp_dir(dst).mkdir()

    cleanup_stale_restore_artifacts(dst)
    cleanup_stale_restore_artifacts(dst)

    assert _snapshot(dst) == {"new.txt": "new"}
    assert not old_dir.exists()
    assert not _tmp_dir(dst).exists()
    assert not (dst / STATE_FILE_NAME).exists()


def test_cleanup_leaves_markerless_old_content_dir_untouched(
    secrets_dir: Path,
) -> None:
    dst = secrets_dir
    old_dir = dst / OLD_CONTENT_DIR_NAME
    old_dir.mkdir()
    (old_dir / "payload.txt").write_text("keep", encoding="utf-8")
    (dst / "live.txt").write_text("live", encoding="utf-8")

    cleanup_stale_restore_artifacts(dst)

    assert _snapshot(dst) == {
        f"{OLD_CONTENT_DIR_NAME}/payload.txt": "keep",
        "live.txt": "live",
    }
    assert old_dir.exists()


def test_mount_point_restore_rejects_existing_markerless_old_dir(
    secrets_dir: Path,
) -> None:
    dst = secrets_dir
    old_dir = dst / OLD_CONTENT_DIR_NAME
    old_dir.mkdir()
    (old_dir / "payload.txt").write_text("keep", encoding="utf-8")
    (dst / "live.txt").write_text("live", encoding="utf-8")

    zf = _make_zip({"data/secrets/new.txt": "new"})
    with patch(
        "qwenpaw.backup._utils._mount_swap.is_mount_point",
        return_value=True,
    ), pytest.raises(RuntimeError, match="Reserved restore directory exists"):
        extract_to_tmp(zf, "data/secrets/", dst, zip_slip_base=dst)
        commit_tmp(dst)

    assert _snapshot(dst) == {
        f"{OLD_CONTENT_DIR_NAME}/payload.txt": "keep",
        "live.txt": "live",
    }
    assert _snapshot(_tmp_dir(dst)) == {"new.txt": "new"}
    assert not (dst / STATE_FILE_NAME).exists()
    assert not (dst / STATE_TMP_FILE_NAME).exists()


def test_startup_cleanup_recovers_all_restore_targets(
    tmp_path: Path,
) -> None:
    targets = [
        tmp_path / "skill_pool",
        tmp_path / "workspace",
    ]
    for target in targets:
        target.mkdir()
        old_dir = target / OLD_CONTENT_DIR_NAME
        old_dir.mkdir()
        (old_dir / "old.txt").write_text("old", encoding="utf-8")
        (target / "new-partial.txt").write_text("new", encoding="utf-8")
        (target / STATE_FILE_NAME).write_text(
            STATE_INSTALLING_NEW,
            encoding="utf-8",
        )
        _tmp_dir(target).mkdir()

    with patch(
        "qwenpaw.backup._utils.safe_swap._startup_restore_targets",
        return_value=targets,
    ), patch(
        "qwenpaw.backup._utils.safe_swap.restore_process_lock",
        return_value=nullcontext(),
    ):
        cleanup_startup_restore_artifacts()

    for target in targets:
        assert _snapshot(target) == {"old.txt": "old"}
        assert not (target / OLD_CONTENT_DIR_NAME).exists()
        assert not _tmp_dir(target).exists()
        assert not (target / STATE_FILE_NAME).exists()


def test_reserved_restore_names_are_not_extracted(
    secrets_dir: Path,
) -> None:
    dst = secrets_dir
    entries = {
        f"data/secrets/{STATE_FILE_NAME}": "state",
        f"data/secrets/{STATE_TMP_FILE_NAME}": "state tmp",
        f"data/secrets/{OLD_CONTENT_DIR_NAME}/payload.txt": "old",
        f"data/secrets/nested/{STATE_FILE_NAME}/payload.txt": "nested state",
        f"data/secrets/nested/{STATE_TMP_FILE_NAME}": "nested state tmp",
        (
            f"data/secrets/nested/{OLD_CONTENT_DIR_NAME}/payload.txt"
        ): "nested old",
        "data/secrets/legit.txt": "legit",
    }

    zf = _make_zip(entries)
    extract_to_tmp(zf, "data/secrets/", dst, zip_slip_base=dst)

    tmp_dst = _tmp_dir(dst)
    assert _snapshot(tmp_dst) == {
        "legit.txt": "legit",
        f"nested/{OLD_CONTENT_DIR_NAME}/payload.txt": "nested old",
        f"nested/{STATE_FILE_NAME}/payload.txt": "nested state",
        f"nested/{STATE_TMP_FILE_NAME}": "nested state tmp",
    }
    for reserved_name in RESERVED_NAMES:
        assert not (tmp_dst / reserved_name).exists()


def test_find_busy_restore_paths_reports_specific_child(
    monkeypatch: pytest.MonkeyPatch,
    tmp_path: Path,
) -> None:
    root = tmp_path / "workspace"
    busy = root / "browser"
    nested = busy / "user_data"
    ok = root / "files"
    nested.mkdir(parents=True)
    ok.mkdir(parents=True)

    def fake_assert_directory_renamable(path: Path) -> None:
        if path in {root, busy}:
            raise PermissionError("locked")

    monkeypatch.setattr(safe_swap_module.os, "name", "nt")
    monkeypatch.setattr(
        safe_swap_module,
        "assert_directory_renamable",
        fake_assert_directory_renamable,
    )

    assert safe_swap_module.find_busy_restore_paths(root) == [busy]
