import errno
import os
import sys
from contextlib import contextmanager
from pathlib import Path

import pytest

from ...constants import *  # NOQA
from ...constants import CACHE_TAG_NAME, CACHE_TAG_CONTENTS
from ...helpers.fs import (
    dir_is_tagged,
    get_base_dir,
    get_cache_dir,
    get_keys_dir,
    get_security_dir,
    get_config_dir,
    get_data_dir,
    get_runtime_dir,
    dash_open,
    safe_unlink,
    remove_dotdot_prefixes,
    make_path_safe,
    map_chars,
)
from ...platform import is_win32, is_darwin
from .. import are_hardlinks_supported
from .. import rejected_dotdot_paths


def test_get_base_dir(monkeypatch):
    """test that get_base_dir respects environment"""
    monkeypatch.delenv("BORG_BASE_DIR", raising=False)
    assert get_base_dir() is None
    monkeypatch.setenv("BORG_BASE_DIR", "/var/tmp/base")
    assert get_base_dir() == "/var/tmp/base"


def test_get_config_dir(monkeypatch):
    """test that get_config_dir respects environment"""
    monkeypatch.delenv("BORG_BASE_DIR", raising=False)
    home_dir = os.path.expanduser("~")
    if is_win32:
        monkeypatch.delenv("BORG_CONFIG_DIR", raising=False)
        assert get_config_dir(create=False) == os.path.join(home_dir, "AppData", "Local", "borg", "borg")
        monkeypatch.setenv("BORG_CONFIG_DIR", home_dir)
        assert get_config_dir(create=False) == home_dir
    elif is_darwin:
        monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)
        monkeypatch.delenv("BORG_CONFIG_DIR", raising=False)
        assert get_config_dir(create=False) == os.path.join(home_dir, "Library", "Application Support", "borg")
        monkeypatch.setenv("XDG_CONFIG_HOME", "/var/tmp/.config")
        assert get_config_dir(create=False) == os.path.join("/var/tmp/.config", "borg")
        monkeypatch.setenv("BORG_CONFIG_DIR", "/var/tmp")
        assert get_config_dir(create=False) == "/var/tmp"
    else:
        monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)
        monkeypatch.delenv("BORG_CONFIG_DIR", raising=False)
        assert get_config_dir(create=False) == os.path.join(home_dir, ".config", "borg")
        monkeypatch.setenv("XDG_CONFIG_HOME", "/var/tmp/.config")
        assert get_config_dir(create=False) == os.path.join("/var/tmp/.config", "borg")
        monkeypatch.setenv("BORG_CONFIG_DIR", "/var/tmp")
        assert get_config_dir(create=False) == "/var/tmp"


def test_get_cache_dir(monkeypatch):
    """test that get_cache_dir respects environment"""
    monkeypatch.delenv("BORG_BASE_DIR", raising=False)
    home_dir = os.path.expanduser("~")
    if is_win32:
        monkeypatch.delenv("BORG_CACHE_DIR", raising=False)
        assert get_cache_dir(create=False) == os.path.join(home_dir, "AppData", "Local", "borg", "borg", "Cache")
        monkeypatch.setenv("BORG_CACHE_DIR", home_dir)
        assert get_cache_dir(create=False) == home_dir
    elif is_darwin:
        monkeypatch.delenv("XDG_CACHE_HOME", raising=False)
        monkeypatch.delenv("BORG_CACHE_DIR", raising=False)
        assert get_cache_dir(create=False) == os.path.join(home_dir, "Library", "Caches", "borg")
        monkeypatch.setenv("XDG_CACHE_HOME", "/var/tmp/.cache")
        assert get_cache_dir(create=False) == os.path.join("/var/tmp/.cache", "borg")
        monkeypatch.setenv("BORG_CACHE_DIR", "/var/tmp")
        assert get_cache_dir(create=False) == "/var/tmp"
    else:
        monkeypatch.delenv("XDG_CACHE_HOME", raising=False)
        monkeypatch.delenv("BORG_CACHE_DIR", raising=False)
        assert get_cache_dir(create=False) == os.path.join(home_dir, ".cache", "borg")
        monkeypatch.setenv("XDG_CACHE_HOME", "/var/tmp/.cache")
        assert get_cache_dir(create=False) == os.path.join("/var/tmp/.cache", "borg")
        monkeypatch.setenv("BORG_CACHE_DIR", "/var/tmp")
        assert get_cache_dir(create=False) == "/var/tmp"


def test_get_keys_dir(monkeypatch):
    """test that get_keys_dir respects environment"""
    monkeypatch.delenv("BORG_BASE_DIR", raising=False)
    home_dir = os.path.expanduser("~")
    if is_win32:
        monkeypatch.delenv("BORG_KEYS_DIR", raising=False)
        assert get_keys_dir(create=False) == os.path.join(home_dir, "AppData", "Local", "borg", "borg", "keys")
        monkeypatch.setenv("BORG_KEYS_DIR", home_dir)
        assert get_keys_dir(create=False) == home_dir
    elif is_darwin:
        monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)
        monkeypatch.delenv("BORG_KEYS_DIR", raising=False)
        assert get_keys_dir(create=False) == os.path.join(home_dir, "Library", "Application Support", "borg", "keys")
        monkeypatch.setenv("XDG_CONFIG_HOME", "/var/tmp/.config")
        assert get_keys_dir(create=False) == os.path.join("/var/tmp/.config", "borg", "keys")
        monkeypatch.setenv("BORG_KEYS_DIR", "/var/tmp")
        assert get_keys_dir(create=False) == "/var/tmp"
    else:
        monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)
        monkeypatch.delenv("BORG_KEYS_DIR", raising=False)
        assert get_keys_dir(create=False) == os.path.join(home_dir, ".config", "borg", "keys")
        monkeypatch.setenv("XDG_CONFIG_HOME", "/var/tmp/.config")
        assert get_keys_dir(create=False) == os.path.join("/var/tmp/.config", "borg", "keys")
        monkeypatch.setenv("BORG_KEYS_DIR", "/var/tmp")
        assert get_keys_dir(create=False) == "/var/tmp"


def test_get_security_dir(monkeypatch):
    """test that get_security_dir respects environment"""
    monkeypatch.delenv("BORG_BASE_DIR", raising=False)
    home_dir = os.path.expanduser("~")
    if is_win32:
        monkeypatch.delenv("BORG_SECURITY_DIR", raising=False)
        assert get_security_dir(create=False) == os.path.join(home_dir, "AppData", "Local", "borg", "borg", "security")
        assert get_security_dir(repository_id="1234", create=False) == os.path.join(
            home_dir, "AppData", "Local", "borg", "borg", "security", "1234"
        )
        monkeypatch.setenv("BORG_SECURITY_DIR", home_dir)
        assert get_security_dir(create=False) == home_dir
    elif is_darwin:
        monkeypatch.delenv("XDG_DATA_HOME", raising=False)
        monkeypatch.delenv("BORG_SECURITY_DIR", raising=False)
        assert get_security_dir(create=False) == os.path.join(
            home_dir, "Library", "Application Support", "borg", "security"
        )
        assert get_security_dir(repository_id="1234", create=False) == os.path.join(
            home_dir, "Library", "Application Support", "borg", "security", "1234"
        )
        monkeypatch.setenv("XDG_DATA_HOME", "/var/tmp/.config")
        assert get_security_dir(create=False) == os.path.join("/var/tmp/.config", "borg", "security")
        monkeypatch.setenv("BORG_SECURITY_DIR", "/var/tmp")
        assert get_security_dir(create=False) == "/var/tmp"
    else:
        monkeypatch.delenv("XDG_DATA_HOME", raising=False)
        monkeypatch.delenv("BORG_SECURITY_DIR", raising=False)
        assert get_security_dir(create=False) == os.path.join(home_dir, ".local", "share", "borg", "security")
        assert get_security_dir(repository_id="1234", create=False) == os.path.join(
            home_dir, ".local", "share", "borg", "security", "1234"
        )
        monkeypatch.setenv("XDG_DATA_HOME", "/var/tmp/.config")
        assert get_security_dir(create=False) == os.path.join("/var/tmp/.config", "borg", "security")
        monkeypatch.setenv("BORG_SECURITY_DIR", "/var/tmp")
        assert get_security_dir(create=False) == "/var/tmp"


def test_get_runtime_dir(monkeypatch):
    """test that get_runtime_dir respects environment"""
    monkeypatch.delenv("BORG_BASE_DIR", raising=False)
    home_dir = os.path.expanduser("~")
    if is_win32:
        monkeypatch.delenv("BORG_RUNTIME_DIR", raising=False)
        assert get_runtime_dir(create=False) == os.path.join(home_dir, "AppData", "Local", "Temp", "borg", "borg")
        monkeypatch.setenv("BORG_RUNTIME_DIR", home_dir)
        assert get_runtime_dir(create=False) == home_dir
    elif is_darwin:
        monkeypatch.delenv("XDG_RUNTIME_DIR", raising=False)
        monkeypatch.delenv("BORG_RUNTIME_DIR", raising=False)
        assert get_runtime_dir(create=False) == os.path.join(home_dir, "Library", "Caches", "TemporaryItems", "borg")
        monkeypatch.setenv("XDG_RUNTIME_DIR", "/var/tmp/.cache")
        assert get_runtime_dir(create=False) == os.path.join("/var/tmp/.cache", "borg")
        monkeypatch.setenv("BORG_RUNTIME_DIR", "/var/tmp")
        assert get_runtime_dir(create=False) == "/var/tmp"
    else:
        monkeypatch.delenv("XDG_RUNTIME_DIR", raising=False)
        monkeypatch.delenv("BORG_RUNTIME_DIR", raising=False)
        uid = str(os.getuid())
        assert get_runtime_dir(create=False) in [
            os.path.join("/run/user", uid, "borg"),
            os.path.join("/var/run/user", uid, "borg"),
            os.path.join(f"/tmp/runtime-{uid}", "borg"),
            os.path.join(f"/mnt/eafs/tmp/runtime-{uid}", "borg"),  # CI netbsd
            os.path.join(f"/var/tmp/borg-ci/runtime-{uid}", "borg"),  # CI omnios (TMPDIR)
        ]
        monkeypatch.setenv("XDG_RUNTIME_DIR", "/var/tmp/.cache")
        assert get_runtime_dir(create=False) == os.path.join("/var/tmp/.cache", "borg")
        monkeypatch.setenv("BORG_RUNTIME_DIR", "/var/tmp")
        assert get_runtime_dir(create=False) == "/var/tmp"


def test_get_cache_dir_subdirs(monkeypatch, tmp_path):
    """extra path components are appended and created; the CACHEDIR.TAG stays in the cache root"""
    monkeypatch.delenv("BORG_BASE_DIR", raising=False)
    monkeypatch.setenv("BORG_CACHE_DIR", str(tmp_path))
    # without subdirs the behaviour is unchanged
    assert get_cache_dir(create=False) == str(tmp_path)
    # extra path components are appended
    assert get_cache_dir("storecache", create=False) == str(tmp_path / "storecache")
    # create=True creates the nested directory ...
    assert get_cache_dir("storecache") == str(tmp_path / "storecache")
    assert (tmp_path / "storecache").is_dir()
    # ... and the CACHEDIR.TAG marks the cache root, not the subdir
    assert (tmp_path / CACHE_TAG_NAME).exists()
    assert not (tmp_path / "storecache" / CACHE_TAG_NAME).exists()


def test_get_dir_subdirs(monkeypatch, tmp_path):
    """the config/data/runtime helpers append and create extra path components"""
    monkeypatch.delenv("BORG_BASE_DIR", raising=False)
    for env_var, get_dir in [
        ("BORG_CONFIG_DIR", get_config_dir),
        ("BORG_DATA_DIR", get_data_dir),
        ("BORG_RUNTIME_DIR", get_runtime_dir),
    ]:
        base = tmp_path / env_var.lower()
        monkeypatch.setenv(env_var, str(base))
        assert get_dir("a", "b", create=False) == str(base / "a" / "b")
        assert get_dir("a", "b") == str(base / "a" / "b")
        assert (base / "a" / "b").is_dir()


def test_dash_open():
    assert dash_open("-", "r") is sys.stdin
    assert dash_open("-", "w") is sys.stdout
    assert dash_open("-", "rb") is sys.stdin.buffer
    assert dash_open("-", "wb") is sys.stdout.buffer


@pytest.mark.skipif(not are_hardlinks_supported(), reason="hard links not supported")
def test_safe_unlink_is_safe(tmpdir):
    contents = b"Hello, world\n"
    victim = tmpdir / "victim"
    victim.write_binary(contents)
    hard_link = tmpdir / "hardlink"
    os.link(str(victim), str(hard_link))  # hard_link.mklinkto is not implemented on win32

    safe_unlink(hard_link)

    assert victim.read_binary() == contents


@pytest.mark.skipif(not are_hardlinks_supported(), reason="hard links not supported")
def test_safe_unlink_is_safe_ENOSPC(tmpdir, monkeypatch):
    contents = b"Hello, world\n"
    victim = tmpdir / "victim"
    victim.write_binary(contents)
    hard_link = tmpdir / "hardlink"
    os.link(str(victim), str(hard_link))  # hard_link.mklinkto is not implemented on win32

    def Path_unlink(_):
        raise OSError(errno.ENOSPC, "Pretend that we ran out of space")

    monkeypatch.setattr(Path, "unlink", Path_unlink)

    with pytest.raises(OSError):
        safe_unlink(hard_link)

    assert victim.read_binary() == contents


@pytest.mark.parametrize(
    "original_path, expected_path",
    [
        (".", "."),
        ("..", "."),
        ("/", "."),
        ("//", "."),
        ("foo", "foo"),
        ("foo/bar", "foo/bar"),
        ("/foo/bar", "foo/bar"),
        ("../foo/bar", "foo/bar"),
    ],
)
def test_remove_dotdot_prefixes(original_path, expected_path):
    assert remove_dotdot_prefixes(original_path) == expected_path


@pytest.mark.parametrize(
    "original_path, expected_path",
    [
        (".", "."),
        ("./", "."),
        ("/foo", "foo"),
        ("//foo", "foo"),
        (".//foo//bar//", "foo/bar"),
        ("/foo/bar", "foo/bar"),
        ("//foo/bar", "foo/bar"),
        ("//foo/./bar", "foo/bar"),
        (".test", ".test"),
        (".test.", ".test."),
        ("..test..", "..test.."),
        ("/te..st/foo/bar", "te..st/foo/bar"),
        ("/..test../abc//", "..test../abc"),
    ],
)
def test_valid_make_path_safe(original_path, expected_path):
    assert make_path_safe(original_path) == expected_path


@pytest.mark.parametrize("path", rejected_dotdot_paths)
def test_invalid_make_path_safe(path):
    with pytest.raises(ValueError, match="unexpected '..' element in path"):
        make_path_safe(path)


def test_make_path_safe_win32_drive_letters(monkeypatch):
    monkeypatch.setattr("borg.helpers.fs.is_win32", True)
    # Basic drive letter
    assert make_path_safe("C:\\Users") == "C/Users"
    assert make_path_safe("C:\\Users\\test") == "C/Users/test"
    # Lowercase drive letter -> Uppercase
    assert make_path_safe("c:\\windows") == "C/windows"
    # Relative path with backslashes
    assert make_path_safe("foo\\bar") == "foo/bar"
    # Just drive letter
    assert make_path_safe("D:") == "D"
    # Drive letter with forward slash
    assert make_path_safe("E:/test") == "E/test"
    # Mixed separators
    assert make_path_safe("F:\\Mixed/Separators") == "F/Mixed/Separators"


def test_dir_is_tagged(tmpdir):
    """Test dir_is_tagged with both path-based and file descriptor-based operations."""

    @contextmanager
    def open_dir(path):
        fd = os.open(path, os.O_RDONLY)
        try:
            yield fd
        finally:
            os.close(fd)

    # Create directories for testing exclude_caches
    cache_dir = tmpdir.mkdir("cache_dir")
    cache_tag_path = cache_dir.join(CACHE_TAG_NAME)
    cache_tag_path.write_binary(CACHE_TAG_CONTENTS)

    invalid_cache_dir = tmpdir.mkdir("invalid_cache_dir")
    invalid_cache_tag_path = invalid_cache_dir.join(CACHE_TAG_NAME)
    invalid_cache_tag_path.write_binary(b"invalid signature")

    # Create directories for testing exclude_if_present
    tagged_dir = tmpdir.mkdir("tagged_dir")
    tag_file = tagged_dir.join(".NOBACKUP")
    tag_file.write("test")

    other_tagged_dir = tmpdir.mkdir("other_tagged_dir")
    other_tag_file = other_tagged_dir.join(".DONOTBACKUP")
    other_tag_file.write("test")

    # Create a directory with both a CACHEDIR.TAG and a custom tag file
    both_dir = tmpdir.mkdir("both_dir")
    cache_tag_path = both_dir.join(CACHE_TAG_NAME)
    cache_tag_path.write_binary(CACHE_TAG_CONTENTS)
    custom_tag_path = both_dir.join(".NOBACKUP")
    custom_tag_path.write("test")

    # Create a directory without any tag files
    normal_dir = tmpdir.mkdir("normal_dir")

    # Test edge cases
    test_dir = tmpdir.mkdir("test_dir")
    assert dir_is_tagged(path=str(test_dir), exclude_caches=None, exclude_if_present=None) == []
    assert dir_is_tagged(path=str(test_dir), exclude_if_present=[]) == []

    # Test with non-existent directory (should not raise an exception)
    non_existent_dir = str(tmpdir.join("non_existent"))
    result = dir_is_tagged(path=non_existent_dir, exclude_caches=True, exclude_if_present=[".NOBACKUP"])
    assert result == []

    # Test 1: exclude_caches with path-based operations
    assert dir_is_tagged(path=str(cache_dir), exclude_caches=True) == [CACHE_TAG_NAME]
    assert dir_is_tagged(path=str(invalid_cache_dir), exclude_caches=True) == []
    assert dir_is_tagged(path=str(normal_dir), exclude_caches=True) == []

    assert dir_is_tagged(path=str(cache_dir), exclude_caches=False) == []
    assert dir_is_tagged(path=str(invalid_cache_dir), exclude_caches=False) == []
    assert dir_is_tagged(path=str(normal_dir), exclude_caches=False) == []

    # Test 2: exclude_caches with file-descriptor-based operations
    if not is_win32:
        with open_dir(str(cache_dir)) as fd:
            assert dir_is_tagged(dir_fd=fd, exclude_caches=True) == [CACHE_TAG_NAME]
        with open_dir(str(invalid_cache_dir)) as fd:
            assert dir_is_tagged(dir_fd=fd, exclude_caches=True) == []
        with open_dir(str(normal_dir)) as fd:
            assert dir_is_tagged(dir_fd=fd, exclude_caches=True) == []

        with open_dir(str(cache_dir)) as fd:
            assert dir_is_tagged(dir_fd=fd, exclude_caches=False) == []
        with open_dir(str(invalid_cache_dir)) as fd:
            assert dir_is_tagged(dir_fd=fd, exclude_caches=False) == []
        with open_dir(str(normal_dir)) as fd:
            assert dir_is_tagged(dir_fd=fd, exclude_caches=False) == []

    # Test 3: exclude_if_present with path-based operations
    tags = [".NOBACKUP"]
    assert dir_is_tagged(path=str(tagged_dir), exclude_if_present=tags) == [".NOBACKUP"]
    assert dir_is_tagged(path=str(other_tagged_dir), exclude_if_present=tags) == []
    assert dir_is_tagged(path=str(normal_dir), exclude_if_present=tags) == []

    tags = [".NOBACKUP", ".DONOTBACKUP"]
    assert dir_is_tagged(path=str(tagged_dir), exclude_if_present=tags) == [".NOBACKUP"]
    assert dir_is_tagged(path=str(other_tagged_dir), exclude_if_present=tags) == [".DONOTBACKUP"]
    assert dir_is_tagged(path=str(normal_dir), exclude_if_present=tags) == []

    # Test 4: exclude_if_present with file descriptor-based operations
    if not is_win32:
        tags = [".NOBACKUP"]
        with open_dir(str(tagged_dir)) as fd:
            assert dir_is_tagged(dir_fd=fd, exclude_if_present=tags) == [".NOBACKUP"]
        with open_dir(str(other_tagged_dir)) as fd:
            assert dir_is_tagged(dir_fd=fd, exclude_if_present=tags) == []
        with open_dir(str(normal_dir)) as fd:
            assert dir_is_tagged(dir_fd=fd, exclude_if_present=tags) == []

        tags = [".NOBACKUP", ".DONOTBACKUP"]
        with open_dir(str(tagged_dir)) as fd:
            assert dir_is_tagged(dir_fd=fd, exclude_if_present=tags) == [".NOBACKUP"]
        with open_dir(str(other_tagged_dir)) as fd:
            assert dir_is_tagged(dir_fd=fd, exclude_if_present=tags) == [".DONOTBACKUP"]
        with open_dir(str(normal_dir)) as fd:
            assert dir_is_tagged(dir_fd=fd, exclude_if_present=tags) == []

    # Test 5: both exclude types with path-based operations
    assert sorted(dir_is_tagged(path=str(both_dir), exclude_caches=True, exclude_if_present=[".NOBACKUP"])) == [
        ".NOBACKUP",
        CACHE_TAG_NAME,
    ]
    assert dir_is_tagged(path=str(cache_dir), exclude_caches=True, exclude_if_present=[".NOBACKUP"]) == [CACHE_TAG_NAME]
    assert dir_is_tagged(path=str(tagged_dir), exclude_caches=True, exclude_if_present=[".NOBACKUP"]) == [".NOBACKUP"]
    assert dir_is_tagged(path=str(normal_dir), exclude_caches=True, exclude_if_present=[".NOBACKUP"]) == []

    # Test 6: both exclude types with file descriptor-based operations
    if not is_win32:
        with open_dir(str(both_dir)) as fd:
            assert sorted(dir_is_tagged(dir_fd=fd, exclude_caches=True, exclude_if_present=[".NOBACKUP"])) == [
                ".NOBACKUP",
                CACHE_TAG_NAME,
            ]
        with open_dir(str(cache_dir)) as fd:
            assert dir_is_tagged(dir_fd=fd, exclude_caches=True, exclude_if_present=[".NOBACKUP"]) == [CACHE_TAG_NAME]
        with open_dir(str(tagged_dir)) as fd:
            assert dir_is_tagged(dir_fd=fd, exclude_caches=True, exclude_if_present=[".NOBACKUP"]) == [".NOBACKUP"]
        with open_dir(str(normal_dir)) as fd:
            assert dir_is_tagged(dir_fd=fd, exclude_caches=True, exclude_if_present=[".NOBACKUP"]) == []


def test_map_chars(monkeypatch):
    # Test behavior on non-Windows (should return path unchanged)
    monkeypatch.setattr("borg.helpers.fs.is_win32", False)
    assert map_chars("foo/bar") == "foo/bar"
    assert map_chars("foo\\bar") == "foo\\bar"
    assert map_chars("foo:bar") == "foo:bar"

    # Test behavior on Windows
    monkeypatch.setattr("borg.helpers.fs.is_win32", True)

    # Reserved characters replacement
    assert map_chars("foo:bar") == "foo\uf03abar"
    assert map_chars("foo<bar") == "foo\uf03cbar"
    assert map_chars("foo>bar") == "foo\uf03ebar"
    assert map_chars('foo"bar') == "foo\uf022bar"
    assert map_chars("foo\\bar") == "foo\uf05cbar"
    assert map_chars("foo|bar") == "foo\uf07cbar"
    assert map_chars("foo?bar") == "foo\uf03fbar"
    assert map_chars("foo*bar") == "foo\uf02abar"
