# This file is a part of ninfs.
#
# Copyright (c) 2017-2021 Ian Burgwin
# This file is licensed under The MIT License (MIT).
# You can find the full license text in LICENSE.md in the root of this project.
import os
import sys
import webbrowser
from inspect import cleandoc
from os.path import dirname, join
from pprint import pformat
from subprocess import Popen, PIPE, STDOUT, TimeoutExpired, check_call
from threading import Thread
from traceback import format_exc
from typing import TYPE_CHECKING
from uuid import uuid4

is_windows = sys.platform == 'win32'
is_mac = sys.platform == 'darwin'

try:
    import tkinter as tk
    import tkinter.ttk as ttk
    import tkinter.messagebox as mb
except ModuleNotFoundError:
    print('Could not find tkinter.', file=sys.stderr)
    if is_mac:
        print('If using a package manager like Homebrew or MacPorts, make sure to install Tkinter with it.',
              file=sys.stderr)
    elif not is_mac or is_windows:
        print('Make sure to install Tkinter with your package manager.', file=sys.stderr)
    sys.exit(1)

from .about import NinfsAbout
from .confighandler import get_bool, set_bool
from .settings import NinfsSettings
from .updatecheck import thread_update_check
from .wizardcontainer import WizardContainer, WizardTypeSelector, WizardFailedMount

if TYPE_CHECKING:
    from typing import Callable, Dict, List, Tuple

tutorial_url = 'https://gbatemp.net/threads/499994/'

# cx_Freeze, PyInstaller, etc.
frozen = getattr(sys, 'frozen', None)
# PyInstaller sets this
meipass = getattr(sys, '_MEIPASS', None)

executable = sys.executable
if is_windows:
    from os.path import isdir as check_mountpoint
    from signal import CTRL_BREAK_EVENT
    from subprocess import CREATE_NEW_PROCESS_GROUP

    if frozen:
        print('Using console exe')
        executable = join(dirname(sys.executable), 'ninfs.exe')
        print(executable)
else:
    from os.path import ismount as check_mountpoint


def thread_output_reader(gui: 'NinfsGUI', proc: 'Popen', uuid: 'str', output_list: 'List[str]'):
    while proc.poll() is None:
        for line in proc.stdout:
            if line != '':
                line = line.rstrip('\r\n')
                output_list.append(line)

    # if the uuid is not in the mounts dict, then it was killed by this script
    if proc.returncode and uuid in gui.mounts:
        gui.remove_mount_info(uuid)
        wizard_window = WizardContainer(gui)
        wizard_window.change_frame(WizardFailedMount, returncode=proc.returncode, output=output_list, kind='crash')
        wizard_window.focus()


class NinfsGUI(tk.Tk):
    def __init__(self):
        super().__init__()

        self.mounts: Dict[str, Tuple[Popen, Thread, List[str], str]] = {}

        container = ttk.Frame(self)
        container.pack(fill=tk.BOTH, expand=True)

        self.wm_withdraw()

        self.wm_title('ninfs')

        self.ico_path = self.get_data_file('windows.ico')

        self.wm_minsize(500, 300)
        self.create_menu_bar()

        style = ttk.Style(container)
        style.configure('TMenubutton', background='gainsboro')

        container.rowconfigure(0, weight=0)
        container.rowconfigure(1, weight=1)
        container.rowconfigure(2, weight=0)
        container.columnconfigure(0, weight=1)

        header = ttk.Label(container, text='Mounted contents', font=(None, 15, 'bold'), justify=tk.LEFT)
        header.grid(row=0, column=0, padx=10, pady=8, sticky=tk.W)

        mount_treeview_frame = ttk.Frame(container)
        mount_treeview_frame.grid(row=1, column=0, sticky=tk.NSEW, padx=10)
        mount_treeview_frame.rowconfigure(0, weight=1)
        mount_treeview_frame.columnconfigure(0, weight=1)
        mount_treeview_frame.columnconfigure(1, weight=0)

        self.mount_treeview = ttk.Treeview(mount_treeview_frame)
        self.mount_treeview.grid(row=0, column=0, sticky=tk.NSEW)
        self.mount_treeview.configure(columns=('mount_path', 'mount_type', 'mounted_item'), show='headings')

        self.mount_treeview.column('mount_path', width=100, anchor=tk.W)
        self.mount_treeview.heading('mount_path', text='Mount Path')
        self.mount_treeview.column('mount_type', width=50, anchor=tk.W)
        self.mount_treeview.heading('mount_type', text='Type')
        self.mount_treeview.column('mounted_item', width=200, anchor=tk.W)
        self.mount_treeview.heading('mounted_item', text='Mounted Content')

        mount_treeview_scrollbar = ttk.Scrollbar(mount_treeview_frame, orient=tk.VERTICAL,
                                                 command=self.mount_treeview.yview)
        self.mount_treeview.configure(yscrollcommand=mount_treeview_scrollbar.set)
        mount_treeview_scrollbar.grid(row=0, column=1, sticky=tk.NS)

        actions_frame = ttk.Frame(container)
        actions_frame.grid(row=2, column=0, padx=10, pady=10, sticky=tk.W)

        new_mount_button = ttk.Button(actions_frame, text='New mount', command=self.show_wizard)
        new_mount_button.pack(side=tk.LEFT)

        unmount = ttk.Button(actions_frame, text='Unmount selected', command=self.unmount_selected)
        unmount.pack(side=tk.LEFT)

        self.wm_protocol('WM_DELETE_WINDOW', self.on_close)

    def get_data_file(self, path):
        if frozen and meipass:
            return join(meipass, 'guidata', path)
        else:
            # this kinda sucks
            return join(dirname(__file__), 'data', path)

    def set_icon(self, wm: 'tk.Wm'):
        # just in case this also works on other platforms, but this assumption is likely wrong lol
        # TODO: fix this for Linux
        if is_windows:
            wm.wm_iconbitmap(self.ico_path)

    def check_fuse(self):
        try:
            import fuse
        except EnvironmentError as e:
            if e.args[0] == 'Unable to find libfuse':
                if is_windows:
                    res = mb.askyesno('Failed to load libfuse',
                                      'Failed to load libfuse. WinFsp needs to be installed.\n\n'
                                      'Would you like to open the WinFsp download page?\n'
                                      'https://winfsp.dev/rel/')
                    if res:
                        webbrowser.open('https://winfsp.dev/rel/')
                elif is_mac:
                    res = mb.askyesno('Failed to load libfuse',
                                      'Failed to load libfuse. macFUSE needs to be installed.\n\n'
                                      'Would you like to open the macFUSE download page?\n'
                                      'https://osxfuse.github.io')
                    if res:
                        webbrowser.open('https://osxfuse.github.io')
                else:
                    mb.showerror('Failed to load libfuse.')
                return False
        except Exception:
            mb.showerror('Failed to import fusepy', 'Failed to import fusepy.\n\n' + format_exc())
            return False
        return True

    def mainloop(self, n=0):
        self.wm_deiconify()

        if not get_bool('internal', 'askedonlinecheck'):
            message = '''
            Check for updates online?
            This will make a request to GitHub every time the ninfs gui is opened.
            
            This can be changed any time in Settings.
            '''
            if mb.askyesno('Check for updates', cleandoc(message)):
                set_bool('update', 'onlinecheck', True)
            set_bool('internal', 'askedonlinecheck', True)

        if get_bool('update', 'onlinecheck'):
            update_thread = Thread(target=thread_update_check, args=(self,))
            update_thread.start()
        super().mainloop(n)

    def on_close(self):
        if self.mounts:
            if mb.askokcancel('Exiting', 'Closing ninfs will unmount all contents.'):
                self.unmount_all(force=True)
                self.destroy()
        else:
            self.destroy()

    def show_settings(self):
        settings_window = NinfsSettings(self)
        settings_window.focus()

    def show_wizard(self):
        wizard_window = WizardContainer(self)
        wizard_window.change_frame(WizardTypeSelector)
        wizard_window.focus()

    def mount(self, mounttype: str, cmdargs: 'List[str]', mountpoint: str, callback_success: 'Callable',
              callback_failed: 'Callable', is_drive_letter: bool):
        if is_windows:
            if not is_drive_letter:
                # the directory must first be deleted before winfsp can mount to it
                try:
                    os.rmdir(mountpoint)
                except FileNotFoundError:
                    pass
                except Exception:
                    exc_list = ['Failed to delete directory:', mountpoint, '']
                    exc_list.extend(format_exc().splitlines())
                    callback_failed(None, exc_list)
                    return

        args = [executable]
        if not frozen:
            args.append(dirname(dirname(__file__)))
        args.extend(cmdargs)
        args.append('-f')
        args.append(mountpoint)

        popen_opts = {}
        if is_windows:
            popen_opts['creationflags'] = CREATE_NEW_PROCESS_GROUP

        uuid = str(uuid4())

        output_list = ['Command: ' + pformat(args), '']
        proc = Popen(args, stdin=PIPE, stdout=PIPE, stderr=STDOUT, encoding='utf-8', **popen_opts)
        thread = Thread(target=thread_output_reader, args=(self, proc, uuid, output_list))

        mount_info = (proc, thread, output_list, mountpoint)

        def check_loop():
            if check_mountpoint(mountpoint):
                self.mount_treeview.insert('', tk.END, text=uuid, iid=uuid, values=(mountpoint, mounttype, cmdargs[1]))
                self.mounts[uuid] = mount_info
                callback_success()
                return
            if proc.poll() is not None:
                thread.join()
                callback_failed(proc.returncode, output_list)
                return

            self.after(500, check_loop)

        thread.start()

        self.after(500, check_loop)

    def unmount_selected(self):
        # the mounts dict gets modified during iteration, so the list of keys is cloned to prevent issues
        # it's also done in reverse, since later mounts might be based on earlier ones
        selection = self.mount_treeview.selection()
        for s in reversed(selection):
            self.unmount(s)

    def unmount_all(self, *, force: bool = False):
        # the mounts dict gets modified during iteration, so the list of keys is cloned to prevent issues
        # it's also done in reverse, since later mounts might be based on earlier ones
        for uuid in reversed(self.mount_treeview.get_children()):
            self.unmount(uuid, force=force)

    def remove_mount_info(self, uuid: str):
        self.mount_treeview.delete(uuid)
        del self.mounts[uuid]

    def unmount(self, uuid: 'str', *, force: bool = False):
        mount_info = self.mounts[uuid]
        if is_windows:
            mount_info[0].send_signal(CTRL_BREAK_EVENT)
            try:
                mount_info[0].wait(3)
            except TimeoutExpired:
                if force:
                    self.remove_mount_info(uuid)
                    mount_info[0].kill()
                else:
                    res = mb.askyesno('Mount not responding', 'The mount subprocess is not responding.\nTerminate it?')
                    if res:
                        self.remove_mount_info(uuid)
                        mount_info[0].kill()
            else:
                self.remove_mount_info(uuid)
        else:
            # I think this is cheating
            if is_mac:
                check_call(['diskutil', 'unmount', mount_info[3]])
                self.remove_mount_info(uuid)
            else:
                # assuming linux or bsd, which have fusermount
                check_call(['fusermount', '-u', mount_info[3]])
                self.remove_mount_info(uuid)

    @staticmethod
    def show_tutorial():
        webbrowser.open(tutorial_url)

    def show_about(self):
        about_window = NinfsAbout(self)
        about_window.focus()

    def create_menu_bar(self):
        self.option_add('*tearOff', tk.FALSE)
        menubar = tk.Menu(self)

        if is_mac:
            apple_menu = tk.Menu(menubar, name='apple')
            apple_menu.add_command(label='About ninfs', command=self.show_about)
            apple_menu.add_separator()
            menubar.add_cascade(menu=apple_menu)

            self.createcommand('tk::mac::ShowPreferences', self.show_settings)
            self.createcommand('tk::mac::Quit', self.on_close)

        file_menu = tk.Menu(menubar)
        if not is_mac:
            file_menu.add_command(label='Settings', command=self.show_settings)

        help_menu = tk.Menu(menubar)
        help_menu.add_command(label='Open tutorial on GBAtemp', command=self.show_tutorial)
        if not is_mac:
            help_menu.add_command(label='About ninfs', command=self.show_about)

        menubar.add_cascade(label='File', menu=file_menu)
        menubar.add_cascade(label='Help', menu=help_menu)

        self.configure(menu=menubar)


def start_gui():
    window = NinfsGUI()

    if is_windows:
        from ctypes import windll, get_last_error
        from os import environ, getpid

        environ['NINFS_GUI_PARENT_PID'] = str(getpid())

        if not windll.kernel32.GetConsoleWindow():
            # if there is no console, make one and hide it
            # this is not an elegant solution but it lets us use send_signal on subprocesses
            if not windll.kernel32.AllocConsole():
                # AllocConsole fails when I'm testing in PyCharm but get_last_error returns 0, meaning it succeeded.
                # I don't know why this happens.
                err = get_last_error()
                if err:
                    print('Failed to use AllocConsole:', err)
            else:
                windll.user32.ShowWindow(windll.kernel32.GetConsoleWindow(), 0)  # SW_HIDE

    if window.check_fuse():
        window.mainloop()
        return 0
    else:
        return 70
