import json
import logging
import os
import shutil
import subprocess as sp
import tempfile
from collections import defaultdict
from collections.abc import Iterator
from pathlib import Path
from typing import NamedTuple

import pytest
from clan_cli.tests import age_keys
from clan_cli.tests.fixture_error import FixtureError
from clan_cli.tests.root import CLAN_CORE
from clan_cli.tests.temporary_dir import TEMPDIR
from clan_lib import cmd
from clan_lib.dirs import (
    TemplateType,
    clan_templates,
    nixpkgs_source,
    specific_machine_dir,
)
from clan_lib.flake import Flake
from clan_lib.locked_open import locked_open
from clan_lib.machines.machines import Machine
from clan_lib.nix import nix_command, nix_test_store

log = logging.getLogger(__name__)

lock_nix = os.environ.get("LOCK_NIX", "")
if not lock_nix:
    lock_nix = tempfile.NamedTemporaryFile().name  # NOQA: SIM115


# allows defining nested dictionary in a single line
def def_value() -> defaultdict:
    return defaultdict(def_value)


def nested_dict() -> defaultdict:
    """Creates a defaultdict that allows for arbitrary levels of nesting.
    For example: d['a']['b']['c'] = value
    """
    return defaultdict(def_value)


# Substitutes strings in a file.
# This can be used on the flake.nix or default.nix of a machine
def substitute(
    file: Path,
    clan_core_flake: Path | None = None,
    flake: Path = Path(__file__).parent,
    inventory_expr: str = r"{}",
) -> None:
    sops_key = str(flake.joinpath("sops.key"))
    buf = ""

    clan_core_replacement = None
    if clan_core_flake:
        clan_core_replacement = f"path://{clan_core_flake}"

        if not nix_test_store():
            _flake = Flake(str(clan_core_flake))
            _flake.prefetch()

            assert _flake.hash, "Clan core flake hash is empty"
            assert _flake.store_path, "Clan core flake store path is empty"
            clan_core_replacement = f"path://{_flake.store_path}?narHash={_flake.hash}"

    with file.open() as f:
        for line in f:
            processed_line = line.replace("__NIXPKGS__", str(nixpkgs_source()))
            if clan_core_replacement:
                processed_line = processed_line.replace(
                    "__CLAN_CORE__",
                    clan_core_replacement,
                )
                processed_line = processed_line.replace(
                    "git+https://git.clan.lol/clan/clan-core",
                    clan_core_replacement,
                )
                processed_line = processed_line.replace(
                    "https://git.clan.lol/clan/clan-core/archive/main.tar.gz",
                    clan_core_replacement,
                )
                processed_line = processed_line.replace(
                    "__INVENTORY_EXPR__",
                    str(inventory_expr),
                )

            processed_line = processed_line.replace("__CLAN_SOPS_KEY_PATH__", sops_key)
            processed_line = processed_line.replace(
                "__CLAN_SOPS_KEY_DIR__",
                str(flake / "facts"),
            )
            buf += processed_line

    print(f"file: {file}")
    print(f"clan_core: {clan_core_flake}")
    print(f"flake: {flake}")
    file.write_text(buf)

    # Lock the flake after substitution if clan_core was replaced
    if clan_core_flake:
        flake_dir = file.parent
        cmd.run(nix_command(["flake", "lock"]), cmd.RunOpts(cwd=flake_dir))


class FlakeForTest(NamedTuple):
    path: Path


def set_machine_settings(
    machine: Machine,
    machine_settings: dict,
) -> None:
    config_path = specific_machine_dir(machine) / "configuration.json"
    config_path.write_text(json.dumps(machine_settings, indent=2))


def set_git_credentials(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    monkeypatch.setenv("GIT_AUTHOR_NAME", "clan-tool")
    monkeypatch.setenv("GIT_AUTHOR_EMAIL", "clan@example.com")
    monkeypatch.setenv("GIT_COMMITTER_NAME", "clan-tool")
    monkeypatch.setenv("GIT_COMMITTER_EMAIL", "clan@example.com")


def init_git(monkeypatch: pytest.MonkeyPatch, flake: Path) -> None:
    set_git_credentials(monkeypatch)
    sp.run(["git", "init", "-b", "main"], cwd=flake, check=True)
    # TODO: Find out why test_vms_api.py fails in nix build
    # but works in pytest when this bottom line is commented out
    sp.run(["git", "add", "."], cwd=flake, check=True)
    sp.run(
        ["git", "commit", "-a", "-m", "Initial commit", "--no-gpg-sign"],
        cwd=flake,
        check=True,
    )


class ClanFlake:
    """Holds all attributes for generating a clan flake.
    For example, inventory and machine configs can be set via self.inventory and self.machines["my_machine"] = {...}.
    Whenever a flake's configuration is changed, it needs to be re-generated by calling refresh().

    Can also be used for managing templates.
    Once initialized, all its settings including all generated files, if any, can be copied using the copy() method.
    This avoids expensive re-computation, like for example creating the flake.lock over and over again.
    """

    def __init__(
        self,
        temporary_home: Path,
        flake_template: Path,
        suppress_tmp_home_warning: bool = False,
    ) -> None:
        self._flake_template = flake_template
        self.inventory = nested_dict()
        self.machines = nested_dict()

        clan_core_flake = Flake(str(CLAN_CORE))

        clan_core_replacement = f"path://{clan_core_flake}"

        # If the test doesnt define a nix_test_store, we assume it is running from a local pytest
        # and we need to prefetch the clan core flake, this would fail in a nix build
        # But is required for local testing
        if not nix_test_store():
            clan_core_flake.prefetch()

            assert clan_core_flake.hash, "Clan core flake hash is empty"
            assert clan_core_flake.store_path, "Clan core flake store path is empty"
            clan_core_replacement = (
                f"path://{clan_core_flake.store_path}?narHash={clan_core_flake.hash}"
            )

        self.substitutions: dict[str, str] = {
            "git+https://git.clan.lol/clan/clan-core": clan_core_replacement,
            "https://git.clan.lol/clan/clan-core/archive/main.tar.gz": clan_core_replacement,
        }
        self.clan_modules: list[str] = []
        self.temporary_home = temporary_home
        self.path = temporary_home / "flake"
        if not suppress_tmp_home_warning and "/tmp" not in str(os.environ.get("HOME")):  # noqa: S108 - Checking if HOME is in temp directory
            log.warning(
                f"!! $HOME does not point to a temp directory!! HOME={os.environ['HOME']}",
            )

    def copy(
        self,
        temporary_home: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> "ClanFlake":
        # copy the files to the new location
        shutil.copytree(self.path, temporary_home / "flake")
        set_git_credentials(monkeypatch)
        return ClanFlake(
            temporary_home=temporary_home,
            flake_template=self._flake_template,
        )

    def substitute(self) -> None:
        for file in self.path.rglob("*"):
            if ".git" in file.parts:
                continue
            if file.is_file():
                buf = ""
                with file.open() as f:
                    for line in f:
                        processed_line = line
                        for key, value in self.substitutions.items():
                            processed_line = processed_line.replace(key, value)
                        buf += processed_line
                file.write_text(buf)

    def init_from_template(self) -> None:
        shutil.copytree(self._flake_template, self.path)
        sp.run(["chmod", "+w", "-R", str(self.path)], check=True)
        self.substitute()
        if not (self.path / ".git").exists():
            with locked_open(Path(lock_nix), "w"):
                sp.run(
                    [
                        "nix",
                        "flake",
                        "lock",
                        "--extra-experimental-features",
                        "flakes nix-command",
                    ],
                    cwd=self.path,
                    check=True,
                )
                with pytest.MonkeyPatch.context() as mp:
                    init_git(mp, self.path)

    def refresh(self) -> None:
        if not self.path.exists():
            self.init_from_template()
        self.substitute()
        if self.inventory:
            inventory_path = self.path / "inventory.json"
            inventory_path.write_text(json.dumps(self.inventory, indent=2))
        imports = "\n".join(
            [f"clan-core.clanModules.{module}" for module in self.clan_modules],
        )
        for machine_name, machine_config in self.machines.items():
            configuration_nix = (
                self.path / "machines" / machine_name / "configuration.nix"
            )
            configuration_nix.parent.mkdir(parents=True, exist_ok=True)
            configuration_nix.write_text(
                f"""
                {{clan-core, ...}}:
                {{
                    imports = [
                        (builtins.fromJSON (builtins.readFile ./configuration.json))
                        {imports}
                    ];
                }}
            """,
            )
            machine = Machine(name=machine_name, flake=Flake(str(self.path)))
            set_machine_settings(machine, machine_config)
        sp.run(["git", "add", "."], cwd=self.path, check=True)
        sp.run(
            ["git", "commit", "-a", "-m", "Update by flake generator"],
            cwd=self.path,
            check=True,
        )


@pytest.fixture(scope="session")
def minimal_flake_template() -> Iterator[ClanFlake]:
    with (
        tempfile.TemporaryDirectory(prefix="minimal-flake-", dir=TEMPDIR) as _dirpath,
        pytest.MonkeyPatch.context() as mp,
    ):
        temporary_home = Path(_dirpath).resolve()
        mp.setenv("HOME", str(temporary_home))
        flake = ClanFlake(
            temporary_home=temporary_home,
            flake_template=clan_templates(TemplateType.CLAN) / "minimal",
        )
        flake.init_from_template()
        yield flake


@pytest.fixture
def flake(
    temporary_home: Path,
    minimal_flake_template: ClanFlake,
    monkeypatch: pytest.MonkeyPatch,
) -> ClanFlake:
    return minimal_flake_template.copy(temporary_home, monkeypatch)


@pytest.fixture
def flake_with_sops(
    flake: ClanFlake,
    sops_setup: age_keys.SopsSetup,
) -> ClanFlake:
    sops_setup.init(flake.path)
    return flake


def create_flake(
    temporary_home: Path,
    flake_template: str | Path,
    monkeypatch: pytest.MonkeyPatch,
    clan_core_flake: Path | None = None,
    # names referring to pre-defined machines from ../machines
    machines: list[str] | None = None,
    # alternatively specify the machines directly including their config
    machine_configs: dict[str, dict] | None = None,
    inventory_expr: str = r"{}",
) -> Iterator[FlakeForTest]:
    """Creates a flake with the given name and machines.
    The machine names map to the machines in ./test_machines
    """
    if machine_configs is None:
        machine_configs = {}
    if machines is None:
        machines = []
    if isinstance(flake_template, Path):
        template_path = flake_template
    else:
        template_path = Path(__file__).parent / flake_template

    flake_template_name = template_path.name

    # copy the template to a new temporary location
    flake = temporary_home / flake_template_name
    shutil.copytree(template_path, flake)
    sp.run(["chmod", "+w", "-R", str(flake)], check=True)

    # add the requested machines to the flake
    if machines:
        (flake / "machines").mkdir(parents=True, exist_ok=True)
    for machine_name in machines:
        machine_path = Path(__file__).parent / "machines" / machine_name
        shutil.copytree(machine_path, flake / "machines" / machine_name)
        substitute(
            flake / "machines" / machine_name / "default.nix",
            flake,
            inventory_expr=inventory_expr,
        )

    # generate machines from machineConfigs
    for machine_name, machine_config in machine_configs.items():
        settings_path = flake / "machines" / machine_name / "settings.json"
        settings_path.parent.mkdir(parents=True, exist_ok=True)
        settings_path.write_text(json.dumps(machine_config, indent=2))

    # in the flake.nix file replace the string __CLAN_URL__ with the the clan flake
    # provided by get_test_flake_toplevel
    flake_nix = flake / "flake.nix"
    # this is where we would install the sops key to, when updating
    substitute(flake_nix, clan_core_flake, flake, inventory_expr=inventory_expr)
    nix_options = []
    if tmp_store := nix_test_store():
        nix_options += ["--store", str(tmp_store)]

    with locked_open(Path(lock_nix), "w"):
        sp.run(
            [
                "nix",
                "flake",
                "lock",
                flake,
                "--extra-experimental-features",
                "nix-command flakes",
                *nix_options,
            ],
            check=True,
        )

    if "/tmp" not in str(os.environ.get("HOME")):  # noqa: S108 - Checking if HOME is in temp directory
        log.warning(
            f"!! $HOME does not point to a temp directory!! HOME={os.environ['HOME']}",
        )

    init_git(monkeypatch, flake)

    yield FlakeForTest(flake)


@pytest.fixture
def test_flake(
    monkeypatch: pytest.MonkeyPatch,
    temporary_home: Path,
) -> Iterator[FlakeForTest]:
    yield from create_flake(
        temporary_home=temporary_home,
        flake_template="test_flake",
        monkeypatch=monkeypatch,
    )

    # check that git diff on ./sops is empty
    if (temporary_home / "test_flake" / "sops").exists():
        git_proc = sp.run(
            ["git", "diff", "--exit-code", "./sops"],
            cwd=temporary_home / "test_flake",
            stderr=sp.PIPE,
            check=False,
        )
        if git_proc.returncode != 0:
            log.error(git_proc.stderr.decode())
            msg = "git diff on ./sops is not empty. This should not happen as all changes should be committed"
            raise FixtureError(msg)


@pytest.fixture
def test_flake_with_core(
    request: pytest.FixtureRequest,
    monkeypatch: pytest.MonkeyPatch,
    temporary_home: Path,
) -> Iterator[FlakeForTest]:
    test_params = getattr(request, "param", {})  # Default if not parametrized
    inventory_expr = test_params.get("inventory_expr", r"{}")  # Default empty inventory
    if not (CLAN_CORE / "flake.nix").exists():
        msg = "clan-core flake not found. This test requires the clan-core flake to be present"
        raise FixtureError(msg)
    yield from create_flake(
        temporary_home=temporary_home,
        flake_template="test_flake_with_core",
        clan_core_flake=CLAN_CORE,
        monkeypatch=monkeypatch,
        inventory_expr=inventory_expr,
    )


@pytest.fixture
def writable_clan_core(
    clan_core: Path,
    tmp_path: Path,
) -> Path:
    """Creates a writable copy of clan_core in a temporary directory.
    If clan_core is a git repo, copies tracked files and uncommitted changes.
    Removes vars/ and sops/ directories if they exist.
    """
    temp_flake = tmp_path / "clan-core"

    # Check if it's a git repository
    if (clan_core / ".git").exists():
        # Create the target directory
        temp_flake.mkdir(parents=True)

        # Copy all tracked and untracked files (excluding ignored)
        # Using git ls-files with -z for null-terminated output to handle filenames with spaces

        # Get tracked files
        tracked_files = (
            sp.run(
                ["git", "ls-files", "-z"],
                cwd=clan_core,
                capture_output=True,
                text=True,
                check=True,
            )
            .stdout.rstrip("\0")
            .split("\0")
        )

        # Get untracked files (excluding ignored)
        untracked_files = (
            sp.run(
                ["git", "ls-files", "-z", "--others", "--exclude-standard"],
                cwd=clan_core,
                capture_output=True,
                text=True,
                check=True,
            )
            .stdout.rstrip("\0")
            .split("\0")
        )

        # Combine and filter out empty strings
        all_files = [f for f in tracked_files + untracked_files if f]

        # Copy files preserving directory structure
        if all_files:
            sp.run(
                ["cp", "--parents", "-t", str(temp_flake), "--", *all_files],
                cwd=clan_core,
                check=True,
            )

        # Copy .git directory to maintain git functionality
        if (clan_core / ".git").is_dir():
            shutil.copytree(
                clan_core / ".git",
                temp_flake / ".git",
                ignore_dangling_symlinks=True,
            )
        else:
            # It's a git file (for submodules/worktrees)
            shutil.copy2(clan_core / ".git", temp_flake / ".git")
    else:
        # Regular copy if not a git repo
        shutil.copytree(clan_core, temp_flake, ignore_dangling_symlinks=True)

    # Make writable
    sp.run(["chmod", "-R", "+w", str(temp_flake)], check=True)

    # Remove vars and sops directories
    shutil.rmtree(temp_flake / "vars", ignore_errors=True)
    shutil.rmtree(temp_flake / "sops", ignore_errors=True)

    return temp_flake


@pytest.fixture
def vm_test_flake(
    clan_core: Path,  # noqa: ARG001
    tmp_path: Path,
) -> Path:
    """Creates a test flake that imports the VM test nixOS modules from clan-core."""
    test_flake_dir = tmp_path / "test-flake"
    test_flake_dir.mkdir(parents=True)

    metadata = sp.run(
        nix_command(["flake", "metadata", "--json"]),
        cwd=CLAN_CORE,
        capture_output=True,
        text=True,
        check=True,
    ).stdout.strip()
    metadata_json = json.loads(metadata)
    clan_core_url = f"path:{metadata_json['path']}"

    # Read the template and substitute the clan-core path
    template_path = Path(__file__).parent / "vm_test_flake.nix"
    template_content = template_path.read_text()

    # Get the current system
    system_result = sp.run(
        nix_command(["config", "show", "system"]),
        capture_output=True,
        text=True,
        check=True,
    )
    current_system = system_result.stdout.strip()

    # Substitute the clan-core URL and system
    flake_content = template_content.replace("__CLAN_CORE__", clan_core_url)
    flake_content = flake_content.replace("__SYSTEM__", current_system)

    # Write the flake.nix
    (test_flake_dir / "flake.nix").write_text(flake_content)

    # Lock the flake with --allow-dirty to handle uncommitted changes
    sp.run(
        nix_command(["flake", "lock", "--allow-dirty-locks"]),
        cwd=test_flake_dir,
        check=True,
    )

    return test_flake_dir
