# -*- coding: utf-8 -*-
# Copyright (c) 2022-2024 Salvador E. Tropea
# Copyright (c) 2022-2024 Instituto Nacional de Tecnología Industrial
# License: GPL-3.0
# Project: KiBot (formerly KiPlot)
"""
Dependencies:
  - name: KiCad PCB/SCH Diff
    version: 2.5.1
    role: mandatory
    github: INTI-CMNB/KiDiff
    command: kicad-diff.py
    pypi: kidiff
    downloader: pytool
    id: KiDiff
  - from: Git
    role: Compare with files in the repo
  - from: KiAuto
    role: Compare schematics
    version: 2.2.0
"""
import datetime
import glob
import pwd
import os
from shutil import copy2, rmtree
from subprocess import CalledProcessError
from tempfile import mkdtemp
from .error import KiPlotConfigurationError
from .gs import GS
from .kicad.color_theme import load_color_theme
from .kiplot import load_any_sch, run_command
from .layer import Layer
from .misc import W_NOTHCMP
from .out_any_diff import AnyDiffOptions
from .macros import macros, document, output_class  # noqa: F401
from . import log

logger = log.get_logger()
HASH_LOCAL = '_local_'
UNDEF_COLOR = '#DBDBDB'
LAYER_COLORS_HEAD = """/* ==============================
   Layer colors
** ============================*/
"""


def get_cur_user():
    try:
        name = pwd.getpwuid(os.geteuid())[4]
        return name.split(',')[0]
    except Exception:
        return 'Local user'


class KiRiOptions(AnyDiffOptions):
    def __init__(self):
        with document:
            self.color_theme = '_builtin_classic'
            """ *Selects the color theme. Only applies to KiCad 6.
                To use the KiCad 6 default colors select `_builtin_default`.
                Usually user colors are stored as `user`, but you can give it another name """
            self.background_color = "#FFFFFF"
            """ Color used for the background of the diff canvas """
            self.max_commits = 0
            """ Maximum number of commits to include. Use 0 for all available commits """
            self.revision = 'HEAD'
            """ Starting point for the commits, can be a branch, a hash, etc.
                Note that this can be a revision-range, consult the gitrevisions manual for more information """
            self.keep_generated = False
            """ *Avoid PCB and SCH images regeneration. Useful for incremental usage """
        super().__init__()
        self.add_to_doc("zones", "Be careful with the *keep_generated* option when changing this setting")
        self._kiri_mode = True

    def config(self, parent):
        super().config(parent)
        self.validate_colors(['background_color'])
        if self.max_commits < 0:
            raise KiPlotConfigurationError(f"Wrong number of commits ({self.max_commits}) must be positive")

    def _get_targets(self, out_dir, only_index=False):
        self.init_tools(out_dir)
        hashes, sch_dirty, pcb_dirty, sch_files = self.collect_hashes()
        if len(hashes) + (1 if sch_dirty or pcb_dirty else 0) < 2:
            return []
        if only_index:
            return [os.path.join(self.cache_dir, 'index.html')]
        files = [os.path.join(self.cache_dir, f) for f in ['blank.svg', 'commits', 'index.html', 'kiri-server', 'project']]
        for h in hashes:
            files.append(os.path.join(self.cache_dir, h[0][:7]))
        if sch_dirty or pcb_dirty:
            files.append(os.path.join(self.cache_dir, HASH_LOCAL))
        return files

    def get_targets(self, out_dir):
        return self._get_targets(out_dir)

    def get_navigate_targets(self, out_dir):
        """ Targets for the navigate results, just the index """
        return self._get_targets(out_dir, True)

    def create_layers_incl(self, layers):
        self.incl_file = None
        if isinstance(layers, type):
            self._solved_layers = None
            return False
        self.save_layers_incl(Layer.solve(layers))
        return True

    def do_cache(self, name, tmp_wd, hash):
        name_copy = os.path.join(tmp_wd, name)
        if not os.path.isfile(name_copy):
            self.write_empty_file(name_copy)
        logger.debug('- Using temporal copy: '+name_copy)
        self.add_to_cache(name_copy, hash[:7])
        return name_copy

    def save_pcb_layers(self, hash):
        subdir = os.path.join(hash[:7], '_KIRI_')
        subdir_layers = os.path.join(self.cache_dir, subdir, 'pcb', 'layer-')
        with open(os.path.join(self.cache_dir, subdir, 'pcb_layers'), 'wt') as f:
            if self._solved_layers:
                for la in self._solved_layers:
                    if os.path.isfile(subdir_layers+('%02d' % la.id)+'.svg'):
                        f.write(str(la.id)+'|'+la.layer+'\n')
            else:
                discard = len(subdir_layers)
                for la in sorted(glob.glob(subdir_layers+'??.svg')):
                    id = int(la[discard:discard+2])
                    f.write(str(id)+'|'+GS.board.GetLayerName(id)+'\n')

    def solve_layer_colors(self):
        # Color theme
        self._color_theme = load_color_theme(self.color_theme)
        if self._color_theme is None:
            raise KiPlotConfigurationError("Unable to load `{}` color theme".format(self.color_theme))
        if self._solved_layers is None:
            return
        # Assign a color if none was defined
        layer_id2color = self._color_theme.layer_id2color
        for la in self._solved_layers:
            if la._id in layer_id2color:
                la.color = layer_id2color[la._id]
            else:
                la.color = UNDEF_COLOR

    def save_sch_sheet(self, hash, name_sch):
        # Load the schematic. Really worth?
        sch = load_any_sch(name_sch, GS.sch_basename)
        with open(os.path.join(self.cache_dir, hash[:7], '_KIRI_', 'sch_sheets'), 'wt') as f:
            base_dir = os.path.dirname(name_sch)
            for s in sorted(sch.all_sheets, key=lambda x: x.sheet_path_h):
                fname = s.fname
                no_ext = os.path.splitext(os.path.basename(fname))[0]
                rel_name = os.path.relpath(fname, base_dir)
                if s.sheet_path_h == '/':
                    instance_name = sheet_path = GS.sch_basename
                else:
                    instance_name = os.path.basename(s.sheet_path_h)
                    sheet_path = s.sheet_path_h.replace('/', '-')
                    sheet_path = GS.sch_basename+'-'+sheet_path[1:]
                f.write(f'{no_ext}|{rel_name}||{instance_name}|{sheet_path}\n')

    def save_commits(self, commits):
        with open(os.path.join(self.cache_dir, 'commits'), 'wt') as f:
            for c in commits:
                hash = c[0][:7]
                dt = c[1].split()[0]
                author = c[2]
                desc = c[3]
                sch_changed = c[0] in self.commits_with_changed_sch
                pcb_changed = c[0] in self.commits_with_changed_pcb
                f.write(f'{hash}|{dt}|{author}|{desc}|{sch_changed}|{pcb_changed}\n')

    def save_project_data(self):
        today = datetime.datetime.today().strftime('%Y-%m-%d')
        with open(os.path.join(self.cache_dir, 'project'), 'wt') as f:
            f.write((GS.pro_basename or GS.sch_basename or GS.pcb_basename or 'unknown')+'\n')
            f.write((GS.sch_title or 'No title')+'|'+(GS.sch_rev or '')+'|'+(GS.sch_date or today)+'\n')
            f.write((GS.pcb_title or 'No title')+'|'+(GS.pcb_rev or '')+'|'+(GS.pcb_date or today)+'\n')

    def get_modified_status(self, pcb_file, sch_files):
        res = self.run_git(['log', '--pretty=format:%H', self.revision] + self._max_commits + ['--', pcb_file])
        self.commits_with_changed_pcb = set(res.split())
        res = self.run_git(['log', '--pretty=format:%H', self.revision] + self._max_commits + ['--'] + sch_files)
        self.commits_with_changed_sch = set(res.split())
        if GS.debug_level > 1:
            logger.debug(f'Commits with changes in the PCB: {self.commits_with_changed_pcb}')
            logger.debug(f'Commits with changes in the Schematics: {self.commits_with_changed_sch}')

    def copy_index(self, src_dir, src_index, dst_index):
        with open(src_index, 'rt') as src:
            with open(dst_index, 'wt') as dst:
                for ln in src:
                    ln_stripped = ln.strip()
                    if ln_stripped.startswith('<script src="'):
                        # Embed Java Scripts
                        fn = ln_stripped[13:].split('"')[0]
                        with open(os.path.join(src_dir, fn), 'rt') as f:
                            script = f.read()
                        dst.write('<script>\n')
                        dst.write(script)
                        dst.write('\n</script>\n')
                    elif ln_stripped.startswith('<link rel="stylesheet" href="'):
                        # Embed CSS
                        fn = ln_stripped[29:].split('"')[0]
                        if fn == 'layer_colors.css':
                            # Create the colors
                            script = ''
                            for id, color in self._color_theme.layer_id2color.items():
                                script += f'.layer_color_{id} {{ color: {color[:7]}; }}\n'
                        elif fn == 'kiri.css':
                            # Replace the SVGs using its source
                            script = ''
                            with open(os.path.join(src_dir, fn), 'rt') as f:
                                for lns in f:
                                    if lns.startswith("\t--svg: url('"):
                                        fns = lns[13:].split("'")[0]
                                        with open(os.path.join(src_dir, fns), 'rt') as f:
                                            svg = f.read().strip()
                                        script += "\t--svg: url('data:image/svg+xml;utf8,"+svg+"');\n"
                                    elif lns.startswith('#svg-id { background-color: '):
                                        script += f'#svg-id {{ background-color: {self.background_color}; }}\n'
                                    else:
                                        script += lns
                        else:
                            with open(os.path.join(src_dir, fn), 'rt') as f:
                                script = f.read()
                        dst.write('<style>\n')
                        dst.write(script)
                        dst.write('\n</style>\n')
                    else:
                        dst.write(ln)

    def create_kiri_files(self):
        src_dir = GS.get_resource_path('kiri')
        copy2(os.path.join(src_dir, 'kiri-server'), os.path.join(self.cache_dir, 'kiri-server'))
        web_dir = self.cache_dir
        os.makedirs(web_dir, exist_ok=True)
        copy2(os.path.join(src_dir, 'blank.svg'), os.path.join(web_dir, 'blank.svg'))
        self.copy_index(src_dir, os.path.join(src_dir, 'index.html'), os.path.join(web_dir, 'index.html'))

    def init_tools(self, out_dir):
        self.cache_dir = out_dir
        self.command = self.ensure_tool('KiDiff')
        self.git_command = self.ensure_tool('Git')
        # Only needed for schematic
        self.ensure_tool('KiAuto')

    def collect_hashes(self):
        # Get a list of files for the project
        GS.check_sch()
        sch_files = GS.sch.get_files()
        self.repo_dir = GS.sch_dir
        self.sch_rel_name = self.run_git(['ls-files', '--full-name', GS.sch_file])
        if not self.sch_rel_name:
            raise KiPlotConfigurationError("The schematic must be committed")
        GS.check_pcb()
        self.pcb_rel_name = self.run_git(['ls-files', '--full-name', GS.pcb_file])
        if not self.pcb_rel_name:
            raise KiPlotConfigurationError("The PCB must be committed")
        # Get a list of hashes where we have changes
        self._max_commits = ['-n', str(self.max_commits)] if self.max_commits else []
        cmd = ['log', "--date=format:%Y-%m-%d %H:%M:%S", '--pretty=format:%H | %ad | %an | %s']
        res = self.run_git(cmd + self._max_commits + [self.revision, '--', GS.pcb_file] + sch_files)
        hashes = [r.split(' | ') for r in res.split('\n')]
        sch_dirty = self.git_dirty(GS.sch_file)
        pcb_dirty = self.git_dirty(GS.pcb_file)
        return hashes, sch_dirty, pcb_dirty, sch_files

    def run(self, name):
        self.init_tools(self._parent.output_dir)
        hashes, sch_dirty, pcb_dirty, sch_files = self.collect_hashes()
        # Ensure we have at least 2
        if len(hashes) + (1 if sch_dirty or pcb_dirty else 0) < 2:
            logger.warning(W_NOTHCMP+'Nothing to compare')
            return
        # Get more information about what is changed
        self.get_modified_status(GS.pcb_file, sch_files)
        self.create_layers_incl(self.layers)
        self.solve_layer_colors()
        try:
            git_tmp_wd = None
            try:
                for h in hashes:
                    hash = h[0]
                    dst_dir = os.path.join(self.cache_dir, hash[:7])
                    already_generated = os.path.isdir(dst_dir)
                    if self.keep_generated and already_generated:
                        logger.debug(f'- Images for {hash} already generated')
                        continue
                    if already_generated:
                        rmtree(dst_dir)
                    git_tmp_wd = mkdtemp()
                    logger.debug('Checking out '+hash+' to '+git_tmp_wd)
                    self.run_git(['worktree', 'add', '--detach', '--force', git_tmp_wd, hash])
                    self.run_git(['submodule', 'update', '--init', '--recursive'], cwd=git_tmp_wd)
                    # Generate SVGs for the schematic
                    name_sch = self.do_cache(self.sch_rel_name, git_tmp_wd, hash)
                    # Generate SVGs for the PCB
                    self.do_cache(self.pcb_rel_name, git_tmp_wd, hash)
                    # List of layers
                    self.save_pcb_layers(hash)
                    # Schematic hierarchy
                    self.save_sch_sheet(hash, name_sch)
                    self.remove_git_worktree(git_tmp_wd)
                    git_tmp_wd = None
            finally:
                if git_tmp_wd:
                    self.remove_git_worktree(git_tmp_wd)
            # Do we have modifications?
            if sch_dirty or pcb_dirty:
                # Include the current files
                dst_dir = os.path.join(self.cache_dir, HASH_LOCAL)
                already_generated = os.path.isdir(dst_dir)
                if self.keep_generated and already_generated:
                    logger.debug(f'- Images for {HASH_LOCAL} already generated')
                else:
                    if already_generated:
                        rmtree(dst_dir)
                    name_sch = self.do_cache(GS.sch_file, GS.sch_dir, HASH_LOCAL)
                    self.save_sch_sheet(HASH_LOCAL, name_sch)
                    self.do_cache(GS.pcb_file, GS.pcb_dir, HASH_LOCAL)
                    self.save_pcb_layers(HASH_LOCAL)
                hashes.insert(0, (HASH_LOCAL, datetime.datetime.today().strftime('%Y-%m-%d %H:%M:%S'), get_cur_user(),
                              'Local changes not committed'))
                if pcb_dirty:
                    self.commits_with_changed_pcb.add(HASH_LOCAL)
                if sch_dirty:
                    self.commits_with_changed_sch.add(HASH_LOCAL)
        finally:
            if self.incl_file:
                os.remove(self.incl_file)
        self.create_kiri_files()
        self.save_commits(hashes)
        self.save_project_data()


@output_class
class KiRi(BaseOutput):  # noqa: F821
    """ KiRi
        Generates an interactive web page to browse the schematic and/or PCB differences between git commits.
        Must be applied to a git repository.
        Recursive git submodules aren't supported (submodules inside submodules).
        Note that most browsers won't allow Java Script to read local files,
        needed to load the SCH/PCB. So you must use a web server or enable the
        access to local files. In the case of Google Chrome you can use the
        `--allow-file-access-from-files` command line option.
        This output generates a simple Python web server called `kiri-server` that you can use.
        For more information visit the [KiRi web](https://github.com/leoheck/kiri) """
    def __init__(self):
        super().__init__()
        self._category = ['PCB/docs', 'Schematic/docs']
        self._both_related = True
        with document:
            self.options = KiRiOptions
            """ *[dict] Options for the `diff` output """
            self.layers = Layer
            """ *[list(dict)|list(string)|string] [all,selected,copper,technical,user,inners,outers]
                List of PCB layers to use. When empty all available layers are used.
                Note that if you want to support adding/removing layers you should specify a list here """

    @staticmethod
    def layer2dict(la):
        return {'layer': la.layer, 'suffix': la.suffix, 'description': la.description}

    @staticmethod
    def has_repo(git_command, file):
        try:
            run_command([git_command, 'ls-files', '--error-unmatch', file], change_to=os.path.dirname(file), just_raise=True)
        except CalledProcessError:
            logger.debug("File `{}` not inside a repo".format(file))
            return False
        return True

    @staticmethod
    def get_conf_examples(name, layers):
        outs = []
        git_command = GS.check_tool(name, 'Git')
        if not git_command or not GS.check_tool(name, 'KiDiff'):
            return None
        if (GS.pcb_file and GS.sch_file and KiRi.has_repo(git_command, GS.pcb_file) and
           KiRi.has_repo(git_command, GS.sch_file)):
            ops = KiRiOptions()
            ops.git_command = git_command
            hashes, sch_dirty, pcb_dirty, _ = ops.collect_hashes()
            if sch_dirty or pcb_dirty:
                hashes.append(HASH_LOCAL)
            if len(hashes) < 2:
                return None
            if GS.debug_level > 1:
                logger.debug(f'KiRi get_conf_examples found: {hashes}')
            gb = {}
            gb['name'] = 'basic_{}'.format(name)
            gb['comment'] = 'Interactive diff between commits'
            gb['type'] = name
            gb['dir'] = 'diff'
            gb['layers'] = [KiRi.layer2dict(la) for la in layers]
            # Avoid taking too much time
            gb['options'] = {'max_commits': 4}
            outs.append(gb)
        return outs

    def get_navigate_targets(self, out_dir):
        return (self.options.get_navigate_targets(out_dir),
                [os.path.join(GS.get_resource_path('kiri'), 'images', 'icon.svg')])

    def run(self, name):
        self.options.layers = self.layers
        super().run(name)
