import os
import pathlib
import stat
import sys
from datetime import datetime, timedelta, timezone
from typing import Any, Callable, Dict, List, Optional
from unittest.mock import patch

import pytest

from dotbot.plugins.link import Link
from tests.conftest import Dotfiles


def test_link_canonicalization(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify links to symlinked targets are canonical.

    "Canonical", here, means that dotbot does not create symlinks
    that point to intermediary symlinks.
    """

    dotfiles.write("f", "apple")
    dotfiles.write_config([{"link": {"~/.f": {"path": "f"}}}])

    # Point to the config file in a symlinked dotfiles directory.
    dotfiles_symlink = os.path.join(home, "dotfiles-symlink")
    os.symlink(dotfiles.directory, dotfiles_symlink)
    config_file = os.path.join(dotfiles_symlink, os.path.basename(dotfiles.config_filename))
    run_dotbot("-c", config_file, custom=True)

    expected = os.path.join(dotfiles.directory, "f")
    actual = os.readlink(os.path.abspath(os.path.expanduser("~/.f")))
    if sys.platform == "win32" and actual.startswith("\\\\?\\"):
        actual = actual[4:]
    assert expected == actual


@pytest.mark.parametrize("dst", ["~/.f", "~/f"])
@pytest.mark.parametrize("include_force", [True, False])
def test_link_default_target(
    dst: str,
    include_force: bool,  # noqa: FBT001
    home: str,
    dotfiles: Dotfiles,
    run_dotbot: Callable[..., None],
) -> None:
    """Verify that default targets are calculated correctly.

    This test includes verifying files with and without leading periods,
    as well as verifying handling of None dict values.
    """

    _ = home
    dotfiles.write("f", "apple")
    config = [
        {
            "link": {
                dst: {"force": False} if include_force else None,
            }
        }
    ]
    dotfiles.write_config(config)
    run_dotbot()

    with open(os.path.abspath(os.path.expanduser(dst))) as file:
        assert file.read() == "apple"


def test_link_environment_user_expansion_link_name(
    home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]
) -> None:
    """Verify link expands user in link name."""

    _ = home
    target = "~/f"
    link_name = "~/g"
    with open(os.path.abspath(os.path.expanduser(target)), "w") as file:
        file.write("apple")
    dotfiles.write_config([{"link": {link_name: target}}])
    run_dotbot()

    with open(os.path.abspath(os.path.expanduser(link_name))) as file:
        assert file.read() == "apple"


def test_link_environment_variable_expansion_target(
    monkeypatch: pytest.MonkeyPatch, home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]
) -> None:
    """Verify link expands environment variables in target."""

    _ = home
    monkeypatch.setenv("APPLE", "h")
    link_name = "~/.i"
    target = "$APPLE"
    dotfiles.write("h", "grape")
    dotfiles.write_config([{"link": {link_name: target}}])
    run_dotbot()

    with open(os.path.abspath(os.path.expanduser(link_name))) as file:
        assert file.read() == "grape"


def test_link_environment_variable_expansion_target_extended(
    monkeypatch: pytest.MonkeyPatch, home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]
) -> None:
    """Verify link expands environment variables in extended config syntax."""

    _ = home
    monkeypatch.setenv("APPLE", "h")
    link_name = "~/.i"
    target = "$APPLE"
    dotfiles.write("h", "grape")
    dotfiles.write_config([{"link": {link_name: {"path": target, "relink": True}}}])
    run_dotbot()

    with open(os.path.abspath(os.path.expanduser(link_name))) as file:
        assert file.read() == "grape"


def test_link_environment_variable_expansion_link_name(
    monkeypatch: pytest.MonkeyPatch, home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]
) -> None:
    """Verify link expands environment variables in link name.

    If the variable doesn't exist, the "variable" must not be replaced.
    """

    monkeypatch.setenv("ORANGE", ".config")
    monkeypatch.setenv("BANANA", "g")
    monkeypatch.delenv("PEAR", raising=False)

    dotfiles.write("f", "apple")
    dotfiles.write("h", "grape")

    config = [
        {
            "link": {
                "~/${ORANGE}/$BANANA": {
                    "path": "f",
                    "create": True,
                },
                "~/$PEAR": "h",
            }
        }
    ]
    dotfiles.write_config(config)
    run_dotbot()

    with open(os.path.join(home, ".config", "g")) as file:
        assert file.read() == "apple"
    with open(os.path.join(home, "$PEAR")) as file:
        assert file.read() == "grape"


def test_link_environment_variable_unset(
    monkeypatch: pytest.MonkeyPatch, home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]
) -> None:
    """Verify link leaves unset environment variables."""

    monkeypatch.delenv("ORANGE", raising=False)
    dotfiles.write("$ORANGE", "apple")
    dotfiles.write_config([{"link": {"~/f": "$ORANGE"}}])
    run_dotbot()

    with open(os.path.join(home, "f")) as file:
        assert file.read() == "apple"


def test_link_force_leaves_when_nonexistent(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify force doesn't erase existing files when targets are nonexistent."""

    os.mkdir(os.path.join(home, "dir"))
    open(os.path.join(home, "file"), "w").close()
    config = [
        {
            "link": {
                "~/dir": {"path": "dir", "force": True},
                "~/file": {"path": "file", "force": True},
            }
        }
    ]
    dotfiles.write_config(config)
    with pytest.raises(SystemExit):
        run_dotbot()

    assert os.path.isdir(os.path.join(home, "dir"))
    assert os.path.isfile(os.path.join(home, "file"))


def test_link_force_overwrite_symlink(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify force overwrites a symlinked directory."""

    os.mkdir(os.path.join(home, "dir"))
    dotfiles.write("dir/f")
    os.symlink(home, os.path.join(home, ".dir"))

    config = [{"link": {"~/.dir": {"path": "dir", "force": True}}}]
    dotfiles.write_config(config)
    run_dotbot()

    assert os.path.isfile(os.path.join(home, ".dir", "f"))


def test_link_force_directory(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify force deletes directories."""

    os.mkdir(os.path.join(home, ".dir"))
    with open(os.path.join(home, ".dir", "f"), "w") as f:
        f.write("apple")
    dotfiles.write("dir/f", "banana")

    config = [{"link": {"~/.dir": {"path": "dir", "force": True}}}]
    dotfiles.write_config(config)
    run_dotbot()

    with open(os.path.join(home, ".dir", "f")) as file:
        assert file.read() == "banana"


def test_link_backup_directory(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify that a backup directory is created if destination directory exists."""

    os.mkdir(os.path.join(home, ".dir"))
    with open(os.path.join(home, ".dir", "f"), "w") as f:
        f.write("apple")
    dotfiles.write("dir/f", "banana")

    config = [{"link": {"~/.dir": {"path": "dir", "backup": True}}}]
    dotfiles.write_config(config)
    run_dotbot()

    backup_dirs = [d for d in os.listdir(home) if d.startswith(".dir.dotbot-backup")]
    assert len(backup_dirs) == 1
    with open(os.path.join(home, backup_dirs[0], "f")) as file:
        assert file.read() == "apple"
    with open(os.path.join(home, ".dir", "f")) as file:
        assert file.read() == "banana"


def test_link_backup_file(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify that a backup file is created if destination file exists."""

    with open(os.path.join(home, ".file"), "w") as f:
        f.write("apple")
    dotfiles.write("file", "banana")

    config = [{"link": {"~/.file": {"path": "file", "backup": True}}}]
    dotfiles.write_config(config)
    run_dotbot()

    backup_files = [f for f in os.listdir(home) if f.startswith(".file.dotbot-backup")]
    assert len(backup_files) == 1
    with open(os.path.join(home, backup_files[0])) as file:
        assert file.read() == "apple"
    with open(os.path.join(home, ".file")) as file:
        assert file.read() == "banana"


def test_link_backup_not_created_if_link(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify that a backup file isn't created if destination is a symlink."""

    open(os.path.join(home, "file"), "w").close()
    dotfiles.write("file")
    os.symlink(os.path.join(home, "file"), os.path.join(home, ".file"))

    config = [{"link": {"~/.file": {"path": "file", "backup": True}}}]
    dotfiles.write_config(config)
    with pytest.raises(SystemExit):
        run_dotbot()

    assert not os.path.exists(os.path.join(home, ".file.dotbot-backup"))


def test_link_backup_created_if_force(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify that backups are created when the force option is used."""

    with open(os.path.join(home, ".file"), "w") as f:
        f.write("apple")
    dotfiles.write("file", "banana")

    config = [{"link": {"~/.file": {"path": "file", "backup": True, "force": True}}}]
    dotfiles.write_config(config)
    run_dotbot()

    backup_files = [f for f in os.listdir(home) if f.startswith(".file.dotbot-backup")]
    assert len(backup_files) == 1
    with open(os.path.join(home, backup_files[0])) as file:
        assert file.read() == "apple"
    with open(os.path.join(home, ".file")) as file:
        assert file.read() == "banana"


def test_link_backup_error_if_dest_already_exists(
    home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]
) -> None:
    """Verify an error is thrown if the backup already exists."""

    os.mkdir(os.path.join(home, ".dir"))
    # create fake backup directories; this test is technically timing dependent but it should work out in practice
    now = datetime.now(timezone.utc).astimezone()
    for delta in range(10):
        timestamp = (now + timedelta(seconds=delta)).strftime("%Y%m%d-%H%M%S")
        os.mkdir(os.path.join(home, f".dir.dotbot-backup.{timestamp}"))
        with open(os.path.join(home, f".dir.dotbot-backup.{timestamp}", "f"), "w") as file:
            file.write("apple")
    dotfiles.write("dir")

    config = [{"link": {"~/.dir": {"path": "dir", "backup": True}}}]
    dotfiles.write_config(config)
    with pytest.raises(SystemExit):
        run_dotbot()

    for delta in range(10):
        timestamp = (now + timedelta(seconds=delta)).strftime("%Y%m%d-%H%M%S")
        with open(os.path.join(home, f".dir.dotbot-backup.{timestamp}", "f")) as file:
            assert file.read() == "apple"


def test_link_backup_glob(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify that backup works with globbing."""
    dotfiles.write("bin/a", "apple")
    dotfiles.write("bin/b", "banana")
    dotfiles.write("bin/c", "cherry")

    os.mkdir(os.path.join(home, "bin"))
    with open(os.path.join(home, "bin", "a"), "w") as f:
        f.write("apricot")
    with open(os.path.join(home, "bin", "b"), "w") as f:
        f.write("blueberry")
    with open(os.path.join(home, "bin", "c"), "w") as f:
        f.write("cranberry")

    config = [
        {
            "defaults": {"link": {"glob": True, "create": True, "backup": True}},
        },
        {
            "link": {"~/bin": "bin/*"},
        },
    ]
    dotfiles.write_config(config)
    run_dotbot()

    backup_a = [f for f in os.listdir(os.path.join(home, "bin")) if f.startswith("a.dotbot-backup")]
    backup_b = [f for f in os.listdir(os.path.join(home, "bin")) if f.startswith("b.dotbot-backup")]
    backup_c = [f for f in os.listdir(os.path.join(home, "bin")) if f.startswith("c.dotbot-backup")]
    assert len(backup_a) == 1
    assert len(backup_b) == 1
    assert len(backup_c) == 1
    with open(os.path.join(home, "bin", backup_a[0])) as file:
        assert file.read() == "apricot"
    with open(os.path.join(home, "bin", backup_b[0])) as file:
        assert file.read() == "blueberry"
    with open(os.path.join(home, "bin", backup_c[0])) as file:
        assert file.read() == "cranberry"

    with open(os.path.join(home, "bin", "a")) as file:
        assert file.read() == "apple"
    with open(os.path.join(home, "bin", "b")) as file:
        assert file.read() == "banana"
    with open(os.path.join(home, "bin", "c")) as file:
        assert file.read() == "cherry"


def test_link_backup_dry_run(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify that a backup file is not created if running in dry-run mode."""

    with open(os.path.join(home, ".file"), "w") as f:
        f.write("apple")
    dotfiles.write("file", "banana")

    config = [{"link": {"~/.file": {"path": "file", "backup": True}}}]
    dotfiles.write_config(config)
    run_dotbot("-n")

    backup_files = [f for f in os.listdir(home) if f.startswith(".file.dotbot-backup")]
    assert len(backup_files) == 0


@pytest.mark.parametrize("option", ["relink", "force"])
def test_link_backup_relink_force_with_existing_incorrect_symlink(
    option: str,
    home: str,
    dotfiles: Dotfiles,
    run_dotbot: Callable[..., None],
) -> None:
    """Verify that backup + relink/force replaces an incorrect symlink.

    When an incorrect symlink exists at the destination, backup should not
    prevent the symlink from being deleted and recreated correctly.
    """

    dotfiles.write("f", "apple")
    # Create an incorrect symlink at the destination.
    wrong_target = os.path.join(home, "wrong_target")
    open(wrong_target, "w").close()
    os.symlink(wrong_target, os.path.join(home, ".f"))

    config = [{"link": {"~/.f": {"path": "f", "backup": True, option: True}}}]
    dotfiles.write_config(config)
    run_dotbot()

    # The symlink should now point to the correct target.
    with open(os.path.join(home, ".f")) as file:
        assert file.read() == "apple"
    # Symlinks are not backed up (backup only applies to real files).
    backup_files = [f for f in os.listdir(home) if f.startswith(".f.dotbot-backup")]
    assert len(backup_files) == 0


def test_link_backup_relink_real_file_skips_delete(
    home: str,
    dotfiles: Dotfiles,
    run_dotbot: Callable[..., None],
) -> None:
    """Verify that _delete is not called when backup already moved a real file.

    When backup successfully renames a real file and relink is set,
    there's no need to call _delete: the file is already gone.
    """

    dotfiles.write("f", "apple")
    with open(os.path.join(home, ".f"), "w") as f:
        f.write("banana")

    config = [{"link": {"~/.f": {"path": "f", "backup": True, "relink": True}}}]
    dotfiles.write_config(config)

    original_delete = Link._delete  # noqa: SLF001
    delete_was_called = False

    def tracking_delete(self: Any, *args: Any, **kwargs: Any) -> Any:
        nonlocal delete_was_called
        delete_was_called = True
        return original_delete(self, *args, **kwargs)

    with patch.object(Link, "_delete", tracking_delete):
        run_dotbot()

    assert not delete_was_called

    with open(os.path.join(home, ".f")) as file:
        assert file.read() == "apple"
    backup_files = [f for f in os.listdir(home) if f.startswith(".f.dotbot-backup")]
    assert len(backup_files) == 1
    with open(os.path.join(home, backup_files[0])) as file:
        assert file.read() == "banana"


def test_link_backup_relink_with_existing_incorrect_symlink_glob(
    home: str,
    dotfiles: Dotfiles,
    run_dotbot: Callable[..., None],
) -> None:
    """Verify that backup + relink replaces incorrect symlinks with globbing."""

    dotfiles.write("bin/a", "apple")
    dotfiles.write("bin/b", "banana")

    os.makedirs(os.path.join(home, "bin"))
    # Create incorrect symlinks at the destinations.
    wrong_target = os.path.join(home, "wrong_target")
    open(wrong_target, "w").close()
    os.symlink(wrong_target, os.path.join(home, "bin", "a"))
    os.symlink(wrong_target, os.path.join(home, "bin", "b"))

    config = [
        {"defaults": {"link": {"glob": True, "create": True, "backup": True, "relink": True}}},
        {"link": {"~/bin": "bin/*"}},
    ]
    dotfiles.write_config(config)
    run_dotbot()

    with open(os.path.join(home, "bin", "a")) as file:
        assert file.read() == "apple"
    with open(os.path.join(home, "bin", "b")) as file:
        assert file.read() == "banana"


def test_link_glob_1(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify globbing works."""

    dotfiles.write("bin/a", "apple")
    dotfiles.write("bin/b", "banana")
    dotfiles.write("bin/c", "cherry")
    dotfiles.write_config(
        [
            {"defaults": {"link": {"glob": True, "create": True}}},
            {"link": {"~/bin": "bin/*"}},
        ]
    )
    run_dotbot()

    with open(os.path.join(home, "bin", "a")) as file:
        assert file.read() == "apple"
    with open(os.path.join(home, "bin", "b")) as file:
        assert file.read() == "banana"
    with open(os.path.join(home, "bin", "c")) as file:
        assert file.read() == "cherry"


def test_link_glob_2(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify globbing works with a trailing slash in the target."""

    dotfiles.write("bin/a", "apple")
    dotfiles.write("bin/b", "banana")
    dotfiles.write("bin/c", "cherry")
    dotfiles.write_config(
        [
            {"defaults": {"link": {"glob": True, "create": True}}},
            {"link": {"~/bin/": "bin/*"}},
        ]
    )
    run_dotbot()

    with open(os.path.join(home, "bin", "a")) as file:
        assert file.read() == "apple"
    with open(os.path.join(home, "bin", "b")) as file:
        assert file.read() == "banana"
    with open(os.path.join(home, "bin", "c")) as file:
        assert file.read() == "cherry"


def test_link_glob_3(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify globbing works with hidden ("period-prefixed") files."""

    dotfiles.write("bin/.a", "dot-apple")
    dotfiles.write("bin/.b", "dot-banana")
    dotfiles.write("bin/.c", "dot-cherry")
    dotfiles.write_config(
        [
            {"defaults": {"link": {"glob": True, "create": True}}},
            {"link": {"~/bin/": "bin/.*"}},
        ]
    )
    run_dotbot()

    with open(os.path.join(home, "bin", ".a")) as file:
        assert file.read() == "dot-apple"
    with open(os.path.join(home, "bin", ".b")) as file:
        assert file.read() == "dot-banana"
    with open(os.path.join(home, "bin", ".c")) as file:
        assert file.read() == "dot-cherry"


def test_link_glob_4(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify globbing works at the root of the home and dotfiles directories."""

    dotfiles.write(".a", "dot-apple")
    dotfiles.write(".b", "dot-banana")
    dotfiles.write(".c", "dot-cherry")
    dotfiles.write_config(
        [
            {
                "link": {
                    "~": {
                        "path": ".*",
                        "glob": True,
                    },
                },
            }
        ]
    )
    run_dotbot()

    with open(os.path.join(home, ".a")) as file:
        assert file.read() == "dot-apple"
    with open(os.path.join(home, ".b")) as file:
        assert file.read() == "dot-banana"
    with open(os.path.join(home, ".c")) as file:
        assert file.read() == "dot-cherry"


def test_link_glob_force(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify that glob/force work together."""

    os.mkdir(os.path.join(home, "bin"))
    with open(os.path.join(home, "bin", "a"), "w") as f:
        f.write("apricot")
    with open(os.path.join(home, "bin", "b"), "w") as f:
        f.write("blueberry")
    with open(os.path.join(home, "bin", "c"), "w") as f:
        f.write("cranberry")

    dotfiles.write("bin/a", "apple")
    dotfiles.write("bin/b", "banana")
    dotfiles.write("bin/c", "cherry")
    dotfiles.write_config(
        [
            {"defaults": {"link": {"glob": True, "create": True, "force": True}}},
            {"link": {"~/bin": "bin/*"}},
        ]
    )
    run_dotbot()

    with open(os.path.join(home, "bin", "a")) as file:
        assert file.read() == "apple"
    with open(os.path.join(home, "bin", "b")) as file:
        assert file.read() == "banana"
    with open(os.path.join(home, "bin", "c")) as file:
        assert file.read() == "cherry"


@pytest.mark.parametrize("path", ["foo", "foo/"])
def test_link_glob_ignore_no_glob_chars(
    path: str, home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]
) -> None:
    """Verify ambiguous link globbing fails."""

    dotfiles.makedirs("foo")
    dotfiles.write_config(
        [
            {
                "link": {
                    "~/foo/": {
                        "path": path,
                        "glob": True,
                    }
                }
            }
        ]
    )
    run_dotbot()
    assert os.path.islink(os.path.join(home, "foo"))
    assert os.path.exists(os.path.join(home, "foo"))


def test_link_glob_exclude_1(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify link globbing with an explicit exclusion."""

    dotfiles.write("config/foo/a", "apple")
    dotfiles.write("config/bar/b", "banana")
    dotfiles.write("config/bar/c", "cherry")
    dotfiles.write("config/baz/d", "donut")
    dotfiles.write_config(
        [
            {
                "defaults": {
                    "link": {
                        "glob": True,
                        "create": True,
                    },
                },
            },
            {
                "link": {
                    "~/.config/": {
                        "path": "config/*",
                        "exclude": ["config/baz"],
                    },
                },
            },
        ]
    )
    run_dotbot()

    assert not os.path.exists(os.path.join(home, ".config", "baz"))

    assert not os.path.islink(os.path.join(home, ".config"))
    assert os.path.islink(os.path.join(home, ".config", "foo"))
    assert os.path.islink(os.path.join(home, ".config", "bar"))
    with open(os.path.join(home, ".config", "foo", "a")) as file:
        assert file.read() == "apple"
    with open(os.path.join(home, ".config", "bar", "b")) as file:
        assert file.read() == "banana"
    with open(os.path.join(home, ".config", "bar", "c")) as file:
        assert file.read() == "cherry"


def test_link_glob_exclude_2(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify deep link globbing with a globbed exclusion."""

    dotfiles.write("config/foo/a", "apple")
    dotfiles.write("config/bar/b", "banana")
    dotfiles.write("config/bar/c", "cherry")
    dotfiles.write("config/baz/d", "donut")
    dotfiles.write("config/baz/buzz/e", "egg")
    dotfiles.write_config(
        [
            {
                "defaults": {
                    "link": {
                        "glob": True,
                        "create": True,
                    },
                },
            },
            {
                "link": {
                    "~/.config/": {
                        "path": "config/*/*",
                        "exclude": ["config/baz/*"],
                    },
                },
            },
        ]
    )
    run_dotbot()

    assert not os.path.exists(os.path.join(home, ".config", "baz"))

    assert not os.path.islink(os.path.join(home, ".config"))
    assert not os.path.islink(os.path.join(home, ".config", "foo"))
    assert not os.path.islink(os.path.join(home, ".config", "bar"))
    assert os.path.islink(os.path.join(home, ".config", "foo", "a"))
    with open(os.path.join(home, ".config", "foo", "a")) as file:
        assert file.read() == "apple"
    with open(os.path.join(home, ".config", "bar", "b")) as file:
        assert file.read() == "banana"
    with open(os.path.join(home, ".config", "bar", "c")) as file:
        assert file.read() == "cherry"


def test_link_glob_exclude_3(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify deep link globbing with an explicit exclusion."""

    dotfiles.write("config/foo/a", "apple")
    dotfiles.write("config/bar/b", "banana")
    dotfiles.write("config/bar/c", "cherry")
    dotfiles.write("config/baz/d", "donut")
    dotfiles.write("config/baz/buzz/e", "egg")
    dotfiles.write("config/baz/bizz/g", "grape")
    dotfiles.write_config(
        [
            {
                "defaults": {
                    "link": {
                        "glob": True,
                        "create": True,
                    },
                },
            },
            {
                "link": {
                    "~/.config/": {
                        "path": "config/*/*",
                        "exclude": ["config/baz/buzz"],
                    },
                },
            },
        ]
    )
    run_dotbot()

    assert not os.path.exists(os.path.join(home, ".config", "baz", "buzz"))

    assert not os.path.islink(os.path.join(home, ".config"))
    assert not os.path.islink(os.path.join(home, ".config", "foo"))
    assert not os.path.islink(os.path.join(home, ".config", "bar"))
    assert not os.path.islink(os.path.join(home, ".config", "baz"))
    assert os.path.islink(os.path.join(home, ".config", "baz", "bizz"))
    assert os.path.islink(os.path.join(home, ".config", "foo", "a"))
    with open(os.path.join(home, ".config", "foo", "a")) as file:
        assert file.read() == "apple"
    with open(os.path.join(home, ".config", "bar", "b")) as file:
        assert file.read() == "banana"
    with open(os.path.join(home, ".config", "bar", "c")) as file:
        assert file.read() == "cherry"
    with open(os.path.join(home, ".config", "baz", "d")) as file:
        assert file.read() == "donut"
    with open(os.path.join(home, ".config", "baz", "bizz", "g")) as file:
        assert file.read() == "grape"


def test_link_glob_exclude_4(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify deep link globbing with multiple globbed exclusions."""

    dotfiles.write("config/foo/a", "apple")
    dotfiles.write("config/bar/b", "banana")
    dotfiles.write("config/bar/c", "cherry")
    dotfiles.write("config/baz/d", "donut")
    dotfiles.write("config/baz/buzz/e", "egg")
    dotfiles.write("config/baz/bizz/g", "grape")
    dotfiles.write("config/fiz/f", "fig")
    dotfiles.write_config(
        [
            {
                "defaults": {
                    "link": {
                        "glob": True,
                        "create": True,
                    },
                },
            },
            {
                "link": {
                    "~/.config/": {
                        "path": "config/*/*",
                        "exclude": ["config/baz/*", "config/fiz/*"],
                    },
                },
            },
        ]
    )
    run_dotbot()

    assert not os.path.exists(os.path.join(home, ".config", "baz"))
    assert not os.path.exists(os.path.join(home, ".config", "fiz"))

    assert not os.path.islink(os.path.join(home, ".config"))
    assert not os.path.islink(os.path.join(home, ".config", "foo"))
    assert not os.path.islink(os.path.join(home, ".config", "bar"))
    assert os.path.islink(os.path.join(home, ".config", "foo", "a"))
    with open(os.path.join(home, ".config", "foo", "a")) as file:
        assert file.read() == "apple"
    with open(os.path.join(home, ".config", "bar", "b")) as file:
        assert file.read() == "banana"
    with open(os.path.join(home, ".config", "bar", "c")) as file:
        assert file.read() == "cherry"


def test_link_glob_multi_star(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify link globbing with deep-nested stars."""

    dotfiles.write("config/foo/a", "apple")
    dotfiles.write("config/bar/b", "banana")
    dotfiles.write("config/bar/c", "cherry")
    dotfiles.write_config(
        [
            {"defaults": {"link": {"glob": True, "create": True}}},
            {"link": {"~/.config/": "config/*/*"}},
        ]
    )
    run_dotbot()

    assert not os.path.islink(os.path.join(home, ".config"))
    assert not os.path.islink(os.path.join(home, ".config", "foo"))
    assert not os.path.islink(os.path.join(home, ".config", "bar"))
    assert os.path.islink(os.path.join(home, ".config", "foo", "a"))
    with open(os.path.join(home, ".config", "foo", "a")) as file:
        assert file.read() == "apple"
    with open(os.path.join(home, ".config", "bar", "b")) as file:
        assert file.read() == "banana"
    with open(os.path.join(home, ".config", "bar", "c")) as file:
        assert file.read() == "cherry"


@pytest.mark.parametrize(
    ("pattern", "expect_file"),
    [
        ("conf/*", lambda fruit: fruit),
        ("conf/.*", lambda fruit: "." + fruit),
        ("conf/[bc]*", lambda fruit: fruit if fruit[0] in "bc" else None),
        ("conf/*e", lambda fruit: fruit if fruit[-1] == "e" else None),
        ("conf/??r*", lambda fruit: fruit if fruit[2] == "r" else None),
    ],
)
def test_link_glob_patterns(
    pattern: str,
    expect_file: Callable[[str], Optional[str]],
    home: str,
    dotfiles: Dotfiles,
    run_dotbot: Callable[..., None],
) -> None:
    """Verify link glob pattern matching."""

    fruits = ["apple", "apricot", "banana", "cherry", "currant", "cantalope"]
    for fruit in fruits:
        dotfiles.write("conf/" + fruit, fruit)
        dotfiles.write("conf/." + fruit, "dot-" + fruit)
    dotfiles.write_config(
        [
            {"defaults": {"link": {"glob": True, "create": True}}},
            {"link": {"~/globtest": pattern}},
        ]
    )
    run_dotbot()

    for fruit in fruits:
        expected = expect_file(fruit)
        if expected is None:
            assert not os.path.exists(os.path.join(home, "globtest", fruit))
            assert not os.path.exists(os.path.join(home, "globtest", "." + fruit))
        elif "." in expected:
            assert not os.path.islink(os.path.join(home, "globtest", fruit))
            assert os.path.islink(os.path.join(home, "globtest", "." + fruit))
        else:  # "." not in expected
            assert os.path.islink(os.path.join(home, "globtest", fruit))
            assert not os.path.islink(os.path.join(home, "globtest", "." + fruit))


def test_link_glob_recursive(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify recursive link globbing and exclusions."""

    dotfiles.write("config/foo/bar/a", "apple")
    dotfiles.write("config/foo/bar/b", "banana")
    dotfiles.write("config/foo/bar/c", "cherry")
    dotfiles.write_config(
        [
            {"defaults": {"link": {"glob": True, "create": True}}},
            {"link": {"~/.config/": {"path": "config/**", "exclude": ["config/**/b"]}}},
        ]
    )
    run_dotbot()

    assert not os.path.islink(os.path.join(home, ".config"))
    assert not os.path.islink(os.path.join(home, ".config", "foo"))
    assert not os.path.islink(os.path.join(home, ".config", "foo", "bar"))
    assert os.path.islink(os.path.join(home, ".config", "foo", "bar", "a"))
    assert not os.path.exists(os.path.join(home, ".config", "foo", "bar", "b"))
    assert os.path.islink(os.path.join(home, ".config", "foo", "bar", "c"))
    with open(os.path.join(home, ".config", "foo", "bar", "a")) as file:
        assert file.read() == "apple"
    with open(os.path.join(home, ".config", "foo", "bar", "c")) as file:
        assert file.read() == "cherry"


def test_link_glob_no_match(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify that a glob with no match doesn't raise an error."""

    _ = home
    dotfiles.makedirs("foo")
    dotfiles.write_config(
        [
            {"defaults": {"link": {"glob": True, "create": True}}},
            {"link": {"~/.config/foo": "foo/*"}},
        ]
    )
    run_dotbot()


def test_link_glob_single_match(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify linking works even when glob matches exactly one file."""
    # regression test for https://github.com/anishathalye/dotbot/issues/282

    dotfiles.write("foo/a", "apple")
    dotfiles.write_config(
        [
            {"defaults": {"link": {"glob": True, "create": True}}},
            {"link": {"~/.config/foo": "foo/*"}},
        ]
    )
    run_dotbot()

    assert not os.path.islink(os.path.join(home, ".config"))
    assert not os.path.islink(os.path.join(home, ".config", "foo"))
    assert os.path.islink(os.path.join(home, ".config", "foo", "a"))
    with open(os.path.join(home, ".config", "foo", "a")) as file:
        assert file.read() == "apple"


@pytest.mark.skipif(
    "sys.platform == 'win32'",
    reason="These if commands won't run on Windows",
)
def test_link_if(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify 'if' directives are checked when linking."""

    os.mkdir(os.path.join(home, "d"))
    dotfiles.write("f", "apple")
    dotfiles.write_config(
        [
            {
                "link": {
                    "~/.f": {"path": "f", "if": "true"},
                    "~/.g": {"path": "f", "if": "false"},
                    "~/.h": {"path": "f", "if": "[ -d ~/d ]"},
                    "~/.i": {"path": "f", "if": "badcommand"},
                },
            }
        ]
    )
    run_dotbot()

    assert not os.path.exists(os.path.join(home, ".g"))
    assert not os.path.exists(os.path.join(home, ".i"))
    with open(os.path.join(home, ".f")) as file:
        assert file.read() == "apple"
    with open(os.path.join(home, ".h")) as file:
        assert file.read() == "apple"


@pytest.mark.skipif(
    "sys.platform == 'win32'",
    reason="These if commands won't run on Windows",
)
def test_link_if_defaults(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify 'if' directive defaults are checked when linking."""

    os.mkdir(os.path.join(home, "d"))
    dotfiles.write("f", "apple")
    dotfiles.write_config(
        [
            {
                "defaults": {
                    "link": {
                        "if": "false",
                    },
                },
            },
            {
                "link": {
                    "~/.j": {"path": "f", "if": "true"},
                    "~/.k": {"path": "f"},  # default is false
                },
            },
        ]
    )
    run_dotbot()

    assert not os.path.exists(os.path.join(home, ".k"))
    with open(os.path.join(home, ".j")) as file:
        assert file.read() == "apple"


@pytest.mark.skipif(
    "sys.platform != 'win32'",
    reason="These if commands only run on Windows",
)
def test_link_if_windows(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify 'if' directives are checked when linking (Windows only)."""

    os.mkdir(os.path.join(home, "d"))
    dotfiles.write("f", "apple")
    dotfiles.write_config(
        [
            {
                "link": {
                    "~/.f": {"path": "f", "if": 'cmd /c "exit 0"'},
                    "~/.g": {"path": "f", "if": 'cmd /c "exit 1"'},
                    "~/.h": {"path": "f", "if": 'cmd /c "dir %USERPROFILE%\\d'},
                    "~/.i": {"path": "f", "if": 'cmd /c "badcommand"'},
                },
            }
        ]
    )
    run_dotbot()

    assert not os.path.exists(os.path.join(home, ".g"))
    assert not os.path.exists(os.path.join(home, ".i"))
    with open(os.path.join(home, ".f")) as file:
        assert file.read() == "apple"
    with open(os.path.join(home, ".h")) as file:
        assert file.read() == "apple"


@pytest.mark.skipif(
    "sys.platform != 'win32'",
    reason="These if commands only run on Windows.",
)
def test_link_if_defaults_windows(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify 'if' directive defaults are checked when linking (Windows only)."""

    os.mkdir(os.path.join(home, "d"))
    dotfiles.write("f", "apple")
    dotfiles.write_config(
        [
            {
                "defaults": {
                    "link": {
                        "if": 'cmd /c "exit 1"',
                    },
                },
            },
            {
                "link": {
                    "~/.j": {"path": "f", "if": 'cmd /c "exit 0"'},
                    "~/.k": {"path": "f"},  # default is false
                },
            },
        ]
    )
    run_dotbot()

    assert not os.path.exists(os.path.join(home, ".k"))
    with open(os.path.join(home, ".j")) as file:
        assert file.read() == "apple"


@pytest.mark.parametrize("ignore_missing", [True, False])
def test_link_ignore_missing(
    ignore_missing: bool,  # noqa: FBT001
    home: str,
    dotfiles: Dotfiles,
    run_dotbot: Callable[..., None],
) -> None:
    """Verify link 'ignore_missing' is respected when the target is missing."""

    dotfiles.write_config(
        [
            {
                "link": {
                    "~/missing_link": {
                        "path": "missing",
                        "ignore-missing": ignore_missing,
                    },
                },
            }
        ]
    )

    if ignore_missing:
        run_dotbot()
        assert os.path.islink(os.path.join(home, "missing_link"))
    else:
        with pytest.raises(SystemExit):
            run_dotbot()


def test_link_leaves_file(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify relink does not overwrite file."""

    dotfiles.write("f", "apple")
    with open(os.path.join(home, ".f"), "w") as file:
        file.write("grape")
    dotfiles.write_config([{"link": {"~/.f": "f"}}])
    with pytest.raises(SystemExit):
        run_dotbot()

    with open(os.path.join(home, ".f")) as file:
        assert file.read() == "grape"


@pytest.mark.parametrize("key", ["canonicalize-path", "canonicalize"])
def test_link_no_canonicalize(key: str, home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify link canonicalization can be disabled."""

    dotfiles.write("f", "apple")
    dotfiles.write_config([{"defaults": {"link": {key: False}}}, {"link": {"~/.f": {"path": "f"}}}])
    os.symlink(
        dotfiles.directory,
        os.path.join(home, "dotfiles-symlink"),
        target_is_directory=True,
    )
    run_dotbot(
        "-c",
        os.path.join(home, "dotfiles-symlink", os.path.basename(dotfiles.config_filename)),
        custom=True,
    )
    assert "dotfiles-symlink" in os.readlink(os.path.join(home, ".f"))


def test_link_prefix(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify link prefixes are prepended."""

    dotfiles.write("conf/a", "apple")
    dotfiles.write("conf/b", "banana")
    dotfiles.write("conf/c", "cherry")
    dotfiles.write_config(
        [
            {
                "link": {
                    "~/": {
                        "glob": True,
                        "path": "conf/*",
                        "prefix": ".",
                    },
                },
            }
        ]
    )
    run_dotbot()
    with open(os.path.join(home, ".a")) as file:
        assert file.read() == "apple"
    with open(os.path.join(home, ".b")) as file:
        assert file.read() == "banana"
    with open(os.path.join(home, ".c")) as file:
        assert file.read() == "cherry"


def test_link_relative(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Test relative linking works."""

    dotfiles.write("f", "apple")
    dotfiles.write("d/e", "grape")
    dotfiles.write_config(
        [
            {
                "link": {
                    "~/.f": {
                        "path": "f",
                    },
                    "~/.frel": {
                        "path": "f",
                        "relative": True,
                    },
                    "~/nested/.frel": {
                        "path": "f",
                        "relative": True,
                        "create": True,
                    },
                    "~/.d": {
                        "path": "d",
                        "relative": True,
                    },
                },
            }
        ]
    )
    run_dotbot()

    f = os.readlink(os.path.join(home, ".f"))
    if sys.platform == "win32" and f.startswith("\\\\?\\"):
        f = f[4:]
    assert f == os.path.join(dotfiles.directory, "f")

    frel = os.readlink(os.path.join(home, ".frel"))
    if sys.platform == "win32" and frel.startswith("\\\\?\\"):
        frel = frel[4:]
    assert frel == os.path.normpath("../../dotfiles/f")

    nested_frel = os.readlink(os.path.join(home, "nested", ".frel"))
    if sys.platform == "win32" and nested_frel.startswith("\\\\?\\"):
        nested_frel = nested_frel[4:]
    assert nested_frel == os.path.normpath("../../../dotfiles/f")

    d = os.readlink(os.path.join(home, ".d"))
    if sys.platform == "win32" and d.startswith("\\\\?\\"):
        d = d[4:]
    assert d == os.path.normpath("../../dotfiles/d")

    with open(os.path.join(home, ".f")) as file:
        assert file.read() == "apple"
    with open(os.path.join(home, ".frel")) as file:
        assert file.read() == "apple"
    with open(os.path.join(home, "nested", ".frel")) as file:
        assert file.read() == "apple"
    with open(os.path.join(home, ".d", "e")) as file:
        assert file.read() == "grape"


def test_link_relink_leaves_file(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify relink does not overwrite file."""

    dotfiles.write("f", "apple")
    with open(os.path.join(home, ".f"), "w") as file:
        file.write("grape")
    dotfiles.write_config([{"link": {"~/.f": {"path": "f", "relink": True}}}])
    with pytest.raises(SystemExit):
        run_dotbot()
    with open(os.path.join(home, ".f")) as file:
        assert file.read() == "grape"


def test_link_relink_overwrite_symlink(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify relink overwrites symlinks."""

    dotfiles.write("f", "apple")
    with open(os.path.join(home, "f"), "w") as file:
        file.write("grape")
    os.symlink(os.path.join(home, "f"), os.path.join(home, ".f"))
    dotfiles.write_config([{"link": {"~/.f": {"path": "f", "relink": True}}}])
    run_dotbot()
    with open(os.path.join(home, ".f")) as file:
        assert file.read() == "apple"


def test_link_relink_relative_leaves_file(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify relink relative does not incorrectly relink file."""

    dotfiles.write("f", "apple")
    with open(os.path.join(home, ".f"), "w") as file:
        file.write("grape")
    config = [
        {
            "link": {
                "~/.folder/f": {
                    "path": "f",
                    "create": True,
                    "relative": True,
                },
            },
        }
    ]
    dotfiles.write_config(config)
    run_dotbot()

    mtime = os.stat(os.path.join(home, ".folder", "f")).st_mtime

    config[0]["link"]["~/.folder/f"]["relink"] = True
    dotfiles.write_config(config)
    run_dotbot()

    new_mtime = os.stat(os.path.join(home, ".folder", "f")).st_mtime
    assert mtime == new_mtime


def test_target_is_not_overwritten_by_symlink_trickery(
    capsys: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]
) -> None:
    dotfiles_path = pathlib.Path(dotfiles.directory)
    home_path = pathlib.Path(home)

    # Setup:
    #   *   A symlink exists from `~/.ssh` to `ssh` in the dotfiles directory.
    #   *   Dotbot is configured to force-recreate a symlink between two paths
    #       when, in reality, it's actually the same file when resolved.
    ssh_config = (dotfiles_path / "ssh/config").absolute()
    os.mkdir(str(ssh_config.parent))
    ssh_config.write_text("preserve me!")
    os.symlink(str(ssh_config.parent), str(home_path / ".ssh"))
    dotfiles.write_config(
        [
            {
                "defaults": {
                    "link": {
                        "relink": True,
                        "create": True,
                        "force": True,
                    },
                }
            },
            {
                "link": {
                    # When symlinks are resolved, these are actually the same file.
                    "~/.ssh/config": "ssh/config",
                },
            },
        ]
    )

    # Execute dotbot.
    with pytest.raises(SystemExit):
        run_dotbot()

    stdout, _ = capsys.readouterr()
    assert "appears to be the same file" in stdout
    # Verify that the file was not overwritten.
    assert ssh_config.read_text() == "preserve me!"


def test_link_defaults_1(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify that link doesn't overwrite non-dotfiles links by default."""

    with open(os.path.join(home, "f"), "w") as file:
        file.write("grape")
    os.symlink(os.path.join(home, "f"), os.path.join(home, ".f"))
    dotfiles.write("f", "apple")
    dotfiles.write_config(
        [
            {
                "link": {"~/.f": "f"},
            }
        ]
    )
    with pytest.raises(SystemExit):
        run_dotbot()

    with open(os.path.join(home, ".f")) as file:
        assert file.read() == "grape"


def test_link_defaults_2(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify that explicit link defaults override the implicit default."""

    with open(os.path.join(home, "f"), "w") as file:
        file.write("grape")
    os.symlink(os.path.join(home, "f"), os.path.join(home, ".f"))
    dotfiles.write("f", "apple")
    dotfiles.write_config(
        [
            {"defaults": {"link": {"relink": True}}},
            {"link": {"~/.f": "f"}},
        ]
    )
    run_dotbot()

    with open(os.path.join(home, ".f")) as file:
        assert file.read() == "apple"


@pytest.mark.parametrize(
    "config",
    [
        pytest.param([{"link": {"~/.f": "f"}}], id="unspecified"),
        pytest.param(
            [{"link": {"~/.f": {"path": "f", "type": "symlink"}}}],
            id="specified",
        ),
        pytest.param(
            [
                {"defaults": {"link": {"type": "symlink"}}},
                {"link": {"~/.f": "f"}},
            ],
            id="symlink set for all links by default",
        ),
    ],
)
def test_link_type_symlink(
    config: List[Dict[str, Any]], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]
) -> None:
    """Verify that symlinks are created by default, and when specified."""

    dotfiles.write("f", "apple")
    dotfiles.write_config(config)
    run_dotbot()

    assert os.path.islink(os.path.join(home, ".f"))


@pytest.mark.parametrize(
    "config",
    [
        pytest.param(
            [{"link": {"~/.f": {"path": "f", "type": "hardlink"}}}],
            id="specified",
        ),
        pytest.param(
            [
                {"defaults": {"link": {"type": "hardlink"}}},
                {"link": {"~/.f": "f"}},
            ],
            id="hardlink set for all links by default",
        ),
    ],
)
def test_link_type_hardlink(
    config: List[Dict[str, Any]], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]
) -> None:
    """Verify that hardlinks are created when specified."""

    dotfiles.write("f", "apple")
    assert os.stat(os.path.join(dotfiles.directory, "f")).st_nlink == 1
    dotfiles.write_config(config)
    run_dotbot()

    assert not os.path.islink(os.path.join(home, ".f"))
    assert os.stat(os.path.join(dotfiles.directory, "f")).st_nlink == 2
    assert os.stat(os.path.join(home, ".f")).st_nlink == 2


@pytest.mark.parametrize(
    "config",
    [
        pytest.param(
            [{"defaults": {"link": {"type": "default-bogus"}}, "link": {}}],
            id="default link type not recognized",
        ),
        pytest.param(
            [{"link": {"~/.f": {"type": "specified-bogus"}}}],
            id="specified link type not recognized",
        ),
    ],
)
def test_unknown_link_type(
    capsys: pytest.CaptureFixture[str],
    config: List[Dict[str, Any]],
    dotfiles: Dotfiles,
    run_dotbot: Callable[..., None],
) -> None:
    """Verify that unknown link types are rejected."""

    dotfiles.write_config(config)
    with pytest.raises(SystemExit):
        run_dotbot()
    stdout, _ = capsys.readouterr()
    assert "link type is not recognized" in stdout


def test_symlink_exists_when_hardlink_requested(
    capsys: pytest.CaptureFixture[str],
    home: str,
    dotfiles: Dotfiles,
    run_dotbot: Callable[..., None],
) -> None:
    """Confirm messaging when a symlink exists but a hardlink is requested."""

    # Setup: Create a symlink to the hardlink target.
    dotfiles.write("target", "potato")
    os.symlink(
        os.path.join(dotfiles.directory, "target"),
        os.path.join(home, "hardlink"),
    )

    # Act
    dotfiles.write_config([{"link": {"~/hardlink": {"path": "target", "type": "hardlink"}}}])
    with pytest.raises(SystemExit):
        run_dotbot()

    # Verify
    stdout, _ = capsys.readouterr()
    assert "already exists but is a symbolic link, not a hard link" in stdout


def test_hardlink_already_exists(
    capsys: pytest.CaptureFixture[str],
    home: str,
    dotfiles: Dotfiles,
    run_dotbot: Callable[..., None],
) -> None:
    """Confirm messaging when the hardlink already exists."""

    dotfiles.write("target", "potato")
    os.link(
        os.path.join(dotfiles.directory, "target"),
        os.path.join(home, "hardlink"),
    )

    # Act
    dotfiles.write_config([{"link": {"~/hardlink": {"path": "target", "type": "hardlink"}}}])
    run_dotbot("-v")

    # Verify
    stdout, _ = capsys.readouterr()
    assert "Link exists" in stdout


def test_broken_symlink_shows_invalid_link_message(
    capsys: pytest.CaptureFixture[str],
    home: str,
    dotfiles: Dotfiles,
    run_dotbot: Callable[..., None],
) -> None:
    """Verify that broken symlinks show 'Invalid link' message instead of 'Linking failed'."""

    broken_target = os.path.join(home, "nonexistent")
    link_name = os.path.join(home, ".f")
    os.symlink(broken_target, link_name)

    dotfiles.write("f", "apple")
    dotfiles.write_config([{"link": {"~/.f": "f"}}])

    with pytest.raises(SystemExit):
        run_dotbot()

    stdout, _ = capsys.readouterr()
    assert "Invalid link" in stdout
    assert "Linking failed" not in stdout


def test_link_dry_run(
    capfd: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]
) -> None:
    """Verify that the link plugin does not create links during a dry run."""

    dotfiles.write("f", "apple")
    dotfiles.write("g", "pear")
    dotfiles.write("h", "banana")
    os.symlink(os.path.join(dotfiles.directory, "g"), os.path.join(home, ".g"))
    dotfiles.write_config(
        [{"link": {"~/.f": "f", "~/.g": {"path": None, "relink": True}, "~/.h": {"path": "h", "type": "hardlink"}}}]
    )
    run_dotbot("-n", "-v")

    assert not os.path.exists(os.path.join(home, ".f"))

    lines = capfd.readouterr().out.splitlines()
    assert any(
        f"Link exists {os.path.join('~', '.g')} -> {os.path.join(dotfiles.directory, 'g')}" == line.strip()
        for line in lines
    )
    assert any(
        f"Would create symlink {os.path.join('~', '.f')} -> {os.path.join(dotfiles.directory, 'f')}" == line.strip()
        for line in lines
    )
    assert any(
        f"Would create hardlink {os.path.join('~', '.h')} -> {os.path.join(dotfiles.directory, 'h')}" == line.strip()
        for line in lines
    )


def test_link_dry_run_if(
    capfd: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]
) -> None:
    """Verify that the link plugin does run condition checks during a dry run."""

    dotfiles.write("f", "apple")
    dotfiles.write("g", "pear")
    dotfiles.write_config(
        [
            {
                "link": {
                    "~/.f": {
                        "path": "f",
                        "if": "touch " + os.path.join(home, "side_effect"),
                    },
                    "~/.g": {
                        "path": "g",
                        "if": "false",
                    },
                }
            }
        ]
    )
    run_dotbot("-n")

    assert os.path.exists(os.path.join(home, "side_effect"))
    assert not os.path.exists(os.path.join(home, ".f"))

    lines = capfd.readouterr().out.splitlines()
    assert any(
        f"Would create symlink {os.path.join('~', '.f')} -> {os.path.join(dotfiles.directory, 'f')}" == line.strip()
        for line in lines
    )
    assert not any("Would create symlink {os.path.join('~', '.g')}" in line for line in lines)


def test_link_dry_run_create(
    capfd: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]
) -> None:
    """Verify that the link plugin does not create parent directories during a dry run."""

    dotfiles.write("f", "apple")
    dotfiles.write_config(
        [
            {
                "link": {
                    "~/.config/.f": {
                        "path": "f",
                        "create": True,
                    }
                }
            }
        ]
    )
    run_dotbot("-n")
    assert not os.path.exists(os.path.join(home, ".config"))
    assert not os.path.exists(os.path.join(home, ".config", ".f"))

    lines = capfd.readouterr().out.splitlines()
    assert any(line.strip() == f"Would create directory {os.path.join(home, '.config')}" for line in lines)
    assert any(
        f"Would create symlink {os.path.join('~', '.config', '.f')} -> {os.path.join(dotfiles.directory, 'f')}"
        == line.strip()
        for line in lines
    )


def test_link_dry_run_relink(
    capfd: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]
) -> None:
    """Verify that the link plugin does not relink existing links during a dry run."""

    dotfiles.write("f", "apple")
    dotfiles.write("g", "pear")
    dotfiles.write_config(
        [
            {
                "link": {
                    "~/.f": {
                        "path": "f",
                        "relink": True,
                    }
                }
            }
        ]
    )
    os.symlink(os.path.join(dotfiles.directory, "g"), os.path.join(home, ".f"))
    run_dotbot("-n")
    with open(os.path.join(home, ".f")) as file:
        assert file.read() == "pear"

    lines = capfd.readouterr().out.splitlines()
    assert any(line.strip() == f"Would remove {os.path.join('~', '.f')}" for line in lines)
    assert any(
        f"Would create symlink {os.path.join('~', '.f')} -> {os.path.join(dotfiles.directory, 'f')}" == line.strip()
        for line in lines
    )


def test_link_dry_run_overwrite(
    capfd: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]
) -> None:
    """Verify that the link plugin does not delete existing files during a dry run."""

    dotfiles.write("f", "apple")
    dotfiles.write_config(
        [
            {
                "link": {
                    "~/.f": {
                        "path": "f",
                        "force": True,
                    }
                }
            }
        ]
    )
    with open(os.path.join(home, ".f"), "w") as file:
        file.write("pear")
    run_dotbot("-n")
    with open(os.path.join(home, ".f")) as file:
        assert file.read() == "pear"

    lines = capfd.readouterr().out.splitlines()
    assert any(line.strip() == f"Would remove {os.path.join('~', '.f')}" for line in lines)
    assert any(
        f"Would create symlink {os.path.join('~', '.f')} -> {os.path.join(dotfiles.directory, 'f')}" == line.strip()
        for line in lines
    )


@pytest.mark.skipif(
    "sys.platform == 'win32'",
    reason="Permissions work differently on Windows",
)
@pytest.mark.skipif(
    "hasattr(os, 'getuid') and os.getuid() == 0",
    reason="Root bypasses permission checks",
)
def test_link_error_creating_link(
    capsys: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]
) -> None:
    """Verify that link reports link creation errors."""

    os.makedirs(os.path.join(home, "subdir"))
    dotfiles.write("f", "apple")
    dotfiles.write_config([{"link": {"~/subdir/.f": "f"}}])

    # Remove all write permissions from subdir.
    old_permissions = stat.S_IMODE(os.stat(os.path.join(home, "subdir")).st_mode)
    os.chmod(
        os.path.join(home, "subdir"),
        stat.S_IRUSR | stat.S_IXUSR | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH,
    )

    with pytest.raises(SystemExit):
        run_dotbot()

    # Restore permissions to allow test cleanup.
    os.chmod(os.path.join(home, "subdir"), old_permissions)

    stdout, _ = capsys.readouterr()
    assert "Linking failed" in stdout


@pytest.mark.skipif(
    "sys.platform == 'win32'",
    reason="Permissions work differently on Windows",
)
@pytest.mark.skipif(
    "hasattr(os, 'getuid') and os.getuid() == 0",
    reason="Root bypasses permission checks",
)
def test_link_error_creating_directory(
    capsys: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]
) -> None:
    """Verify that link reports directory creation errors."""

    os.makedirs(os.path.join(home, "subdir"))
    dotfiles.write("f", "apple")
    dotfiles.write_config([{"link": {"~/subdir/subsubdir/.f": {"path": "f", "create": True}}}])

    # Remove all write permissions from subdir.
    old_permissions = stat.S_IMODE(os.stat(os.path.join(home, "subdir")).st_mode)
    os.chmod(
        os.path.join(home, "subdir"),
        stat.S_IRUSR | stat.S_IXUSR | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH,
    )

    with pytest.raises(SystemExit):
        run_dotbot()

    # Restore permissions to allow test cleanup.
    os.chmod(os.path.join(home, "subdir"), old_permissions)

    stdout, _ = capsys.readouterr()
    assert "Failed to create directory" in stdout


@pytest.mark.skipif(
    "sys.platform == 'win32'",
    reason="Permissions work differently on Windows",
)
@pytest.mark.skipif(
    "hasattr(os, 'getuid') and os.getuid() == 0",
    reason="Root bypasses permission checks",
)
def test_link_error_delete(
    capsys: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]
) -> None:
    """Verify that link reports deletion errors."""

    os.makedirs(os.path.join(home, "subdir"))
    with open(os.path.join(home, "subdir", ".f"), "w") as file:
        file.write("grape")
    dotfiles.write("f", "apple")
    dotfiles.write_config([{"link": {"~/subdir/.f": {"path": "f", "force": True}}}])

    # Remove all write permissions from subdir.
    old_permissions = stat.S_IMODE(os.stat(os.path.join(home, "subdir")).st_mode)
    os.chmod(
        os.path.join(home, "subdir"),
        stat.S_IRUSR | stat.S_IXUSR | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH,
    )

    with pytest.raises(SystemExit):
        run_dotbot()

    # Restore permissions to allow test cleanup.
    os.chmod(os.path.join(home, "subdir"), old_permissions)

    stdout, _ = capsys.readouterr()
    assert "Failed to remove" in stdout
