import errno
import json
import os
import tempfile
from random import randbytes
import shutil
import socket
import stat
import subprocess
import time

import pytest

from ... import platform
from ...constants import *  # NOQA
from ...manifest import Manifest
from ...platform import is_cygwin, is_win32, is_darwin
from ...repository import Repository
from ...helpers import CommandError
from .. import has_lchflags
from .. import changedir
from .. import (
    are_symlinks_supported,
    are_hardlinks_supported,
    are_fifos_supported,
    is_utime_fully_supported,
    is_birthtime_fully_supported,
    same_ts_ns,
    is_root,
)
from . import (
    cmd,
    generate_archiver_tests,
    create_test_files,
    assert_dirs_equal,
    create_regular_file,
    requires_hardlinks,
    _create_test_caches,
    _create_test_tagged,
    _create_test_keep_tagged,
    _assert_test_caches,
    _assert_test_tagged,
    _assert_test_keep_tagged,
    RK_ENCRYPTION,
)

pytest_generate_tests = lambda metafunc: generate_archiver_tests(metafunc, kinds="local,remote,binary")  # NOQA


def test_basic_functionality(archivers, request):
    archiver = request.getfixturevalue(archivers)
    if archiver.EXE:
        pytest.skip("test_basic_functionality seems incompatible with fakeroot and/or the binary.")
    have_root = create_test_files(archiver.input_path)
    # fork required to test show-rc output
    output = cmd(archiver, "rcreate", RK_ENCRYPTION, "--show-version", "--show-rc", fork=True)
    assert "borgbackup version" in output
    assert "terminating with success status, rc 0" in output

    cmd(archiver, "create", "--exclude-nodump", "test", "input")
    output = cmd(archiver, "create", "--exclude-nodump", "--stats", "test.2", "input")
    assert "Archive name: test.2" in output

    with changedir("output"):
        cmd(archiver, "extract", "test")

    list_output = cmd(archiver, "rlist", "--short")
    assert "test" in list_output
    assert "test.2" in list_output

    expected = [
        "input",
        "input/bdev",
        "input/cdev",
        "input/dir2",
        "input/dir2/file2",
        "input/empty",
        "input/file1",
        "input/flagfile",
    ]
    if are_fifos_supported():
        expected.append("input/fifo1")
    if are_symlinks_supported():
        expected.append("input/link1")
    if are_hardlinks_supported():
        expected.append("input/hardlink")
    if not have_root:
        # we could not create these device files without (fake)root
        expected.remove("input/bdev")
        expected.remove("input/cdev")
    if has_lchflags:
        # remove the file we did not back up, so input and output become equal
        expected.remove("input/flagfile")  # this file is UF_NODUMP
        os.remove(os.path.join("input", "flagfile"))

    list_output = cmd(archiver, "list", "test", "--short")
    for name in expected:
        assert name in list_output
    assert_dirs_equal("input", "output/input")

    info_output = cmd(archiver, "info", "-a", "test")
    item_count = 5 if has_lchflags else 6  # one file is UF_NODUMP
    assert "Number of files: %d" % item_count in info_output
    shutil.rmtree(archiver.cache_path)
    info_output2 = cmd(archiver, "info", "-a", "test")

    def filter(output):
        # filter for interesting "info" output, ignore cache rebuilding related stuff
        prefixes = ["Name:", "Fingerprint:", "Number of files:", "This archive:", "All archives:", "Chunk index:"]
        result = []
        for line in output.splitlines():
            for prefix in prefixes:
                if line.startswith(prefix):
                    result.append(line)
        return "\n".join(result)

    # the interesting parts of info_output2 and info_output should be same
    assert filter(info_output) == filter(info_output2)


def test_archived_paths(archivers, request):
    # As borg comes from the POSIX (Linux, UNIX) world, a lot of stuff assumes path separators
    # to be slashes "/", e.g.: in archived items, for pattern matching.
    # To make our lives easier and to support cross-platform extraction we always use slashes.
    # Similarly, archived paths are expected to be full, but relative (have no leading slash).
    archiver = request.getfixturevalue(archivers)
    full_path = os.path.abspath(os.path.join(archiver.input_path, "test"))
    # remove windows drive letter, if any:
    posix_path = full_path[2:] if full_path[1] == ":" else full_path
    # only needed on Windows in case there are backslashes:
    posix_path = posix_path.replace("\\", "/")
    # no leading slash in borg archives:
    archived_path = posix_path.lstrip("/")
    create_regular_file(archiver.input_path, "test")
    cmd(archiver, "rcreate", "--encryption=none")
    cmd(archiver, "create", "test", "input", posix_path)
    # "input" directory is recursed into, "input/test" is discovered and joined by borg's recursion.
    # posix_path was directly given as a cli argument and should end up as archive_path in the borg archive.
    expected_paths = sorted(["input", "input/test", archived_path])

    # check path in archived items:
    archive_list = cmd(archiver, "list", "test", "--short")
    assert expected_paths == sorted([path for path in archive_list.splitlines() if path])

    # check path in archived items (json):
    archive_list = cmd(archiver, "list", "test", "--json-lines")
    assert expected_paths == sorted([json.loads(line)["path"] for line in archive_list.splitlines() if line])


@requires_hardlinks
def test_create_duplicate_root(archivers, request):
    archiver = request.getfixturevalue(archivers)
    # setup for #5603
    path_a = os.path.join(archiver.input_path, "a")
    path_b = os.path.join(archiver.input_path, "b")
    os.mkdir(path_a)
    os.mkdir(path_b)
    hl_a = os.path.join(path_a, "hardlink")
    hl_b = os.path.join(path_b, "hardlink")
    create_regular_file(archiver.input_path, hl_a, contents=b"123456")
    os.link(hl_a, hl_b)
    cmd(archiver, "rcreate", "--encryption=none")
    cmd(archiver, "create", "test", "input", "input")  # give input twice!
    # test if created archive has 'input' contents twice:
    archive_list = cmd(archiver, "list", "test", "--json-lines")
    paths = [json.loads(line)["path"] for line in archive_list.split("\n") if line]
    # we have all fs items exactly once!
    assert sorted(paths) == ["input", "input/a", "input/a/hardlink", "input/b", "input/b/hardlink"]


def test_create_unreadable_parent(archiver):
    parent_dir = os.path.join(archiver.input_path, "parent")
    root_dir = os.path.join(archiver.input_path, "parent", "root")
    os.mkdir(parent_dir)
    os.mkdir(root_dir)
    os.chmod(parent_dir, 0o111)  # --x--x--x == parent dir traversable, but not readable
    try:
        cmd(archiver, "rcreate", "--encryption=none")
        # issue #7746: we *can* read root_dir and we *can* traverse parent_dir, so this should work:
        cmd(archiver, "create", "test", root_dir)
    finally:
        os.chmod(parent_dir, 0o771)  # otherwise cleanup after this test fails


@pytest.mark.skipif(is_win32, reason="unix sockets not available on windows")
def test_unix_socket(archivers, request, monkeypatch):
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "rcreate", RK_ENCRYPTION)
    try:
        with tempfile.TemporaryDirectory() as temp_dir:
            sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
            sock.bind(os.path.join(temp_dir, "unix-socket"))
    except PermissionError as err:
        if err.errno == errno.EPERM:
            pytest.skip("unix sockets disabled or not supported")
        elif err.errno == errno.EACCES:
            pytest.skip("permission denied to create unix sockets")
    cmd(archiver, "create", "test", "input")
    sock.close()
    with changedir("output"):
        cmd(archiver, "extract", "test")
        print(f"{temp_dir}/unix-socket")
        assert not os.path.exists(f"{temp_dir}/unix-socket")


@pytest.mark.skipif(not is_utime_fully_supported(), reason="cannot setup and execute test without utime")
@pytest.mark.skipif(not is_birthtime_fully_supported(), reason="cannot setup and execute test without birth time")
def test_nobirthtime(archivers, request):
    archiver = request.getfixturevalue(archivers)
    create_test_files(archiver.input_path)
    birthtime, mtime, atime = 946598400, 946684800, 946771200
    os.utime("input/file1", (atime, birthtime))
    os.utime("input/file1", (atime, mtime))
    cmd(archiver, "rcreate", RK_ENCRYPTION)
    cmd(archiver, "create", "test", "input", "--nobirthtime")
    with changedir("output"):
        cmd(archiver, "extract", "test")
    sti = os.stat("input/file1")
    sto = os.stat("output/input/file1")
    assert same_ts_ns(sti.st_birthtime * 1e9, birthtime * 1e9)
    assert same_ts_ns(sto.st_birthtime * 1e9, mtime * 1e9)
    assert same_ts_ns(sti.st_mtime_ns, sto.st_mtime_ns)
    assert same_ts_ns(sto.st_mtime_ns, mtime * 1e9)


def test_create_stdin(archivers, request):
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "rcreate", RK_ENCRYPTION)
    input_data = b"\x00foo\n\nbar\n   \n"
    cmd(archiver, "create", "test", "-", input=input_data)
    item = json.loads(cmd(archiver, "list", "test", "--json-lines"))
    assert item["size"] == len(input_data)
    assert item["path"] == "stdin"
    extracted_data = cmd(archiver, "extract", "test", "--stdout", binary_output=True)
    assert extracted_data == input_data


def test_create_stdin_checkpointing(archivers, request):
    archiver = request.getfixturevalue(archivers)
    chunk_size = 1000  # fixed chunker with this size, also volume based checkpointing after that volume
    cmd(archiver, "rcreate", RK_ENCRYPTION)
    input_data = b"X" * (chunk_size * 2 - 1)  # one full and one partial chunk
    cmd(
        archiver,
        "create",
        f"--chunker-params=fixed,{chunk_size}",
        f"--checkpoint-volume={chunk_size}",
        "test",
        "-",
        input=input_data,
    )
    # repo looking good overall? checks for rc == 0.
    cmd(archiver, "check", "--debug")
    # verify that there are no part files in final archive
    out = cmd(archiver, "list", "test")
    assert "stdin.borg_part" not in out
    # verify full file
    out = cmd(archiver, "extract", "test", "stdin", "--stdout", binary_output=True)
    assert out == input_data


def test_create_erroneous_file(archivers, request):
    archiver = request.getfixturevalue(archivers)
    chunk_size = 1000  # fixed chunker with this size, also volume based checkpointing after that volume
    create_regular_file(archiver.input_path, os.path.join(archiver.input_path, "file1"), size=chunk_size * 2)
    create_regular_file(archiver.input_path, os.path.join(archiver.input_path, "file2"), size=chunk_size * 2)
    create_regular_file(archiver.input_path, os.path.join(archiver.input_path, "file3"), size=chunk_size * 2)
    cmd(archiver, "rcreate", RK_ENCRYPTION)
    flist = "".join(f"input/file{n}\n" for n in range(1, 4))
    out = cmd(
        archiver,
        "create",
        f"--chunker-params=fail,{chunk_size},rrrEEErrrr",
        "--paths-from-stdin",
        "--list",
        "test",
        input=flist.encode(),
        exit_code=0,
    )
    assert "retry: 3 of " in out
    assert "E input/file2" not in out  # we managed to read it in the 3rd retry (after 3 failed reads)
    # repo looking good overall? checks for rc == 0.
    cmd(archiver, "check", "--debug")
    # check files in created archive
    out = cmd(archiver, "list", "test")
    assert "input/file1" in out
    assert "input/file2" in out
    assert "input/file3" in out


@pytest.mark.skipif(is_root(), reason="test must not be run as (fake)root")
def test_create_no_permission_file(archivers, request):
    archiver = request.getfixturevalue(archivers)
    file_path = os.path.join(archiver.input_path, "file")
    create_regular_file(archiver.input_path, file_path + "1", size=1000)
    create_regular_file(archiver.input_path, file_path + "2", size=1000)
    create_regular_file(archiver.input_path, file_path + "3", size=1000)
    # revoke read permissions on file2 for everybody, including us:
    if is_win32:
        subprocess.run(["icacls.exe", file_path + "2", "/deny", "everyone:(R)"])
    else:
        # note: this will NOT take away read permissions for root
        os.chmod(file_path + "2", 0o000)
    cmd(archiver, "rcreate", RK_ENCRYPTION)
    flist = "".join(f"input/file{n}\n" for n in range(1, 4))
    out = cmd(
        archiver,
        "create",
        "--paths-from-stdin",
        "--list",
        "test",
        input=flist.encode(),
        exit_code=1,  # WARNING status: could not back up file2.
    )
    assert "retry: 1 of " not in out  # retries were NOT attempted!
    assert "E input/file2" in out  # no permissions!
    # repo looking good overall? checks for rc == 0.
    cmd(archiver, "check", "--debug")
    # check files in created archive
    out = cmd(archiver, "list", "test")
    assert "input/file1" in out
    assert "input/file2" not in out  # it skipped file2
    assert "input/file3" in out


def test_sanitized_stdin_name(archivers, request):
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "rcreate", RK_ENCRYPTION)
    cmd(archiver, "create", "--stdin-name", "./a//path", "test", "-", input=b"")
    item = json.loads(cmd(archiver, "list", "test", "--json-lines"))
    assert item["path"] == "a/path"


def test_dotdot_stdin_name(archivers, request):
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "rcreate", RK_ENCRYPTION)
    output = cmd(archiver, "create", "--stdin-name", "foo/../bar", "test", "-", input=b"", exit_code=2)
    assert output.endswith("'..' element in path 'foo/../bar'" + os.linesep)


def test_dot_stdin_name(archivers, request):
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "rcreate", RK_ENCRYPTION)
    output = cmd(archiver, "create", "--stdin-name", "./", "test", "-", input=b"", exit_code=2)
    assert output.endswith("'./' is not a valid file name" + os.linesep)


def test_create_content_from_command(archivers, request):
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "rcreate", RK_ENCRYPTION)
    input_data = "some test content"
    name = "a/b/c"
    cmd(archiver, "create", "--stdin-name", name, "--content-from-command", "test", "--", "echo", input_data)
    item = json.loads(cmd(archiver, "list", "test", "--json-lines"))
    assert item["size"] == len(input_data) + 1  # `echo` adds newline
    assert item["path"] == name
    extracted_data = cmd(archiver, "extract", "test", "--stdout")
    assert extracted_data == input_data + "\n"


def test_create_content_from_command_with_failed_command(archivers, request):
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "rcreate", RK_ENCRYPTION)
    if archiver.FORK_DEFAULT:
        output = cmd(archiver, "create", "--content-from-command", "test", "--", "sh", "-c", "exit 73;", exit_code=2)
        assert output.endswith("Command 'sh' exited with status 73" + os.linesep)
    else:
        with pytest.raises(CommandError):
            cmd(archiver, "create", "--content-from-command", "test", "--", "sh", "-c", "exit 73;")
    archive_list = json.loads(cmd(archiver, "rlist", "--json"))
    assert archive_list["archives"] == []


def test_create_content_from_command_missing_command(archivers, request):
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "rcreate", RK_ENCRYPTION)
    output = cmd(archiver, "create", "test", "--content-from-command", exit_code=2)
    assert output.endswith("No command given." + os.linesep)


def test_create_paths_from_stdin(archivers, request):
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "rcreate", RK_ENCRYPTION)
    create_regular_file(archiver.input_path, "file1", size=1024 * 80)
    create_regular_file(archiver.input_path, "dir1/file2", size=1024 * 80)
    create_regular_file(archiver.input_path, "dir1/file3", size=1024 * 80)
    create_regular_file(archiver.input_path, "file4", size=1024 * 80)
    input_data = b"input/file1\0input/dir1\0input/file4"
    cmd(archiver, "create", "test", "--paths-from-stdin", "--paths-delimiter", "\\0", input=input_data)
    archive_list = cmd(archiver, "list", "test", "--json-lines")
    paths = [json.loads(line)["path"] for line in archive_list.split("\n") if line]
    assert paths == ["input/file1", "input/dir1", "input/file4"]


def test_create_paths_from_command(archivers, request):
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "rcreate", RK_ENCRYPTION)
    create_regular_file(archiver.input_path, "file1", size=1024 * 80)
    create_regular_file(archiver.input_path, "file2", size=1024 * 80)
    create_regular_file(archiver.input_path, "file3", size=1024 * 80)
    create_regular_file(archiver.input_path, "file4", size=1024 * 80)
    input_data = "input/file1\ninput/file2\ninput/file3"
    if is_win32:
        with open("filenames.cmd", "w") as script:
            for filename in input_data.splitlines():
                script.write(f"@echo {filename}\n")
    cmd(archiver, "create", "--paths-from-command", "test", "--", "filenames.cmd" if is_win32 else "echo", input_data)
    archive_list = cmd(archiver, "list", "test", "--json-lines")
    paths = [json.loads(line)["path"] for line in archive_list.split("\n") if line]
    assert paths == ["input/file1", "input/file2", "input/file3"]


def test_create_paths_from_command_with_failed_command(archivers, request):
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "rcreate", RK_ENCRYPTION)
    if archiver.FORK_DEFAULT:
        output = cmd(archiver, "create", "--paths-from-command", "test", "--", "sh", "-c", "exit 73;", exit_code=2)
        assert output.endswith("Command 'sh' exited with status 73" + os.linesep)
    else:
        with pytest.raises(CommandError):
            cmd(archiver, "create", "--paths-from-command", "test", "--", "sh", "-c", "exit 73;")
    archive_list = json.loads(cmd(archiver, "rlist", "--json"))
    assert archive_list["archives"] == []


def test_create_paths_from_command_missing_command(archivers, request):
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "rcreate", RK_ENCRYPTION)
    output = cmd(archiver, "create", "test", "--paths-from-command", exit_code=2)
    assert output.endswith("No command given." + os.linesep)


def test_create_without_root(archivers, request):
    """test create without a root"""
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "rcreate", RK_ENCRYPTION)
    cmd(archiver, "create", "test", exit_code=2)


def test_create_pattern_root(archivers, request):
    """test create with only a root pattern"""
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "rcreate", RK_ENCRYPTION)
    create_regular_file(archiver.input_path, "file1", size=1024 * 80)
    create_regular_file(archiver.input_path, "file2", size=1024 * 80)
    output = cmd(archiver, "create", "test", "-v", "--list", "--pattern=R input")
    assert "A input/file1" in output
    assert "A input/file2" in output


def test_create_pattern(archivers, request):
    """test file patterns during create"""
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "rcreate", RK_ENCRYPTION)
    create_regular_file(archiver.input_path, "file1", size=1024 * 80)
    create_regular_file(archiver.input_path, "file2", size=1024 * 80)
    create_regular_file(archiver.input_path, "file_important", size=1024 * 80)
    output = cmd(
        archiver, "create", "-v", "--list", "--pattern=+input/file_important", "--pattern=-input/file*", "test", "input"
    )
    assert "A input/file_important" in output
    assert "- input/file1" in output
    assert "- input/file2" in output


def test_create_pattern_file(archivers, request):
    """test file patterns during create"""
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "rcreate", RK_ENCRYPTION)
    create_regular_file(archiver.input_path, "file1", size=1024 * 80)
    create_regular_file(archiver.input_path, "file2", size=1024 * 80)
    create_regular_file(archiver.input_path, "otherfile", size=1024 * 80)
    create_regular_file(archiver.input_path, "file_important", size=1024 * 80)
    output = cmd(
        archiver,
        "create",
        "-v",
        "--list",
        "--pattern=-input/otherfile",
        "--patterns-from=" + archiver.patterns_file_path,
        "test",
        "input",
    )
    assert "A input/file_important" in output
    assert "- input/file1" in output
    assert "- input/file2" in output
    assert "- input/otherfile" in output


def test_create_pattern_exclude_folder_but_recurse(archivers, request):
    """test when patterns exclude a parent folder, but include a child"""
    archiver = request.getfixturevalue(archivers)
    patterns_file_path2 = os.path.join(archiver.tmpdir, "patterns2")
    with open(patterns_file_path2, "wb") as fd:
        fd.write(b"+ input/x/b\n- input/x*\n")
    cmd(archiver, "rcreate", RK_ENCRYPTION)
    create_regular_file(archiver.input_path, "x/a/foo_a", size=1024 * 80)
    create_regular_file(archiver.input_path, "x/b/foo_b", size=1024 * 80)
    create_regular_file(archiver.input_path, "y/foo_y", size=1024 * 80)
    output = cmd(archiver, "create", "-v", "--list", "--patterns-from=" + patterns_file_path2, "test", "input")
    assert "- input/x/a/foo_a" in output
    assert "A input/x/b/foo_b" in output
    assert "A input/y/foo_y" in output


def test_create_pattern_exclude_folder_no_recurse(archivers, request):
    """test when patterns exclude a parent folder, but include a child"""
    archiver = request.getfixturevalue(archivers)
    patterns_file_path2 = os.path.join(archiver.tmpdir, "patterns2")
    with open(patterns_file_path2, "wb") as fd:
        fd.write(b"+ input/x/b\n! input/x*\n")
    cmd(archiver, "rcreate", RK_ENCRYPTION)
    create_regular_file(archiver.input_path, "x/a/foo_a", size=1024 * 80)
    create_regular_file(archiver.input_path, "x/b/foo_b", size=1024 * 80)
    create_regular_file(archiver.input_path, "y/foo_y", size=1024 * 80)
    output = cmd(archiver, "create", "-v", "--list", "--patterns-from=" + patterns_file_path2, "test", "input")
    assert "input/x/a/foo_a" not in output
    assert "input/x/a" not in output
    assert "A input/y/foo_y" in output


def test_create_pattern_intermediate_folders_first(archivers, request):
    """test that intermediate folders appear first when patterns exclude a parent folder but include a child"""
    archiver = request.getfixturevalue(archivers)
    patterns_file_path2 = os.path.join(archiver.tmpdir, "patterns2")
    with open(patterns_file_path2, "wb") as fd:
        fd.write(b"+ input/x/a\n+ input/x/b\n- input/x*\n")
    cmd(archiver, "rcreate", RK_ENCRYPTION)
    create_regular_file(archiver.input_path, "x/a/foo_a", size=1024 * 80)
    create_regular_file(archiver.input_path, "x/b/foo_b", size=1024 * 80)
    with changedir("input"):
        cmd(archiver, "create", "--patterns-from=" + patterns_file_path2, "test", ".")
    # list the archive and verify that the "intermediate" folders appear before
    # their contents
    out = cmd(archiver, "list", "test", "--format", "{type} {path}{NL}")
    out_list = out.splitlines()
    assert "d x/a" in out_list
    assert "d x/b" in out_list
    assert out_list.index("d x/a") < out_list.index("- x/a/foo_a")
    assert out_list.index("d x/b") < out_list.index("- x/b/foo_b")


def test_create_no_cache_sync(archivers, request):
    archiver = request.getfixturevalue(archivers)
    create_test_files(archiver.input_path)
    cmd(archiver, "rcreate", RK_ENCRYPTION)
    cmd(archiver, "rdelete", "--cache-only")
    create_json = json.loads(
        cmd(archiver, "create", "--no-cache-sync", "--json", "--error", "test", "input")
    )  # ignore experimental warning
    info_json = json.loads(cmd(archiver, "info", "-a", "test", "--json"))
    create_stats = create_json["cache"]["stats"]
    info_stats = info_json["cache"]["stats"]
    assert create_stats == info_stats
    cmd(archiver, "rdelete", "--cache-only")
    cmd(archiver, "create", "--no-cache-sync", "test2", "input")
    cmd(archiver, "rinfo")
    cmd(archiver, "check")


def test_create_archivename_with_placeholder(archivers, request):
    archiver = request.getfixturevalue(archivers)
    create_test_files(archiver.input_path)
    cmd(archiver, "rcreate", RK_ENCRYPTION)
    ts = "1999-12-31T23:59:59"
    name_given = "test-{now}"  # placeholder in archive name gets replaced by borg
    name_expected = f"test-{ts}"  # placeholder in f-string gets replaced by python
    cmd(archiver, "create", f"--timestamp={ts}", name_given, "input")
    list_output = cmd(archiver, "rlist", "--short")
    assert name_expected in list_output


def test_exclude_caches(archivers, request):
    archiver = request.getfixturevalue(archivers)
    _create_test_caches(archiver)
    cmd(archiver, "create", "test", "input", "--exclude-caches")
    _assert_test_caches(archiver)


def test_exclude_tagged(archivers, request):
    archiver = request.getfixturevalue(archivers)
    _create_test_tagged(archiver)
    cmd(archiver, "create", "test", "input", "--exclude-if-present", ".NOBACKUP", "--exclude-if-present", "00-NOBACKUP")
    _assert_test_tagged(archiver)


def test_exclude_keep_tagged(archivers, request):
    archiver = request.getfixturevalue(archivers)
    _create_test_keep_tagged(archiver)
    cmd(
        archiver,
        "create",
        "test",
        "input",
        "--exclude-if-present",
        ".NOBACKUP1",
        "--exclude-if-present",
        ".NOBACKUP2",
        "--exclude-caches",
        "--keep-exclude-tags",
    )
    _assert_test_keep_tagged(archiver)


def test_path_sanitation(archivers, request):
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "rcreate", RK_ENCRYPTION)
    create_regular_file(archiver.input_path, "dir1/dir2/file", size=1024 * 80)
    with changedir("input/dir1/dir2"):
        cmd(archiver, "create", "test", "../../../input/dir1/../dir1/dir2/..")
    output = cmd(archiver, "list", "test")
    assert ".." not in output
    assert " input/dir1/dir2/file" in output


def test_exclude_sanitation(archivers, request):
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "rcreate", RK_ENCRYPTION)
    create_regular_file(archiver.input_path, "file1", size=1024 * 80)
    create_regular_file(archiver.input_path, "file2", size=1024 * 80)
    with changedir("input"):
        cmd(archiver, "create", "test1", ".", "--exclude=file1")
    with changedir("output"):
        cmd(archiver, "extract", "test1")
    assert sorted(os.listdir("output")) == ["file2"]
    with changedir("input"):
        cmd(archiver, "create", "test2", ".", "--exclude=./file1")
    with changedir("output"):
        cmd(archiver, "extract", "test2")
    assert sorted(os.listdir("output")) == ["file2"]
    cmd(archiver, "create", "test3", "input", "--exclude=input/./file1")
    with changedir("output"):
        cmd(archiver, "extract", "test3")
    assert sorted(os.listdir("output/input")) == ["file2"]


def test_repeated_files(archivers, request):
    archiver = request.getfixturevalue(archivers)
    create_regular_file(archiver.input_path, "file1", size=1024 * 80)
    cmd(archiver, "rcreate", RK_ENCRYPTION)
    cmd(archiver, "create", "test", "input", "input")


@pytest.mark.skipif("BORG_TESTS_IGNORE_MODES" in os.environ, reason="modes unreliable")
@pytest.mark.skipif(is_win32, reason="modes unavailable on Windows")
def test_umask(archivers, request):
    archiver = request.getfixturevalue(archivers)
    create_regular_file(archiver.input_path, "file1", size=1024 * 80)
    cmd(archiver, "rcreate", RK_ENCRYPTION)
    cmd(archiver, "create", "test", "input")
    mode = os.stat(archiver.repository_path).st_mode
    assert stat.S_IMODE(mode) == 0o700


def test_create_dry_run(archivers, request):
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "rcreate", RK_ENCRYPTION)
    cmd(archiver, "create", "--dry-run", "test", "input")
    # Make sure no archive has been created
    with Repository(archiver.repository_path) as repository:
        manifest = Manifest.load(repository, Manifest.NO_OPERATION_CHECK)
    assert len(manifest.archives) == 0


def test_progress_on(archivers, request):
    archiver = request.getfixturevalue(archivers)
    create_regular_file(archiver.input_path, "file1", size=1024 * 80)
    cmd(archiver, "rcreate", RK_ENCRYPTION)
    output = cmd(archiver, "create", "test4", "input", "--progress")
    assert "\r" in output


def test_progress_off(archivers, request):
    archiver = request.getfixturevalue(archivers)
    create_regular_file(archiver.input_path, "file1", size=1024 * 80)
    cmd(archiver, "rcreate", RK_ENCRYPTION)
    output = cmd(archiver, "create", "test5", "input")
    assert "\r" not in output


def test_file_status(archivers, request):
    """test that various file status show expected results
    clearly incomplete: only tests for the weird "unchanged" status for now"""
    archiver = request.getfixturevalue(archivers)
    create_regular_file(archiver.input_path, "file1", size=1024 * 80)
    time.sleep(1)  # file2 must have newer timestamps than file1
    create_regular_file(archiver.input_path, "file2", size=1024 * 80)
    cmd(archiver, "rcreate", RK_ENCRYPTION)
    output = cmd(archiver, "create", "--list", "test", "input")
    assert "A input/file1" in output
    assert "A input/file2" in output
    # should find first file as unmodified
    output = cmd(archiver, "create", "--list", "test2", "input")
    assert "U input/file1" in output
    # although surprising, this is expected. For why, see:
    # https://borgbackup.readthedocs.org/en/latest/faq.html#i-am-seeing-a-added-status-for-a-unchanged-file
    assert "A input/file2" in output


@pytest.mark.skipif(
    is_win32, reason="ctime attribute is file creation time on Windows"
)  # see https://docs.python.org/3/library/os.html#os.stat_result.st_ctime
def test_file_status_cs_cache_mode(archivers, request):
    archiver = request.getfixturevalue(archivers)
    """test that a changed file with faked "previous" mtime still gets backed up in ctime,size cache_mode"""
    create_regular_file(archiver.input_path, "file1", contents=b"123")
    time.sleep(1)  # file2 must have newer timestamps than file1
    create_regular_file(archiver.input_path, "file2", size=10)
    cmd(archiver, "rcreate", RK_ENCRYPTION)
    cmd(archiver, "create", "test1", "input", "--list", "--files-cache=ctime,size")
    # modify file1, but cheat with the mtime (and atime) and also keep same size:
    st = os.stat("input/file1")
    create_regular_file(archiver.input_path, "file1", contents=b"321")
    os.utime("input/file1", ns=(st.st_atime_ns, st.st_mtime_ns))
    # this mode uses ctime for change detection, so it should find file1 as modified
    output = cmd(archiver, "create", "test2", "input", "--list", "--files-cache=ctime,size")
    assert "M input/file1" in output


def test_file_status_ms_cache_mode(archivers, request):
    """test that a chmod'ed file with no content changes does not get chunked again in mtime,size cache_mode"""
    archiver = request.getfixturevalue(archivers)
    create_regular_file(archiver.input_path, "file1", size=10)
    time.sleep(1)  # file2 must have newer timestamps than file1
    create_regular_file(archiver.input_path, "file2", size=10)
    cmd(archiver, "rcreate", RK_ENCRYPTION)
    cmd(archiver, "create", "--list", "--files-cache=mtime,size", "test1", "input")
    # change mode of file1, no content change:
    st = os.stat("input/file1")
    os.chmod("input/file1", st.st_mode ^ stat.S_IRWXO)  # this triggers a ctime change, but mtime is unchanged
    # this mode uses mtime for change detection, so it should find file1 as unmodified
    output = cmd(archiver, "create", "--list", "--files-cache=mtime,size", "test2", "input")
    assert "U input/file1" in output


def test_file_status_rc_cache_mode(archivers, request):
    """test that files get rechunked unconditionally in rechunk,ctime cache mode"""
    archiver = request.getfixturevalue(archivers)
    create_regular_file(archiver.input_path, "file1", size=10)
    time.sleep(1)  # file2 must have newer timestamps than file1
    create_regular_file(archiver.input_path, "file2", size=10)
    cmd(archiver, "rcreate", RK_ENCRYPTION)
    cmd(archiver, "create", "--list", "--files-cache=rechunk,ctime", "test1", "input")
    # no changes here, but this mode rechunks unconditionally
    output = cmd(archiver, "create", "--list", "--files-cache=rechunk,ctime", "test2", "input")
    assert "A input/file1" in output


def test_file_status_excluded(archivers, request):
    """test that excluded paths are listed"""
    archiver = request.getfixturevalue(archivers)
    create_regular_file(archiver.input_path, "file1", size=1024 * 80)
    time.sleep(1)  # file2 must have newer timestamps than file1
    create_regular_file(archiver.input_path, "file2", size=1024 * 80)
    if has_lchflags:
        create_regular_file(archiver.input_path, "file3", size=1024 * 80)
        platform.set_flags(os.path.join(archiver.input_path, "file3"), stat.UF_NODUMP)
    cmd(archiver, "rcreate", RK_ENCRYPTION)
    output = cmd(archiver, "create", "--list", "--exclude-nodump", "test", "input")
    assert "A input/file1" in output
    assert "A input/file2" in output
    if has_lchflags:
        assert "- input/file3" in output
    # should find second file as excluded
    output = cmd(archiver, "create", "test1", "input", "--list", "--exclude-nodump", "--exclude", "*/file2")
    assert "U input/file1" in output
    assert "- input/file2" in output
    if has_lchflags:
        assert "- input/file3" in output


def test_file_status_counters(archivers, request):
    """Test file status counters in the stats of `borg create --stats`"""
    archiver = request.getfixturevalue(archivers)

    def to_dict(borg_create_output):
        borg_create_output = borg_create_output.strip().splitlines()
        borg_create_output = [line.split(":", 1) for line in borg_create_output]
        borg_create_output = {
            key: int(value)
            for key, value in borg_create_output
            if key in ("Added files", "Unchanged files", "Modified files")
        }
        return borg_create_output

    # Test case set up: create a repository
    cmd(archiver, "rcreate", RK_ENCRYPTION)
    # Archive an empty dir
    result = cmd(archiver, "create", "--stats", "test_archive", archiver.input_path)
    result = to_dict(result)
    assert result["Added files"] == 0
    assert result["Unchanged files"] == 0
    assert result["Modified files"] == 0
    # Archive a dir with two added files
    create_regular_file(archiver.input_path, "testfile1", contents=b"test1")
    time.sleep(1.0 if is_darwin else 0.01)  # testfile2 must have newer timestamps than testfile1
    create_regular_file(archiver.input_path, "testfile2", contents=b"test2")
    result = cmd(archiver, "create", "--stats", "test_archive2", archiver.input_path)
    result = to_dict(result)
    assert result["Added files"] == 2
    assert result["Unchanged files"] == 0
    assert result["Modified files"] == 0
    # Archive a dir with 1 unmodified file and 1 modified
    create_regular_file(archiver.input_path, "testfile1", contents=b"new data")
    result = cmd(archiver, "create", "--stats", "test_archive3", archiver.input_path)
    result = to_dict(result)
    # Should process testfile2 as added because of
    # https://borgbackup.readthedocs.io/en/stable/faq.html#i-am-seeing-a-added-status-for-an-unchanged-file
    assert result["Added files"] == 1
    assert result["Unchanged files"] == 0
    assert result["Modified files"] == 1


def test_create_json(archivers, request):
    archiver = request.getfixturevalue(archivers)
    create_regular_file(archiver.input_path, "file1", size=1024 * 80)
    cmd(archiver, "rcreate", RK_ENCRYPTION)
    create_info = json.loads(cmd(archiver, "create", "--json", "test", "input"))
    # The usual keys
    assert "encryption" in create_info
    assert "repository" in create_info
    assert "cache" in create_info
    assert "last_modified" in create_info["repository"]

    archive = create_info["archive"]
    assert archive["name"] == "test"
    assert isinstance(archive["command_line"], str)
    assert isinstance(archive["duration"], float)
    assert len(archive["id"]) == 64
    assert "stats" in archive


def test_create_topical(archivers, request):
    archiver = request.getfixturevalue(archivers)
    create_regular_file(archiver.input_path, "file1", size=1024 * 80)
    time.sleep(1)  # file2 must have newer timestamps than file1
    create_regular_file(archiver.input_path, "file2", size=1024 * 80)
    cmd(archiver, "rcreate", RK_ENCRYPTION)
    # no listing by default
    output = cmd(archiver, "create", "test", "input")
    assert "file1" not in output
    # shouldn't be listed even if unchanged
    output = cmd(archiver, "create", "test0", "input")
    assert "file1" not in output
    # should list the file as unchanged
    output = cmd(archiver, "create", "test1", "input", "--list", "--filter=U")
    assert "file1" in output
    # should *not* list the file as changed
    output = cmd(archiver, "create", "test2", "input", "--list", "--filter=AM")
    assert "file1" not in output
    # change the file
    create_regular_file(archiver.input_path, "file1", size=1024 * 100)
    # should list the file as changed
    output = cmd(archiver, "create", "test3", "input", "--list", "--filter=AM")
    assert "file1" in output


@pytest.mark.skipif(not are_fifos_supported() or is_cygwin, reason="FIFOs not supported, hangs on cygwin")
def test_create_read_special_symlink(archivers, request):
    archiver = request.getfixturevalue(archivers)
    from threading import Thread

    def fifo_feeder(fifo_fn, data):
        fd = os.open(fifo_fn, os.O_WRONLY)
        try:
            os.write(fd, data)
        finally:
            os.close(fd)

    cmd(archiver, "rcreate", RK_ENCRYPTION)
    data = b"foobar" * 1000

    fifo_fn = os.path.join(archiver.input_path, "fifo")
    link_fn = os.path.join(archiver.input_path, "link_fifo")
    os.mkfifo(fifo_fn)
    os.symlink(fifo_fn, link_fn)

    t = Thread(target=fifo_feeder, args=(fifo_fn, data))
    t.start()
    try:
        cmd(archiver, "create", "--read-special", "test", "input/link_fifo")
    finally:
        # In case `borg create` failed to open FIFO, read all data to avoid join() hanging.
        fd = os.open(fifo_fn, os.O_RDONLY | os.O_NONBLOCK)
        try:
            os.read(fd, len(data))
        except OSError:
            # fails on FreeBSD 13 with BlockingIOError
            pass
        finally:
            os.close(fd)
        t.join()
    with changedir("output"):
        cmd(archiver, "extract", "test")
        fifo_fn = "input/link_fifo"
        with open(fifo_fn, "rb") as f:
            extracted_data = f.read()
    assert extracted_data == data


@pytest.mark.skipif(not are_symlinks_supported(), reason="symlinks not supported")
def test_create_read_special_broken_symlink(archivers, request):
    archiver = request.getfixturevalue(archivers)
    os.symlink("somewhere does not exist", os.path.join(archiver.input_path, "link"))
    cmd(archiver, "rcreate", RK_ENCRYPTION)
    cmd(archiver, "create", "--read-special", "test", "input")
    output = cmd(archiver, "list", "test")
    assert "input/link -> somewhere does not exist" in output


def test_create_dotslash_hack(archivers, request):
    archiver = request.getfixturevalue(archivers)
    os.makedirs(os.path.join(archiver.input_path, "first", "secondA", "thirdA"))
    os.makedirs(os.path.join(archiver.input_path, "first", "secondB", "thirdB"))
    cmd(archiver, "rcreate", RK_ENCRYPTION)
    cmd(archiver, "create", "test", "input/first/./")  # hack!
    output = cmd(archiver, "list", "test")
    # dir levels left of slashdot (= input, first) not in archive:
    assert "input" not in output
    assert "input/first" not in output
    assert "input/first/secondA" not in output
    assert "input/first/secondA/thirdA" not in output
    assert "input/first/secondB" not in output
    assert "input/first/secondB/thirdB" not in output
    assert "first" not in output
    assert "first/secondA" not in output
    assert "first/secondA/thirdA" not in output
    assert "first/secondB" not in output
    assert "first/secondB/thirdB" not in output
    # dir levels right of slashdot are in archive:
    assert "secondA" in output
    assert "secondA/thirdA" in output
    assert "secondB" in output
    assert "secondB/thirdB" in output


def test_log_json(archivers, request):
    archiver = request.getfixturevalue(archivers)
    create_test_files(archiver.input_path)
    cmd(archiver, "rcreate", RK_ENCRYPTION)
    log = cmd(archiver, "create", "test", "input", "--log-json", "--list", "--debug")
    messages = {}  # type -> message, one of each kind
    for line in log.splitlines():
        msg = json.loads(line)
        messages[msg["type"]] = msg

    file_status = messages["file_status"]
    assert "status" in file_status
    assert file_status["path"].startswith("input")

    log_message = messages["log_message"]
    assert isinstance(log_message["time"], float)
    assert log_message["levelname"] == "DEBUG"  # there should only be DEBUG messages
    assert isinstance(log_message["message"], str)


def test_common_options(archivers, request):
    archiver = request.getfixturevalue(archivers)
    create_test_files(archiver.input_path)
    cmd(archiver, "rcreate", RK_ENCRYPTION)
    log = cmd(archiver, "--debug", "create", "test", "input")
    assert "security: read previous location" in log


def test_hashing_time(archivers, request):
    archiver = request.getfixturevalue(archivers)

    def extract_hashing_time(borg_create_output):
        borg_create_output = borg_create_output.strip().splitlines()
        borg_create_output = [line.split(":", 1) for line in borg_create_output]
        hashing_time = [line for line in borg_create_output if line[0] == "Time spent in hashing"].pop()
        hashing_time = hashing_time[1]
        hashing_time = float(hashing_time.removesuffix(" seconds"))
        return hashing_time

    # Test case set up: create a repository and a file
    cmd(archiver, "rcreate", "--encryption=none")
    create_regular_file(archiver.input_path, "testfile", contents=randbytes(50000000))
    # Archive
    result = cmd(archiver, "create", "--stats", "test_archive", archiver.input_path)
    hashing_time = extract_hashing_time(result)
    assert hashing_time > 0.0


def test_chunking_time(archivers, request):
    archiver = request.getfixturevalue(archivers)

    def extract_chunking_time(borg_create_output):
        borg_create_output = borg_create_output.strip().splitlines()
        borg_create_output = [line.split(":", 1) for line in borg_create_output]
        chunking_time = [line for line in borg_create_output if line[0] == "Time spent in chunking"].pop()
        chunking_time = chunking_time[1]
        chunking_time = float(chunking_time.removesuffix(" seconds"))
        return chunking_time

    # Test case set up: create a repository and a file
    cmd(archiver, "rcreate", RK_ENCRYPTION)
    create_regular_file(archiver.input_path, "testfile", contents=randbytes(50000000))
    # Archive
    result = cmd(archiver, "create", "--stats", "test_archive", archiver.input_path)
    chunking_time = extract_chunking_time(result)
    assert chunking_time > 0.0
