import os
import queue
import signal
import subprocess
import threading
import time
import tkinter as tk
from logging import getLogger
from tkinter import messagebox, ttk
from typing import Optional

from thonny import tktextext
from thonny.languages import tr
from thonny.misc_utils import get_menu_char, running_on_mac_os, running_on_windows
from thonny.ui_utils import (
    CommonDialog,
    create_action_label,
    ems_to_pixels,
    get_style_configuration,
    set_text_if_different,
)

logger = getLogger(__name__)


class WorkDialog(CommonDialog):
    def __init__(self, master, autostart=False):
        super(WorkDialog, self).__init__(master)

        self._autostart = autostart
        self._has_been_started = False
        self._state = "idle"
        self.success = False
        self._work_events_queue = queue.Queue()
        self.init_instructions_frame()
        self.init_main_frame()
        self.init_action_frame()
        self.init_log_frame()
        self.populate_main_frame()
        self.rowconfigure(4, weight=1)  # log frame
        self.columnconfigure(0, weight=1)
        self.title(self.get_title())
        self.stdout = ""
        self.stderr = ""

        self._update_scheduler = None
        self._keep_updating_ui()

        self.bind("<Escape>", self.on_cancel, True)
        self.protocol("WM_DELETE_WINDOW", self.on_cancel)

        if self._autostart:
            self.bind("<Map>", self._start_on_map, True)

    def _start_on_map(self, event) -> None:
        if self._has_been_started:
            return

        self._has_been_started = True
        self.start_work_and_update_ui()

    def populate_main_frame(self):
        pass

    def is_ready_for_work(self):
        return True

    def init_instructions_frame(self):
        instructions = self.get_instructions()
        self.instructions_frame = ttk.Frame(self, style="Tip.TFrame")
        self.instructions_frame.grid(row=0, column=0, sticky="nsew")
        self.instructions_frame.rowconfigure(0, weight=1)
        self.instructions_frame.columnconfigure(0, weight=1)

        pad = self.get_large_padding()
        self.instructions_label = ttk.Label(self, style="Tip.TLabel", text=instructions)
        self.instructions_label.grid(row=0, column=0, sticky="w", padx=pad, pady=pad)

    def get_instructions(self) -> Optional[str]:
        return None

    def init_main_frame(self):
        self.main_frame = ttk.Frame(self)
        self.main_frame.grid(row=1, column=0, sticky="nsew")

    def init_action_frame(self):
        padding = self.get_large_padding()
        intpad = self.get_small_padding()

        self.action_frame = ttk.Frame(self)
        self.action_frame.grid(row=2, column=0, sticky="nsew")

        self._progress_bar = ttk.Progressbar(
            self.action_frame, length=ems_to_pixels(4), mode="indeterminate"
        )

        self._current_action_label = create_action_label(
            self.action_frame,
            text="",
            width=round(self.get_action_text_max_length() * 1.1),
            click_handler=self.toggle_log_frame,
        )
        self._current_action_label.grid(
            row=1, column=2, sticky="we", pady=padding, padx=(0, intpad)
        )

        self._menu_button = ttk.Button(
            self.action_frame,
            text=get_menu_char(),
            command=self.post_action_menu,
            # style="Toolbutton"
            width=3,
        )
        if self.has_action_menu():
            self._menu_button.grid(column=3, row=1, pady=padding, padx=(0, intpad))

        if running_on_mac_os():
            menu_conf = {}
        else:
            menu_conf = get_style_configuration("Menu")
        self._action_menu = tk.Menu(self, tearoff=False, **menu_conf)

        self._ok_button = ttk.Button(
            self.action_frame,
            text=self.get_ok_text(),
            command=self.on_click_ok_button,
            state="disabled",
            default="active",
        )
        if not self._autostart:
            self._ok_button.grid(column=4, row=1, pady=padding, padx=(0, intpad))

        self._cancel_button = ttk.Button(
            self.action_frame,
            text=self.get_cancel_text(),
            command=self.on_cancel,
        )
        self._cancel_button.grid(column=5, row=1, padx=(0, padding), pady=padding)

        self.action_frame.columnconfigure(2, weight=1)

    def on_click_ok_button(self):
        self.start_work_and_update_ui()

    def has_action_menu(self) -> bool:
        return False

    def populate_action_menu(self, action_menu: tk.Menu) -> None:
        pass

    def post_action_menu(self) -> None:
        self._action_menu.delete(0, "end")
        post_x = self._menu_button.winfo_rootx()
        post_y = self._menu_button.winfo_rooty() + self._menu_button.winfo_height()
        self.populate_action_menu(self._action_menu)
        self._action_menu.tk_popup(post_x, post_y)

    def get_action_text_max_length(self):
        return 35

    def get_initial_log_line_count(self):
        return 5

    def init_log_frame(self):
        self.log_frame = ttk.Frame(self)
        self.log_frame.columnconfigure(1, weight=1)
        self.log_frame.rowconfigure(1, weight=1)
        fixed_font = tk.font.nametofont("TkFixedFont")
        font = fixed_font.copy()
        font.configure(size=round(fixed_font.cget("size") * 0.8))
        self.log_text = tktextext.TextFrame(
            self.log_frame,
            horizontal_scrollbar=False,
            wrap="word",
            borderwidth=1,
            height=self.get_initial_log_line_count(),
            width=20,
            font=font,
            read_only=True,
        )

        padding = self.get_large_padding()
        self.log_text.grid(row=1, column=1, sticky="nsew", padx=padding, pady=(0, padding))

    def update_ui(self):
        if self._state == "closed":
            return

        while not self._work_events_queue.empty():
            self.handle_work_event(*self._work_events_queue.get())
            if self._state == "closed":
                return

        if self._state == "idle":
            if self.is_ready_for_work():
                self._ok_button.configure(state="normal")
            else:
                self._ok_button.configure(state="disabled")
        else:
            self._ok_button.configure(state="disabled")

        if self._state == "done":
            set_text_if_different(self._cancel_button, tr("Close"))
        else:
            set_text_if_different(self._cancel_button, tr("Cancel"))

    def start_work(self):
        pass

    def get_title(self):
        return "Work dialog"

    def _keep_updating_ui(self):
        if self._state != "closed":
            if self.winfo_ismapped():
                self.update_ui()
            self._update_scheduler = self.after(200, self._keep_updating_ui)
        else:
            self._update_scheduler = None

    def close(self):
        self._state = "closed"
        if self._update_scheduler is not None:
            try:
                self.after_cancel(self._update_scheduler)
            except tk.TclError:
                pass

        self.destroy()

    def cancel_work(self):
        # worker should periodically check this value
        self._state = "cancelling"
        self.set_action_text(tr("Cancelling"))

    def toggle_log_frame(self, event=None):
        if self.log_frame.winfo_ismapped():
            self.hide_log_frame()
        else:
            self.show_log_frame()

    def show_log_frame(self):
        self.log_frame.grid(row=4, column=0, sticky="nsew")
        self.rowconfigure(2, weight=0)
        self.rowconfigure(4, weight=1)

    def hide_log_frame(self):
        self.log_frame.grid_forget()
        self.rowconfigure(2, weight=1)
        self.rowconfigure(4, weight=0)

    def clear_log(self) -> None:
        self.log_text.text.direct_delete("1.0", "end")

    def get_ok_text(self):
        return tr("OK")

    def get_cancel_text(self):
        return tr("Cancel")

    def start_work_and_update_ui(self, event=None):
        assert self._state == "idle"
        if self.start_work() is not False:
            self._state = "working"
            self.success = False
            self.grid_progress_widgets()
            self._progress_bar["mode"] = "indeterminate"
            self._progress_bar.start()
            if not self._current_action_label["text"]:
                self._current_action_label["text"] = tr("Starting") + "..."

    def grid_progress_widgets(self):
        padding = self.get_large_padding()
        intpad = self.get_small_padding()
        self._progress_bar.grid(row=1, column=1, sticky="w", padx=(padding, intpad), pady=padding)

    def on_cancel(self, event=None):
        if self._state in ("idle", "done"):
            self.close()
        elif self._state == "cancelling" and self.confirm_leaving_while_cancelling():
            self.close()
        elif self.confirm_cancel():
            self.cancel_work()

    def confirm_leaving_while_cancelling(self):
        return messagebox.askyesno(
            "Close dialog?",
            "Cancelling is in progress.\nDo you still want to close the dialog?",
            parent=self,
        )

    def confirm_cancel(self):
        return messagebox.askyesno(
            "Cancel work?",
            "Are you sure you want to cancel?",
            parent=self,
        )

    def append_text(self, text: str, stream_name="stdout") -> None:
        """Appends text to the details box. May be called from another thread."""
        self._work_events_queue.put(("append", (text, stream_name)))
        setattr(self, stream_name, getattr(self, stream_name) + text)

    def replace_last_line(self, text: str, stream_name="stdout") -> None:
        """Replaces last line in the details box. May be called from another thread."""
        self._work_events_queue.put(("replace", (text, stream_name)))
        setattr(self, stream_name, getattr(self, stream_name) + text)

    def report_progress(self, value: Optional[float], maximum: Optional[float]) -> None:
        """Updates progress bar. May be called from another thread."""
        self._work_events_queue.put(("progress", (value, maximum)))

    def set_action_text(self, text: str) -> None:
        """Updates text above the progress bar. May be called from another thread."""
        self._work_events_queue.put(("action", (text,)))

    def set_action_text_smart(self, text: str) -> None:
        """Updates text above the progress bar. May be called from another thread."""
        text = text.strip()
        if not text:
            return
        if len(text) > self.get_action_text_max_length():
            text = text[: self.get_action_text_max_length() - 3] + "..."
        self.set_action_text(text)

    def report_done(self, success):
        """May be called from another thread."""
        self._work_events_queue.put(("done", (success,)))

    def handle_work_event(self, type, args):
        if type in ("append", "replace"):
            text, stream_name = args
            if type == "replace":
                self.log_text.text.direct_delete("end-1c linestart", "end-1c")
            self.log_text.text.direct_insert("end", text, (stream_name,))
            self.log_text.text.see("end")
        elif type == "action":
            set_text_if_different(self._current_action_label, args[0])
        elif type == "progress":
            value, maximum = args
            if value is None or maximum is None:
                if self._progress_bar["mode"] != "indeterminate":
                    self._progress_bar["mode"] = "indeterminate"
                    self._progress_bar.start()
            else:
                if self._progress_bar["mode"] != "determinate":
                    self._progress_bar["mode"] = "determinate"
                    self._progress_bar.stop()
                self._progress_bar.configure(value=value, maximum=maximum)
        elif type == "done":
            self.on_done(args[0])

    def allow_single_success(self) -> bool:
        return True

    def on_done(self, success):
        """NB! Don't call from non-ui thread!"""
        self.success = success
        if self.success and self.allow_single_success() or self._autostart:
            self._state = "done"
            self._cancel_button.focus_set()
            self._cancel_button["default"] = "active"
            self._ok_button["default"] = "normal"
        else:
            # allows trying again when failed or wasn't final action
            self.allow_new_work()

        self._progress_bar.stop()
        # need to put to determinate mode, otherwise it looks half done
        self._progress_bar["mode"] = "determinate"
        if self.success and self._autostart and not self.log_frame.winfo_ismapped():
            self.close()

        if not self.success:
            self.show_log_frame()

    def allow_new_work(self):
        self._state = "idle"
        self._ok_button.focus_set()
        self._ok_button["default"] = "active"
        self._cancel_button["default"] = "normal"


class SubprocessDialog(WorkDialog):
    """Shows incrementally the output of given subprocess.
    Allows cancelling"""

    def __init__(self, master, proc, title, long_description=None, autostart=True):
        self._proc = proc
        self.stdout = ""
        self.stderr = ""
        self._stdout_thread = None
        self._stderr_thread = None
        self._title = title
        self._long_description = long_description
        self.returncode = None

        super().__init__(master, autostart=autostart)

    def is_ready_for_work(self):
        return True

    def get_title(self):
        return self._title

    def get_instructions(self) -> Optional[str]:
        return self._long_description

    def start_work(self):
        if hasattr(self._proc, "cmd"):
            try:
                self.append_text(subprocess.list2cmdline(self._proc.cmd) + "\n")
            except:
                logger.warning("Could not extract cmd (%s)", self._proc.cmd)
        self._start_listening_current_proc()

    def _start_listening_current_proc(self):
        def listen_stream(stream_name):
            stream = getattr(self._proc, stream_name)
            while True:
                data = stream.readline()
                self.append_text(data, stream_name)
                self._check_set_action_text_from_output_line(data)
                setattr(self, stream_name, getattr(self, stream_name) + data)
                if data == "":
                    logger.debug("Finished reading %s", stream_name)
                    break

            if stream_name == "stdout":
                self._finish_process()

            logger.debug("Returning from reading %s", stream_name)

        self._stdout_thread = threading.Thread(target=listen_stream, args=["stdout"], daemon=True)
        self._stdout_thread.start()
        if self._proc.stderr is not None:
            self._stderr_thread = threading.Thread(
                target=listen_stream, args=["stderr"], daemon=True
            )
            self._stderr_thread.start()

    def _finish_process(self):
        self.returncode = self._proc.wait()
        logger.debug("Process ended with returncode %s", self.returncode)
        if self.returncode:
            self.set_action_text("Error")
            self.append_text("Error: process returned with code %s\n" % self.returncode)
        else:
            self.set_action_text("Done!")
            self.append_text("Done!")

        self.report_done(self.returncode == 0)

    def get_action_text_max_length(self):
        return 35

    def _check_set_action_text_from_output_line(self, line):
        if len(line) > self.get_action_text_max_length():
            line = line[: self.get_action_text_max_length() - 3].strip() + "..."
        if line:
            self.set_action_text(line.strip())

    def cancel_work(self):
        super().cancel_work()
        # try gently first
        try:
            try:
                if running_on_windows():
                    os.kill(self._proc.pid, signal.CTRL_BREAK_EVENT)  # pylint: disable=no-member
                else:
                    os.kill(self._proc.pid, signal.SIGINT)

                self._proc.wait(2)
            except subprocess.TimeoutExpired:
                if self._proc.poll() is None:
                    # now let's be more concrete
                    self._proc.kill()
        except OSError as e:
            messagebox.showerror("Error", "Could not kill subprocess: " + str(e), master=self)
            logger.error("Could not kill subprocess", exc_info=e)
