#!/usr/bin/env python3
"""
Reorganize tool data to the layout described in https://github.com/galaxyproject/galaxy/discussions/19013
"""

EPILOG = """
WARNING: This script moves data and updates associated location files. Its authors make no guarantees as to its safety
and correct functionality. Please test without --commit and verify the printed changes are as expected. Do not run this
script as root.

ENSURE YOUR DATA AND LOCATION FILES ARE BACKED UP PRIOR TO USE.
"""
import argparse
import os
import pathlib
import shutil
import subprocess
import sys
import tempfile
from collections import namedtuple
from enum import Enum
from xml.etree import ElementTree


class PathType(Enum):
    # path column is path to a single file
    FILE = 1
    # path column is path to a directory
    DIRECTORY = 2
    # path column is path to a symlink to the seq in a directory
    SEQ_LINK = 3
    # path column is path to a file prefix in a directory
    PREFIX = 4


PATH_TEMPLATES = {
    "all_fasta": "{tool_data_path}/genomes/{dbkey}/seq/{value}.fa",
    # __dbkeys__ and twobit only have value, not dbkey, in this case we hope there are no variants
    "twobit": "{tool_data_path}/genomes/{value}/seq/{value}.2bit",
    "__dbkeys__": "{tool_data_path}/genomes/{value}/len/{value}.len",
    "fasta_indexes": "{tool_data_path}/genomes/{dbkey}/sam_fasta_index/v1/{value}/{value}.fa",
    "bowtie_indexes": "{tool_data_path}/genomes/{dbkey}/bowtie_index/v1/{value}/{value}.fa",
    "bowtie2_indexes": "{tool_data_path}/genomes/{dbkey}/bowtie_index/v2/{value}/{value}",
    "tophat2_indexes": "{tool_data_path}/genomes/{dbkey}/bowtie_index/v2/{value}/{value}",
    "bwa_mem_indexes": "{tool_data_path}/genomes/{dbkey}/bwa_mem_index/v1/{value}/{value}.fa",
    "bwa_mem2_indexes": "{tool_data_path}/genomes/{dbkey}/bwa_mem_index/v2/{value}/{value}.fa",
    "hisat2_indexes": "{tool_data_path}/genomes/{dbkey}/hisat_index/v2/{value}/{value}",
    "rnastar_index2x_versioned": "{tool_data_path}/genomes/{dbkey}/rnastar_index/v{version}/{value}",
}

PATH_TYPES = {
    "all_fasta": PathType.FILE,
    "twobit": PathType.FILE,
    "__dbkeys__": PathType.FILE,
    "fasta_indexes": PathType.SEQ_LINK,
    "bowtie_indexes": PathType.SEQ_LINK,
    "bowtie2_indexes": PathType.SEQ_LINK,
    "tophat2_indexes": PathType.SEQ_LINK,
    "bwa_mem_indexes": PathType.SEQ_LINK,
    "bwa_mem2_indexes": PathType.SEQ_LINK,
    "hisat2_indexes": PathType.SEQ_LINK,
    "rnastar_index2x_versioned": PathType.DIRECTORY,
}

LOC_ONLY = {
    "tophat2_indexes",
}

PATH_COLUMNS = {
    "__dbkeys__": "len_path",
}

SEQ_APPEND_EXTENSION = {
    "bowtie2_indexes": ".fa",
    "hisat2_indexes": ".fa",
}


class __dbkeys__(namedtuple("__dbkeys__", ["value", "name", "len_path"])):
    @property
    def path(self):
        return self.len_path


class Color:
    RESET = "\033[0m"
    BOLD = "\033[1m"
    DIM = "\033[2m"
    ITALIC = "\033[3m"
    UNDERLINE = "\033[4m"
    BLINK = "\033[5m"
    REVERSE = "\033[7m"
    HIDDEN = "\033[8m"

    BLACK = "\033[30m"
    RED = "\033[31m"
    GREEN = "\033[32m"
    YELLOW = "\033[33m"
    BLUE = "\033[34m"
    MAGENTA = "\033[35m"
    CYAN = "\033[36m"
    WHITE = "\033[37m"

    @staticmethod
    def sprint(text, color, effect=None):
        effect = effect or ""
        return f"{color}{effect}{text}{Color.RESET}"

    @staticmethod
    def print(text, color, effect=None, **kwargs):
        print(Color.sprint(text, color, effect=effect), **kwargs)


class LocFile:
    def __init__(self, path, table, comment_char):
        self.file = open(path)
        self.table = table
        self.comment_char = comment_char

    def __iter__(self):
        for line in self.file:
            line = line.strip()
            if not line.startswith(self.comment_char):
                yield self.table(*line.split("\t"))


class DataTable:
    @classmethod
    def from_elem(cls, elem, tool_data_path=None):
        name = elem.attrib["name"]
        comment_char = elem.attrib.get("comment_char", "#")
        columns = [c.strip() for c in elem.find("columns").text.split(",")]
        loc_file_path = elem.find("file").attrib["path"].strip()
        if name not in PATH_TEMPLATES:
            Color.print(f"WARNING: no path template for table: {name}", Color.RED)
            return None
        if not os.path.exists(loc_file_path):
            Color.print(f"WARNING: loc file for table {name} does not exist: {loc_file_path}", Color.RED)
            return None
        return cls(name, columns, loc_file_path, comment_char, tool_data_path=tool_data_path)

    def __init__(self, name, columns, loc_file_path, comment_char, tool_data_path=None):
        self.name = name
        self.columns = columns
        self.loc_file_path = loc_file_path
        self.comment_char = comment_char
        self.moved = set()
        if name == "__dbkeys__":
            self.table = __dbkeys__
        else:
            self.table = namedtuple(name, columns)
        self._tool_data_path = tool_data_path
        self.loc_file = LocFile(loc_file_path, self.table, comment_char)
        self._initialize_new_loc_file()

    def _initialize_new_loc_file(self):
        fd, self.new_loc_file_path = tempfile.mkstemp(
            suffix=".reorganize_tool_data.loc", prefix=f"{self.name}.", text=True
        )
        self.new_loc_file = os.fdopen(fd, mode="wt")

    @property
    def path_type(self):
        return PATH_TYPES[self.name]

    @property
    def tool_data_path(self):
        assert self._tool_data_path is not None
        return self._tool_data_path

    @property
    def symlink_seq(self):
        return self.path_type == PathType.SEQ_LINK

    @property
    def move_data(self):
        return self.name not in LOC_ONLY

    @property
    def path_column(self):
        return PATH_COLUMNS.get(self.name, "path")

    def _table_path(self, entry, table=None):
        table = table or self.name
        template_dict = {"tool_data_path": self.tool_data_path}
        template_dict.update(entry._asdict())
        return PATH_TEMPLATES[table].format(**template_dict)

    def _source_path(self, entry):
        if self.path_type in (PathType.FILE, PathType.DIRECTORY):
            return entry.path
        elif self.path_type in (PathType.SEQ_LINK, PathType.PREFIX):
            return os.path.dirname(entry.path)
        else:
            raise NotImplementedError(self.path_type)

    def _dest_path(self, entry):
        if self.path_type in (PathType.FILE, PathType.DIRECTORY):
            return self._table_path(entry)
        elif self.path_type in (PathType.SEQ_LINK, PathType.PREFIX):
            return os.path.dirname(self._table_path(entry))
        else:
            raise NotImplementedError(self.path_type)

    def _symlink_seq(self, entry, commit=False):
        if self.symlink_seq:
            append = SEQ_APPEND_EXTENSION.get(self.name, "")
            table_path = self._table_path(entry) + append
            if commit:
                path = table_path
            else:
                path = entry.path + append
            if os.path.exists(path) and not os.path.islink(path):
                Color.print(f"WARNING: Expected sequence link exists but is not a link: {path}", Color.RED)
                return
            assert os.path.islink(path), Color.sprint(
                f"Expected sequence link is not a link (or does not exist): {path}", Color.RED, effect=Color.BOLD
            )
            seq_path = self._table_path(entry, table="all_fasta")
            link_target = os.path.relpath(seq_path, start=os.path.dirname(table_path))
            if commit:
                os.unlink(table_path)
                os.symlink(table_path, link_target)
            Color.print(f"Remapped seq symlink: {table_path} -> {link_target}", Color.YELLOW)

    def _write_entry_to_loc(self, entry, path=None):
        if path:
            new_entry_dict = entry._asdict()
            new_entry_dict.update({self.path_column: path})
            new_entry = self.table(**new_entry_dict)
        else:
            new_entry = entry
        new_entry_str = "\t".join(new_entry)
        self.new_loc_file.write(f"{new_entry_str}\n")

    def _move_entry(self, entry, dest_path, commit=False):
        source = self._source_path(entry)
        dest = self._dest_path(entry)
        assert os.path.lexists(source) and os.path.exists(source), Color.sprint(
            f"ERROR: source path does not exist: {source}", Color.RED, effect=Color.BOLD
        )
        assert not os.path.exists(dest), Color.sprint(f"ERROR: dest path exists: {dest}", Color.RED, effect=Color.BOLD)
        if self.move_data:
            dest_parent = os.path.dirname(dest)
            if not os.path.exists(dest_parent):
                print(f"Creating destination parent directory: {dest_parent}")
                if commit:
                    os.makedirs(dest_parent)
            Color.print("Moving data:", Color.YELLOW)
            Color.print(f"  {source} ->", Color.YELLOW)
            Color.print(f"  {dest}", Color.YELLOW)
            if commit:
                shutil.move(source, dest)
            self._symlink_seq(entry, commit=commit)
        self.moved.add(source)
        self._write_entry_to_loc(entry, path=dest_path)
        Color.print("Changed table entry:", Color.YELLOW)
        Color.print(f"  {entry.path} ->", Color.YELLOW)
        Color.print(f"  {dest_path}", Color.YELLOW)

    def reorganize(self, commit=False):
        changed = False
        try:
            for entry in self.loc_file:
                dest_path = self._table_path(entry)
                if dest_path != entry.path:
                    self._move_entry(entry, dest_path, commit=commit)
                    changed = True
                else:
                    self._write_entry_to_loc(entry)
                    Color.print(f"Ok: {entry.path}", Color.GREEN)
        except Exception:
            Color.print(
                f"ERROR: Encountered an exception while reorganizing data in {self.name} table: {self.loc_file_path}",
                Color.RED,
                effect=Color.BOLD,
            )
            Color.print(
                f"ERROR: Partial loc file rewrite can be found at: {self.new_loc_file_path}",
                Color.RED,
                effect=Color.BOLD,
            )
            self.new_loc_file.close()
            raise
        self.new_loc_file.close()
        if changed:
            Color.print("Changes to .loc file:", Color.CYAN, flush=True)
            subprocess.call(["diff", "-u", self.loc_file_path, self.new_loc_file_path])
            if commit:
                dest_stat = os.stat(self.loc_file_path)
                shutil.move(self.new_loc_file_path, self.loc_file_path)
                os.chmod(self.loc_file_path, dest_stat.st_mode)
            else:
                os.unlink(self.new_loc_file_path)
        else:
            Color.print("No changes", Color.CYAN)
            os.unlink(self.new_loc_file_path)

    def prune_dirs(self, commit=False):
        # because this uses os.rmdir() there is no danger of removing data here even if it were to operate outside the
        # old tool-data tree root (which could happen if loc files contain entries in completely disparate trees), since
        # only empty directories would be removed
        Color.print("Pruning empty directories", Color.MAGENTA)
        # this should either end with / or if it doesn't, then anything after the last / is a partial subdir name
        old_root = os.path.dirname(os.path.commonprefix(list(self.moved)))
        print(f"Prune root: {old_root}")
        for path in self.moved:
            d = os.path.dirname(path)
            if not commit:
                Color.print(f"Would prune directory (and empty parents): {d}", Color.YELLOW)
                continue
            while d not in (old_root, "/"):
                try:
                    os.rmdir(d)
                    Color.print(f"Pruned empty directory: {d}", Color.YELLOW)
                    d = os.path.dirname(d)
                except FileNotFoundError:
                    continue
                except OSError:
                    break


def parse_tdtc(tdtc, tool_data_path):
    tree = ElementTree.parse(tdtc)
    root = tree.getroot()
    assert root.tag == "tables", Color.sprint(
        f"ERROR: Root element should be <tables> (was: <{root.tag}>), is this a tool_data_table_conf.xml?: {tdtc}",
        Color.RED,
        effect=Color.BOLD,
    )
    for table in root.findall("table"):
        dt = DataTable.from_elem(table, tool_data_path=tool_data_path)
        if dt:
            yield dt


def parse_arguments(argv):
    parser = argparse.ArgumentParser(description=__doc__, epilog=EPILOG)
    parser.add_argument(
        "--tool-data-path",
        required=True,
        type=pathlib.Path,
        help="root path of target tool data dir (can be the same as your existing tool data dir)",
    )
    parser.add_argument(
        "--commit",
        default=False,
        action="store_true",
        help="commit changes (otherwise, only print what would be done without making changes)",
    )
    parser.add_argument(
        "--prune-dirs",
        default=False,
        action="store_true",
        help="prune empty parent directories left behind after reorganization",
    )
    parser.add_argument("tool_data_table_conf", nargs="+", type=pathlib.Path, help="path to a tool_data_table_conf.xml")
    return parser.parse_args(argv)


def main(argv=None):
    if not argv:
        argv = sys.argv[1:]
    args = parse_arguments(argv)
    for tdtc in args.tool_data_table_conf:
        Color.print(f"Processing: {tdtc}", Color.MAGENTA, effect=Color.UNDERLINE)
        for dt in parse_tdtc(tdtc, args.tool_data_path):
            Color.print(f"Data Table: {dt.name}", Color.MAGENTA)
            dt.reorganize(commit=args.commit)
            if args.prune_dirs:
                dt.prune_dirs(commit=args.commit)


if __name__ == "__main__":
    main()
