#!/usr/bin/env python3
# Mikey Garcia, @gikeymarcia
# https://github.com/gikeymarcia/mydot

# Standard Library
# https://docs.python.org/3/library/functools.html?highlight=functools#functools.cached_property
from functools import cached_property
from os import chdir, getenv
import re
from pathlib import Path
from subprocess import run
from sys import exit as sys_exit
from typing import Union, List
from shutil import which

# PyPi
from pydymenu import fzf

# Project Modules
from mydot.console import console
from mydot.exceptions import MissingRepositoryLocation, WorktreeMissing


class Dotfiles:
    """Power up control of your dotfiles with fzf and python."""

    def __init__(
        self,
        git_dir: Union[Path, str, None] = None,
        work_tree: Union[Path, str, None] = None,
    ):
        """Define a 'git_dir' and 'work_tree' to begin."""
        self.bare_repo: Path = self._resolve_repo_location(git_dir)
        self.work_tree: Path = self._resolve_work_tree_location(work_tree)
        self._git_base = [
            "git",
            f"--git-dir={self.bare_repo}",
            f"--work-tree={self.work_tree}",
        ]
        self.run_from: Path = Path.cwd()
        chdir(self.work_tree)

    @staticmethod
    def _resolve_repo_location(path_loc: Union[str, Path, None]) -> Path:
        """Decides which dotfile repository location will be used.

        When None is given, try to read $DOTFILES from environment.
        When a location is specified return it's Path
        """
        if path_loc is None:
            if env_val := getenv("DOTFILES", default=None):
                return Path(env_val)
            else:
                raise MissingRepositoryLocation(
                    "No repository specified and no value for $DOTFILES in environment."
                )
        else:
            return Path(path_loc) if isinstance(path_loc, str) else path_loc

    @staticmethod
    def _resolve_work_tree_location(dir: Union[Path, str, None]) -> Path:
        """Define work tree location.

        When None given presume Path.home()
        """
        if dir is None:
            return Path.home()
        else:
            work_tree = Path(dir) if isinstance(dir, str) else dir
            if work_tree.is_dir():
                return work_tree
            else:
                msg = "Missing work-tree directory!\n" f"{work_tree} doesn't exist"
                raise WorktreeMissing(msg)

    def show_status(self) -> None:
        """Short pretty formatted info about the repo state."""
        console.print("Branches:", style="header")
        run(self._git_base + ["branch", "-a"])
        console.print("\nModified Files:", style="header")
        run(self._git_base + ["status", "-s"])

    def edit_files(self) -> List[str]:
        """Interactively choose dotfiles to open in text editor."""
        edits = fzf(
            self.list_all,
            prompt="Pick file(s) to edit: ",
            multi=True,
            preview=f"{self.preview_app}" + " {}",
        )
        if edits is None:
            sys_exit("No selection made. Cancelling action.")
        else:
            if len(edits) == 1:
                run([self.editor, edits[0]])
            else:
                run([self.editor, "-o"] + edits)
            return edits

    def add_changes(self) -> List[str]:
        """Interactively choose modified files to add to the staging area."""
        if self.modified_unstaged:
            adding = fzf(
                self.modified_unstaged,
                prompt="Choose changes to add: ",
                multi=True,
                preview=f"{self._git_str} diff --color --minimal -- " + "{}",
            )
            if adding is None:
                sys_exit("No selection made. No changes will be staged.")
            else:
                run(self._git_base + ["add", "-v", "--"] + adding)
                return adding
        else:
            sys_exit("No unstaged changes to 'add'.")

    def restore(self) -> List[str]:
        """Interactively choose files to remove from the staging area."""
        restorables = sorted(list(set(self.modified_staged + self.deleted_staged)))
        if restorables:
            restores = fzf(
                restorables,
                prompt="Choose changes to remove from staging area: ",
                multi=True,
                preview=f"{self._git_str} diff --color --minimal HEAD -- " + "{}",
            )
            if restores is None:
                sys_exit("No selection made. No files will be unstaged.")
            else:
                run(self._git_base + ["restore", "--staged", "--"] + restores)
                return restores
        else:
            self.show_status()
            sys_exit("\nNo staged changes to restore.")

    @cached_property
    def short_status(self) -> List[str]:
        output = run(
            self._git_base
            + ["status", "--short", "--untracked-files=no", "--porcelain", "-z"],
            text=True,
            capture_output=True,
        ).stdout

        def reformat_renames(m):
            """Reformat renamed entry output from 'git status -z'.

            New format: '\x00R[ MD] oldname -> rename\x00'
            Important: this is different than the format stated in 'man git-status'
            """
            return "\x00" + m.group(1) + m.group(3) + " -> " + m.group(2) + "\x00"

        renamed_regex = r"\x00(R[ MD] )(.*)\x00(.*)\x00"
        # https://docs.python.org/3/library/re.html#text-munging
        reformatted = re.sub(renamed_regex, reformat_renames, output).split("\x00")
        return [statusline for statusline in reformatted if len(statusline) > 0]

    @cached_property
    def tracked(self) -> List[str]:
        cmd = self._git_base + ["ls-tree", "--full-tree", "--full-name", "-r", "HEAD"]
        lines = run(cmd, text=True, capture_output=True).stdout.strip().split("\n")
        tracked = [item.split("\t")[-1] for item in lines]
        # TODO: prune deleted staged, renamed old_names
        return tracked

    @cached_property
    def adds_staged(self) -> List[str]:
        if len(self.short_status) == 0:
            return []
        else:
            # TODO this join seems wrong
            return [
                " ".join(fp.split()[1:]) for fp in self.short_status if fp[0] == "A"
            ]

    @cached_property
    def renames(self) -> List[str]:
        if len(self.short_status) == 0:
            return []
        else:
            rename_lines = [l for l in self.short_status if l[0] == "R"]
            if len(rename_lines) > 0:
                rename_files = [f.split(" -> ")[1] for f in rename_lines]
                return rename_files
            else:
                return []

    @cached_property
    def list_all(self) -> List[str]:
        # TODO pseudocode:
        # (tracked - (deletes + oldnames)) + renames + adds
        adds = self.adds_staged
        tracked = self.tracked
        renames = self.renames
        return sorted(adds + tracked + renames)

    @property
    def deleted_staged(self) -> List[str]:
        """Returns all files staged for deletion."""
        return [stat[3:] for stat in self.short_status if stat[0] == "D"]

    @property
    def modified_staged(self) -> List[str]:
        """Returns files with staged modifications."""
        return [stat[3:] for stat in self.short_status if stat[0] == "M"]

    @property
    def modified_unstaged(self) -> List[str]:
        """Returns all files with unstaged modifications or Deletions."""
        return [stat[3:] for stat in self.short_status if stat[1] in ["M", "D"]]

    @cached_property
    def _git_str(self) -> str:
        """String representation of _git_base command."""
        return " ".join(self._git_base).strip()

    @cached_property
    def preview_app(self) -> str:
        """Return: bat > batcat > highlight > cat."""
        if which("bat"):
            return "bat --color=always"
        elif which("batcat"):
            return "batcat --color=always"
        elif which("highlight"):
            return "highlight -O ansi"
        else:
            return "cat"

    @cached_property
    def editor(self) -> str:
        """Preference: $EDITOR > nvim > vim > nano > emacs."""
        if env := getenv("EDITOR", None):
            return env
        else:
            for prog in ["nvim", "vim", "nano", "emacs"]:
                if bin := which(prog):
                    return bin
            else:
                sys_exit("Cannot find a suitable editor, and boy did we look!")


# vim: foldlevel=1 :
