import datetime
import os.path
import shutil
import stat
import subprocess
import time
import tkinter as tk
from logging import getLogger
from tkinter import messagebox, simpledialog, ttk
from typing import Optional

from thonny import get_runner, get_workbench, misc_utils, tktextext
from thonny.common import InlineCommand, UserError, get_dirs_children_info
from thonny.languages import tr
from thonny.misc_utils import get_menu_char, running_on_mac_os, running_on_windows, sizeof_fmt
from thonny.ui_utils import (
    CommonDialog,
    ask_one_from_choices,
    ask_string,
    create_string_var,
    ems_to_pixels,
    get_hyperlink_cursor,
    lookup_style_option,
    open_with_default_app,
    scrollbar_style,
    show_dialog,
)

_dummy_node_text = "..."

_LOCAL_FILES_ROOT_TEXT = ""  # needs to be initialized later
ROOT_NODE_ID = ""

HIDDEN_FILES_OPTION = "file.show_hidden_files"

logger = getLogger(__name__)


class BaseFileBrowser(ttk.Frame):
    def __init__(self, master, show_expand_buttons=True):
        self.show_expand_buttons = show_expand_buttons
        self._cached_child_data = {}
        self.path_to_highlight = None

        ttk.Frame.__init__(self, master, borderwidth=0, relief="flat")
        self.vert_scrollbar = ttk.Scrollbar(
            self, orient=tk.VERTICAL, style=scrollbar_style("Vertical")
        )
        self.vert_scrollbar.grid(row=0, column=1, sticky=tk.NSEW, rowspan=3)

        tktextext.fixwordbreaks(tk._default_root)
        self.building_breadcrumbs = False
        self.init_header(row=0, column=0)

        spacer = ttk.Frame(self, height=1)
        spacer.grid(row=1, sticky="nsew")

        self.tree = ttk.Treeview(
            self,
            columns=["#0", "kind", "path", "name", "modified", "size"],
            displaycolumns=(
                # 4,
                # 5
            ),
            yscrollcommand=self.vert_scrollbar.set,
            selectmode="extended",
        )
        self.tree.grid(row=2, column=0, sticky=tk.NSEW)
        self.vert_scrollbar["command"] = self.tree.yview
        self.columnconfigure(0, weight=1)
        self.rowconfigure(2, weight=1)

        self.tree["show"] = "tree"

        self.tree.bind("<3>", self.on_secondary_click, True)
        if misc_utils.running_on_mac_os():
            self.tree.bind("<2>", self.on_secondary_click, True)
            self.tree.bind("<Control-1>", self.on_secondary_click, True)
        self.tree.bind("<Double-Button-1>", self.on_double_click, True)
        self.tree.bind("<<TreeviewOpen>>", self.on_open_node)

        self.copypaste = None

        wb = get_workbench()
        self.folder_icon = wb.get_image("folder")
        self.python_file_icon = wb.get_image("python-icon")
        self.text_file_icon = wb.get_image("text-file")
        self.generic_file_icon = wb.get_image("generic-file")
        self.hard_drive_icon = wb.get_image("hard-drive")

        self.tree.column("#0", width=200, anchor=tk.W)
        self.tree.heading("#0", text=tr("Name"), anchor=tk.W)
        self.tree.column("modified", width=60, anchor=tk.W)
        self.tree.heading("modified", text=tr("Modified"), anchor=tk.W)
        self.tree.column("size", width=40, anchor=tk.E)
        self.tree.heading("size", text=tr("Size (bytes)"), anchor=tk.E)
        self.tree.column("kind", width=30, anchor=tk.W)
        #         self.tree.heading("kind", text="Kind")
        #         self.tree.column("path", width=300, anchor=tk.W)
        #         self.tree.heading("path", text="path")
        #         self.tree.column("name", width=60, anchor=tk.W)
        #         self.tree.heading("name", text="name")

        # set-up root node
        self.tree.set(ROOT_NODE_ID, "kind", "root")
        self.menu = tk.Menu(self.tree, tearoff=False)
        self.current_focus = None

    def init_header(self, row, column):
        header_frame = ttk.Frame(self, style="ViewToolbar.TFrame")
        header_frame.grid(row=row, column=column, sticky="nsew")
        header_frame.columnconfigure(0, weight=1)

        self.path_bar = tktextext.TweakableText(
            header_frame,
            borderwidth=0,
            relief="flat",
            height=1,
            font="TkDefaultFont",
            wrap="word",
            padx=ems_to_pixels(0.6),
            pady=ems_to_pixels(0.5),
            insertwidth=0,
            highlightthickness=0,
            background=lookup_style_option("ViewToolbar.TFrame", "background"),
        )

        self.path_bar.grid(row=0, column=0, sticky="nsew")
        self.path_bar.set_read_only(True)
        self.path_bar.bind("<Configure>", self.resize_path_bar, True)
        self.path_bar.tag_configure(
            "dir", foreground=lookup_style_option("Url.TLabel", "foreground")
        )
        self.path_bar.tag_configure("underline", underline=True)

        def get_dir_range(event):
            mouse_index = self.path_bar.index("@%d,%d" % (event.x, event.y))
            return self.path_bar.tag_prevrange("dir", mouse_index + "+1c")

        def dir_tag_motion(event):
            self.path_bar.tag_remove("underline", "1.0", "end")
            dir_range = get_dir_range(event)
            if dir_range:
                range_start, range_end = dir_range
                self.path_bar.tag_add("underline", range_start, range_end)

        def dir_tag_enter(event):
            self.path_bar.config(cursor=get_hyperlink_cursor())

        def dir_tag_leave(event):
            self.path_bar.config(cursor="")
            self.path_bar.tag_remove("underline", "1.0", "end")

        def dir_tag_click(event):
            mouse_index = self.path_bar.index("@%d,%d" % (event.x, event.y))
            lineno = int(float(mouse_index))
            if lineno == 1:
                self.request_focus_into("")
            else:
                assert lineno == 2
                dir_range = get_dir_range(event)
                if dir_range:
                    _, end_index = dir_range
                    path = self.path_bar.get("2.0", end_index)
                    if path.endswith(":"):
                        path += "\\"
                    self.request_focus_into(path)

        self.path_bar.tag_bind("dir", "<1>", dir_tag_click)
        self.path_bar.tag_bind("dir", "<Enter>", dir_tag_enter)
        self.path_bar.tag_bind("dir", "<Leave>", dir_tag_leave)
        self.path_bar.tag_bind("dir", "<Motion>", dir_tag_motion)

        # self.menu_button = ttk.Button(header_frame, text="≡ ", style="ViewToolbar.Toolbutton")
        self.menu_button = ttk.Button(
            header_frame,
            text=f" {get_menu_char()} ",
            style="ViewToolbar.Toolbutton",
            command=self.post_button_menu,
        )
        # self.menu_button.grid(row=0, column=1, sticky="ne")
        self.menu_button.place(anchor="ne", rely=0, relx=1)

    def clear(self):
        self.clear_error()
        self.invalidate_cache()
        self.path_bar.direct_delete("1.0", "end")
        self.tree.set_children("")
        self.current_focus = None

    def path_exists(self, path: str) -> Optional[bool]:
        return None

    def request_focus_into(self, path):
        return self.focus_into(path)

    def focus_into(self, path):
        self.clear_error()
        self.invalidate_cache()

        # clear
        self.tree.set_children(ROOT_NODE_ID)

        self.tree.set(ROOT_NODE_ID, "path", path)

        self.building_breadcrumbs = True
        self.path_bar.direct_delete("1.0", "end")

        self.path_bar.direct_insert("1.0", self.get_root_text(), ("dir",))

        if path and path != "/":
            self.path_bar.direct_insert("end", "\n")

            def create_spacer():
                return ttk.Frame(self.path_bar, height=1, width=4, style="ViewToolbar.TFrame")

            parts = self.split_path(path)
            for i, part in enumerate(parts):
                if i > 0:
                    if parts[i - 1] != "":
                        self.path_bar.window_create("end", window=create_spacer())
                    self.path_bar.direct_insert("end", self.get_dir_separator())
                    self.path_bar.window_create("end", window=create_spacer())

                self.path_bar.direct_insert("end", part, tags=("dir",))

        self.building_breadcrumbs = False
        self.resize_path_bar()
        self.render_children_from_cache()
        self.scroll_to_top()
        self.current_focus = path

    def scroll_to_top(self):
        children = self.tree.get_children()
        if children:
            self.tree.see(children[0])

    def split_path(self, path):
        return path.split(self.get_dir_separator())

    def get_root_text(self):
        return get_local_files_root_text()

    def on_open_node(self, event):
        node_id = self.get_selected_node()
        if self.get_selected_kind() == "file":
            # can happen in Windows when pressing ENTER on file
            return "break"

        path = self.tree.set(node_id, "path")
        if path:  # and path not in self._cached_child_data:
            self.render_children_from_cache(node_id)
            # self.request_dirs_child_data(node_id, [path])
        # else:

    def resize_path_bar(self, event=None):
        if self.building_breadcrumbs:
            return
        height = self.tk.call((self.path_bar, "count", "-update", "-displaylines", "1.0", "end"))
        self.path_bar.configure(height=height)

    def _cleaned_selection(self):
        # In some cases (eg. Python 3.6.9 and Tk 8.6.8 in Ubuntu when selecting a range with shift),
        # nodes may contain collapsed children.
        # In most cases this does no harm, because the command would apply to children as well,
        # but dummy dir marker nodes may cause confusion
        nodes = self.tree.selection()
        return [node for node in nodes if self.tree.item(node, "text") != _dummy_node_text]

    def get_selected_node(self):
        """Returns single node (or nothing)"""
        nodes = self._cleaned_selection()
        if len(nodes) == 1:
            return nodes[0]
        elif len(nodes) > 1:
            return self.tree.focus() or None
        else:
            return None

    def get_selected_nodes(self, notify_if_empty=False):
        """Can return several nodes"""
        result = self._cleaned_selection()
        if not result and notify_if_empty:
            self.notify_missing_selection()
        return result

    def get_selection_info(self, notify_if_empty=False):
        nodes = self.get_selected_nodes(notify_if_empty)
        if not nodes:
            return None
        elif len(nodes) == 1:
            description = "'" + self.tree.set(nodes[0], "name") + "'"
        else:
            description = tr("%d items") % len(nodes)

        paths = [self.tree.set(node, "path") for node in nodes]
        kinds = [self.tree.set(node, "kind") for node in nodes]

        return {"description": description, "nodes": nodes, "paths": paths, "kinds": kinds}

    def get_selected_path(self):
        return self.get_selected_value("path")

    def get_selected_kind(self):
        return self.get_selected_value("kind")

    def get_selected_name(self):
        return self.get_selected_value("name")

    def get_extension_from_name(self, name):
        if name is None:
            return None
        if "." in name:
            return "." + name.split(".")[-1].lower()
        else:
            return name.lower()

    def get_selected_value(self, key):
        node_id = self.get_selected_node()

        if node_id:
            return self.tree.set(node_id, key)
        else:
            return None

    def get_active_directory(self):
        path = self.tree.set(ROOT_NODE_ID, "path")
        return path

    def request_dirs_child_data(self, node_id, paths):
        raise NotImplementedError()

    def show_fs_info(self):
        path = self.get_selected_path()
        if path is None:
            path = self.current_focus
        self.request_fs_info(path)

    def request_fs_info(self, path):
        raise NotImplementedError()

    def present_fs_info(self, info):
        total_str = "?" if info["total"] is None else sizeof_fmt(info["total"])
        used_str = "?" if info["used"] is None else sizeof_fmt(info["used"])
        free_str = "?" if info["free"] is None else sizeof_fmt(info["free"])
        text = tr("Storage space on this drive or filesystem") + ":\n\n" "    %s: %s\n" % (
            tr("total space"),
            total_str,
        ) + "    %s: %s\n" % (tr("used space"), used_str) + "    %s: %s\n" % (
            tr("free space"),
            free_str,
        )

        if info.get("comment"):
            text += "\n" + info["comment"]

        messagebox.showinfo(tr("Storage info"), text, master=self)

    def cache_dirs_child_data(self, data):
        from copy import deepcopy

        data = deepcopy(data)

        for parent_path in data:
            children_data = data[parent_path]
            if isinstance(children_data, dict):
                for child_name in children_data:
                    child_data = children_data[child_name]
                    assert isinstance(child_data, dict)
                    if "label" not in child_data:
                        child_data["label"] = child_name

                    if "isdir" not in child_data:
                        child_data["isdir"] = child_data.get("size", 0) is None
            else:
                assert children_data is None

        self._cached_child_data.update(data)

    def file_exists_in_cache(self, path):
        for parent_path in self._cached_child_data:
            # hard to split because it may not be in this system format
            name = path[len(parent_path) :]
            if name[0:1] in ["/", "\\"]:
                name = name[1:]

            if name in self._cached_child_data[parent_path]:
                return True

        return False

    def select_path_if_visible(self, path, node_id=""):
        for child_id in self.tree.get_children(node_id):
            if self.tree.set(child_id, "path") == path:
                self.tree.selection_set(child_id)
                return

            if self._is_open_dir_node(child_id):
                self.select_path_if_visible(path, child_id)

    def _is_open_dir_node(self, node_id) -> bool:
        # In Windows a node may get open=True simply by pressing ENTER on it
        return self.tree.item(node_id, "open") and self.tree.set(node_id, "kind") != "file"

    def get_open_paths(self, node_id=ROOT_NODE_ID):
        if self.tree.set(node_id, "kind") == "file":
            return set()
        elif node_id == ROOT_NODE_ID or self._is_open_dir_node(node_id):
            result = {self.tree.set(node_id, "path")}
            for child_id in self.tree.get_children(node_id):
                result.update(self.get_open_paths(child_id))
            return result

        else:
            return set()

    def invalidate_cache(self, paths=None):
        if paths is None:
            self._cached_child_data.clear()
        else:
            for path in paths:
                if path in self._cached_child_data:
                    del self._cached_child_data[path]

    def render_children_from_cache(self, node_id=""):
        """This node is supposed to be a directory and
        its contents needs to be shown and/or refreshed"""
        path = self.tree.set(node_id, "path")
        kind = self.tree.set(node_id, "kind")
        if kind == "file":
            logger.warning("File %r is treated as dir", path)
            return

        logger.debug("Rendering %r from cache", path)

        if path not in self._cached_child_data:
            self.request_dirs_child_data(node_id, self.get_open_paths() | {path})
            # leave it as is for now, it will be updated later
            return

        children_data = self._cached_child_data[path]

        if children_data in ["file", "missing"]:
            # path used to be a dir but is now a file or does not exist

            # if browser is focused into this path
            if node_id == "":
                self.show_error("Directory " + path + " does not exist anymore", node_id)
            elif children_data == "missing":
                self.tree.delete(node_id)
            else:
                assert children_data == "file"
                self.tree.set_children(node_id)  # clear the list of children
                self.tree.item(node_id, open=False)

        elif children_data is None:
            raise RuntimeError("None data for %s" % path)
        else:
            fs_children_names = children_data.keys()
            tree_children_ids = self.tree.get_children(node_id)

            # recollect children
            children = {}

            # first the ones, which are present already in tree
            for child_id in tree_children_ids:
                name = self.tree.set(child_id, "name")
                if name in fs_children_names:
                    children[name] = child_id
                    self.update_node_data(child_id, name, children_data[name])

            # add missing children
            for name in fs_children_names:
                if name not in children:
                    child_id = self.tree.insert(node_id, "end")
                    children[name] = child_id
                    self.tree.set(children[name], "path", self.join(path, name))
                    self.update_node_data(child_id, name, children_data[name])

            def file_order(name):
                # items in a folder should be ordered so that
                # folders come first and names are ordered case insensitively
                return (
                    not children_data[name]["isdir"],  # prefer directories
                    not ":" in name,  # prefer drives
                    name.upper(),
                    name,
                )

            # update tree
            ids_sorted_by_name = list(
                map(lambda key: children[key], sorted(children.keys(), key=file_order))
            )
            self.tree.set_children(node_id, *ids_sorted_by_name)

            # recursively update open children
            for child_id in ids_sorted_by_name:
                if self._is_open_dir_node(child_id):
                    self.render_children_from_cache(child_id)

    def show_error(self, msg, node_id=""):
        if not node_id:
            # clear tree
            self.tree.set_children("")

        err_id = self.tree.insert(node_id, "end")
        self.tree.item(err_id, text=msg)
        self.tree.set_children(node_id, err_id)

    def clear_error(self):
        "TODO:"

    def update_node_data(self, node_id, name, data):
        assert node_id != ""

        path = self.tree.set(node_id, "path")

        if data.get("modified"):
            try:
                # modification time is Unix epoch
                time_str = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(int(data["modified"])))
            except Exception:
                time_str = ""
        else:
            time_str = ""

        self.tree.set(node_id, "modified", time_str)

        if data["isdir"]:
            self.tree.set(node_id, "kind", "dir")
            self.tree.set(node_id, "size", "")

            # Ensure that expand button is visible
            # unless we know it doesn't have children
            children_ids = self.tree.get_children(node_id)
            if (
                self.show_expand_buttons
                and len(children_ids) == 0
                and (path not in self._cached_child_data or self._cached_child_data[path])
            ):
                self.tree.insert(node_id, "end", text=_dummy_node_text)

            if path.endswith(":") or path.endswith(":\\"):
                img = self.hard_drive_icon
            else:
                img = self.folder_icon
        else:
            self.tree.set(node_id, "kind", "file")
            self.tree.set(node_id, "size", data["size"])

            # Make sure it doesn't have children
            self.tree.set_children(node_id)

            if (
                path.lower().endswith(".py")
                or path.lower().endswith(".pyw")
                or path.lower().endswith(".pyi")
            ):
                img = self.python_file_icon
            elif self.should_open_name_in_thonny(name):
                img = self.text_file_icon
            else:
                img = self.generic_file_icon

        self.tree.set(node_id, "name", name)
        self.tree.item(node_id, text=" " + data["label"], image=img)

    def join(self, parent, child):
        if parent == "":
            if self.get_dir_separator() == "/":
                return "/" + child
            else:
                return child

        if parent.endswith(self.get_dir_separator()):
            return parent + child
        else:
            return parent + self.get_dir_separator() + child

    def get_dir_separator(self):
        return os.path.sep

    def on_double_click(self, event):
        # TODO: don't act when the click happens below last item
        path = self.get_selected_path()
        kind = self.get_selected_kind()
        name = self.get_selected_name()
        if kind == "file":
            if self.should_open_name_in_thonny(name):
                self.open_file(path)
            else:
                self.open_path_with_system_app(path)
        elif kind == "dir":
            self.request_focus_into(path)

        return "break"

    def open_file(self, path):
        pass

    def open_path_with_system_app(self, path):
        pass

    def on_secondary_click(self, event):
        node_id = self.tree.identify_row(event.y)

        if node_id:
            if node_id not in self.tree.selection():
                # replace current selection
                self.tree.selection_set(node_id)
            self.tree.focus(node_id)
        else:
            self.tree.selection_set()
            self.path_bar.focus_set()

        self.tree.update()

        self.refresh_menu(context="item")
        self.menu.tk_popup(event.x_root, event.y_root)

    def post_button_menu(self):
        self.refresh_menu(context="button")
        self.menu.tk_popup(
            self.menu_button.winfo_rootx(),
            self.menu_button.winfo_rooty() + self.menu_button.winfo_height(),
        )

    def refresh_menu(self, context):
        self.menu.delete(0, "end")
        self.add_first_menu_items(context)
        self.menu.add_separator()
        self.add_middle_menu_items(context)
        self.menu.add_separator()
        self.add_last_menu_items(context)

    def is_active_browser(self):
        return False

    def add_first_menu_items(self, context):
        if context == "item":
            selected_path = self.get_selected_path()
            selected_kind = self.get_selected_kind()
        else:
            selected_path = self.get_active_directory()
            selected_kind = "dir"

        if context == "button":
            self.menu.add_command(label=tr("Refresh"), command=self.cmd_refresh_tree)
            self.menu.add_command(
                label=tr("Open in system file manager"),
                command=lambda: self.open_path_with_system_app(selected_path),
            )

        else:
            if selected_kind == "dir":
                self.menu.add_command(
                    label=tr("Focus into"), command=lambda: self.request_focus_into(selected_path)
                )
            else:
                self.menu.add_command(
                    label=tr("Open in Thonny"), command=lambda: self.open_file(selected_path)
                )

            if self.is_active_browser():
                self.menu.add_command(
                    label=tr("Open in default external app"),
                    command=lambda: self.open_path_with_system_app(selected_path),
                )

                if selected_kind == "file":
                    ext = self.get_extension_from_name(self.get_selected_name())
                    self.menu.add_command(
                        label=tr("Configure %s files") % ext + "...",
                        command=lambda: self.open_extension_dialog(ext),
                    )

        hidden_files_label = (
            tr("Hide hidden files") if show_hidden_files() else tr("Show hidden files")
        )
        self.menu.add_command(label=hidden_files_label, command=self.toggle_hidden_files)

    def toggle_hidden_files(self):
        get_workbench().set_option(
            HIDDEN_FILES_OPTION, not get_workbench().get_option(HIDDEN_FILES_OPTION)
        )
        self.refresh_tree()

    def cmd_refresh_tree(self):
        self.refresh_tree()

    def open_extension_dialog(self, extension: str) -> None:
        system_choice = tr("Open in system default app")
        thonny_choice = tr("Open in Thonny's text editor")

        current_index = (
            1 if get_workbench().get_option(get_file_handler_conf_key(extension)) == "thonny" else 0
        )

        choice = ask_one_from_choices(
            title=tr("Configure %s files") % extension,
            question=tr(
                "What to do with a %s file when you double-click it in Thonny's file browser?"
            )
            % extension,
            choices=[system_choice, thonny_choice],
            initial_choice_index=current_index,
            master=self.winfo_toplevel(),
        )

        if not choice:
            return

        get_workbench().set_option(
            get_file_handler_conf_key(extension),
            "system" if choice == system_choice else "thonny",
        )
        # update icons
        self.refresh_tree()

    def add_middle_menu_items(self, context):
        if self.supports_new_file():
            self.menu.add_command(label=tr("New file") + "...", command=self.create_new_file)

        if self.supports_directories():
            self.menu.add_command(label=tr("New directory") + "...", command=self.mkdir)

        if self.supports_copypaste():
            self.menu.add_command(label=tr("Cut"), command=self.cut_files)
            self.menu.add_command(label=tr("Copy"), command=self.copy_files)
            target = self.get_selected_file()
            self.menu.add_command(label=tr("Paste"), command=self.paste_files)
            if (
                target is None
                or not self.copypaste.has_selection()
                or self.copypaste.conflicts(target)
            ):
                self.menu.entryconfig(tr("Paste"), state="disabled")

        if self.supports_rename():
            self.menu.add_command(label=tr("Rename"), command=self.rename_file)

        if self.supports_trash():
            trash_label = tr("Move to Trash")
            self.menu.add_command(label=trash_label, command=self.move_to_trash)
        else:
            self.menu.add_command(label=tr("Delete"), command=self.delete)

    def add_last_menu_items(self, context):
        self.menu.add_command(label=tr("Properties"), command=self.show_properties)
        if context == "button":
            self.menu.add_command(label=tr("Storage space"), command=self.show_fs_info)

    def show_properties(self):
        node_id = self.get_selected_node()
        if node_id is None:
            self.notify_missing_selection()
            return

        values = self.tree.set(node_id)

        text = tr("Path") + ":\n    " + values["path"] + "\n\n"
        if values["kind"] == "dir":
            title = tr("Directory properties")
        else:
            title = tr("File properties")
            size_fmt_str = sizeof_fmt(int(values["size"]))
            bytes_str = str(values["size"]) + " " + tr("bytes")

            text += (
                tr("Size")
                + ":\n    "
                + (
                    bytes_str
                    if size_fmt_str.endswith(" B")
                    else size_fmt_str + "  (" + bytes_str + ")"
                )
                + "\n\n"
            )

        if values["modified"].strip():
            text += tr("Modified") + ":\n    " + values["modified"] + "\n\n"

        messagebox.showinfo(title, text.strip(), master=self)

    def refresh_tree(self, paths_to_invalidate=None):
        self.invalidate_cache(paths_to_invalidate)
        if self.winfo_ismapped():
            self.render_children_from_cache("")

        if self.path_to_highlight:
            self.select_path_if_visible(self.path_to_highlight)
            self.path_to_highlight = None

    def create_new_file(self):
        selected_node_id = self.get_selected_node()

        if selected_node_id:
            selected_path = self.tree.set(selected_node_id, "path")
            selected_kind = self.tree.set(selected_node_id, "kind")

            if selected_kind == "dir":
                parent_path = selected_path
            else:
                parent_id = self.tree.parent(selected_node_id)
                parent_path = self.tree.set(parent_id, "path")
        else:
            parent_path = self.current_focus

        name = ask_string(
            "File name", "Provide filename", initial_value="", master=self.winfo_toplevel()
        )

        if not name:
            return None

        path = self.join(parent_path, name)

        if self.path_exists(path):
            messagebox.showerror("Error", "The file '" + path + "' already exists", master=self)
            return self.create_new_file()
        else:
            self.create_new_file_editor(path)

        return path

    def create_new_file_editor(self, path):
        raise NotImplementedError()

    def delete(self):
        selection = self.get_selection_info(True)
        if not selection:
            return

        confirmation = "Are you sure want to delete %s?" % selection["description"]
        confirmation += "\n\nNB! Trash bin won't be used (no way to undelete)!"
        if "dir" in selection["kinds"]:
            confirmation += "\n" + "Directories will be deleted with content."

        if not messagebox.askyesno("Are you sure?", confirmation, master=self):
            return

        self.perform_delete(selection["paths"], tr("Deleting %s") % selection["description"])
        self.refresh_tree()

    def move_to_trash(self):
        assert self.supports_trash()

        selection = self.get_selection_info(True)
        if not selection:
            return

        if not messagebox.askokcancel(
            tr("Moving to Trash"),
            tr("Move %s to Trash?") % selection["description"],
            icon="info",
            master=self,
        ):
            return

        self.perform_move_to_trash(
            selection["paths"], tr("Moving %s to Trash") % (selection["description"])
        )
        self.refresh_tree()

    def supports_trash(self):
        return False

    def mkdir(self):
        parent = self.get_selected_path()
        if parent is None:
            parent = self.current_focus
        else:
            if self.get_selected_kind() == "file":
                # dirname does the right thing even if parent is Linux path and running on Windows
                parent = os.path.dirname(parent)

        name = ask_string(
            tr("New directory"),
            tr("Enter name for new directory under\n%s") % parent,
            master=self.winfo_toplevel(),
        )
        if not name or not name.strip():
            return

        self.perform_mkdir(parent, name.strip())
        self.refresh_tree()

    def perform_delete(self, paths, description):
        raise NotImplementedError()

    def perform_move_to_trash(self, paths, description):
        raise NotImplementedError()

    def supports_directories(self):
        return True

    def perform_mkdir(self, parent_dir, name):
        raise NotImplementedError()

    def notify_missing_selection(self):
        messagebox.showerror(
            tr("Nothing selected"), tr("Select an item and try again!"), master=self
        )

    def should_open_name_in_thonny(self, name):
        ext = self.get_extension_from_name(name)
        return get_workbench().get_option(get_file_handler_conf_key(ext), "system") == "thonny"

    def supports_new_file(self):
        return False

    def get_selected_file(self):
        selection = self.get_selection_info(False)
        if not selection or len(selection["paths"]) > 1:
            return
        return selection["paths"][0]

    def supports_rename(self):
        return False

    def rename_file(self):
        old_name = self.get_selected_file()
        if old_name is None:
            return
        file_name = os.path.basename(old_name)

        new_name = simpledialog.askstring(
            tr("Rename '%s'") % file_name, tr("Enter new name"), initialvalue=file_name, parent=self
        )
        if new_name:
            self.perform_rename(old_name, new_name)
            self.refresh_tree()

    def perform_rename(self, old_name, new_name):
        raise Exception("overload this in subclass")

    def supports_copypaste(self):
        return self.copypaste

    def cut_files(self):
        assert self.copypaste
        self.copypaste.cut()

    def copy_files(self):
        assert self.copypaste
        self.copypaste.copy()

    def paste_files(self):
        assert self.copypaste
        target = self.get_selected_file()
        assert target
        if os.path.isfile(target):
            target = os.path.dirname(target)
        self.copypaste.paste(target)


class CopyPaste(object):
    def __init__(self, filebrowser):
        self.fb = filebrowser
        self.reset()

    def reset(self):
        self.mode = None
        self.paths = []

    def get_selection_paths(self):
        selection = self.fb.get_selection_info(True)
        if selection:
            return selection["paths"]
        return []

    def has_selection(self):
        return len(self.paths) > 0

    def dirname(self, path):
        if os.path.isfile(path):
            path = os.path.dirname(path)
        return path

    def _conflicts(self, target):
        target = self.dirname(target) + os.sep
        folder = set(map(lambda x: self.dirname(x) + os.sep, self.paths))

        if target in folder:
            return True

        for p in self.paths:
            p_isdir = os.path.isdir(p)
            if p_isdir:
                if target.startswith(p + os.sep):
                    return True
                if p.startswith(target):
                    _conf = p.find(os.sep, len(target)) < 0
                    if _conf:
                        return True
        return False

    def conflicts(self, target):
        conf = self._conflicts(target)
        return conf

    def cut(self):
        self.paths = self.get_selection_paths()
        self.mode = "X"

    def copy(self):
        self.paths = self.get_selection_paths()
        self.mode = "C"

    def paste(self, target):
        # https://docs.python.org/3/library/shutil.html#shutil.move
        # depends on os.rename semantics
        try:
            if self.conflicts(target):
                messagebox.showerror(
                    tr("Paste failed"), tr("There are conflicting folders"), parent=self
                )
                return

            for f in self.paths:
                isfile = os.path.isfile(f)
                if self.mode == "C":
                    if isfile:
                        shutil.copy2(f, target)
                    else:
                        folder_target = os.path.join(target, os.path.basename(f))
                        shutil.copytree(f, folder_target, dirs_exist_ok=True)
                elif self.mode == "X":
                    dest = shutil.move(f, target)
                else:
                    raise Exception("unsupported mode")

        finally:
            self.reset()


class BaseLocalFileBrowser(BaseFileBrowser):
    def __init__(self, master, show_expand_buttons=True):
        super().__init__(master, show_expand_buttons=show_expand_buttons)
        get_workbench().bind("WindowFocusIn", self.on_window_focus_in, True)
        get_workbench().bind("LocalFileOperation", self.on_local_file_operation, True)
        self.copypaste = CopyPaste(self)

    def destroy(self):
        super().destroy()
        get_workbench().unbind("WindowFocusIn", self.on_window_focus_in)
        get_workbench().unbind("LocalFileOperation", self.on_local_file_operation)

    def path_exists(self, path: str) -> Optional[bool]:
        return os.path.exists(path)

    def create_new_file_editor(self, path):
        get_workbench().get_editor_notebook().open_new_file(path)

    def request_dirs_child_data(self, node_id, paths):
        self.cache_dirs_child_data(get_dirs_children_info(paths, show_hidden_files()))
        self.render_children_from_cache(node_id)

    def split_path(self, path):
        parts = super().split_path(path)
        if running_on_windows() and path.startswith("\\\\"):
            # Don't split a network name!
            sep = self.get_dir_separator()
            for i in reversed(range(len(parts))):
                prefix = sep.join(parts[: i + 1])
                if os.path.ismount(prefix):
                    return [prefix] + parts[i + 1 :]

            # Could not find the prefix corresponding to mount
            return [path]
        else:
            return parts

    def open_file(self, path):
        get_workbench().get_editor_notebook().show_file(path)

    def open_path_with_system_app(self, path):
        try:
            open_with_default_app(path)
        except Exception as e:
            logger.error("Could not open %r in external. app", path, exc_info=e)
            messagebox.showerror(
                "Error",
                "Could not open '%s' in external app\nError: %s" % (path, e),
                parent=self.winfo_toplevel(),
            )

    def on_window_focus_in(self, event=None):
        self.refresh_tree()

    def on_local_file_operation(self, event):
        if event["operation"] in ["save", "delete"]:
            self.refresh_tree()
            self.select_path_if_visible(event["path"])

    def request_fs_info(self, path):
        if path == "":
            self.notify_missing_selection()
        else:
            if not os.path.isdir(path):
                path = os.path.dirname(path)

            import shutil

            self.present_fs_info(shutil.disk_usage(path)._asdict())

    def perform_delete(self, paths, description):
        # Deprecated. moving to trash should be used instead
        raise NotImplementedError()

    def perform_move_to_trash(self, paths, description):
        # TODO: do it with subprocess dialog
        import send2trash

        for path in paths:
            send2trash.send2trash(path)

    def perform_mkdir(self, parent_dir, name):
        os.mkdir(os.path.join(parent_dir, name), mode=0o700)

    def supports_trash(self):
        try:
            import send2trash  # @UnusedImport

            return True
        except ImportError:
            return False

    def supports_new_file(self):
        return True

    def supports_rename(self):
        return self.get_selected_file()

    def perform_rename(self, old_name, new_name):
        basepath = os.path.dirname(old_name)
        full_path = os.path.join(basepath, new_name)
        logger.debug("rename %s to %s" % (old_name, full_path))

        if old_name == full_path:
            return

        if os.path.exists(full_path):
            raise UserError(tr("File already exists"))

        os.rename(old_name, full_path)


class BaseRemoteFileBrowser(BaseFileBrowser):
    def __init__(self, master, show_expand_buttons=True):
        super().__init__(master, show_expand_buttons=show_expand_buttons)
        self.dir_separator = "/"

        get_workbench().bind("get_dirs_children_info_response", self.update_dir_data, True)
        get_workbench().bind("get_fs_info_response", self.present_fs_info, True)
        get_workbench().bind("RemoteFileOperation", self.on_remote_file_operation, True)

    def destroy(self):
        super().destroy()
        get_workbench().unbind("get_dirs_children_info_response", self.update_dir_data)
        get_workbench().unbind("get_fs_info_response", self.present_fs_info)
        get_workbench().unbind("RemoteFileOperation", self.on_remote_file_operation)

    def get_root_text(self):
        runner = get_runner()
        if runner:
            return runner.get_node_label()

        return "Back-end"

    def create_new_file_editor(self, path):
        get_workbench().get_editor_notebook().open_new_file(path, remote=True)

    def request_dirs_child_data(self, node_id, paths):
        if get_runner():
            get_runner().send_command(
                InlineCommand(
                    "get_dirs_children_info",
                    node_id=node_id,
                    paths=paths,
                    include_hidden=show_hidden_files(),
                )
            )

    def request_fs_info(self, path):
        if get_runner():
            get_runner().send_command(InlineCommand("get_fs_info", path=path))

    def get_dir_separator(self):
        return self.dir_separator

    def update_dir_data(self, msg):
        if msg.get("error"):
            self.show_error(msg["error"])
        else:
            self.dir_separator = msg["dir_separator"]
            self.cache_dirs_child_data(msg["data"])
            self.render_children_from_cache(msg["node_id"])

        if self.path_to_highlight:
            self.select_path_if_visible(self.path_to_highlight)
            self.path_to_highlight = None

    def open_file(self, path):
        get_workbench().get_editor_notebook().show_remote_file(path)

    def open_path_with_system_app(self, path):
        messagebox.showinfo(
            tr("Not supported"),
            tr("Opening remote files in external app is not supported.")
            + "\n\n"
            + tr(
                "If it is a text file, then you can configure it to open in Thonny "
                "by right-clicking it and selecting 'Configure ... files'."
            )
            + "\n\n"
            + tr(
                "If the file needs to be opened in external app, then download it to a local "
                "directory and open it from there!"
            ),
            master=self,
        )

    def supports_directories(self):
        runner = get_runner()
        if not runner:
            return False
        proxy = runner.get_backend_proxy()
        if not proxy:
            return False
        return proxy.supports_remote_directories()

    def on_remote_file_operation(self, event):
        path = event["path"]
        exists_in_cache = self.file_exists_in_cache(path)
        if (
            event["operation"] == "save"
            and exists_in_cache
            or event["operation"] == "delete"
            and not exists_in_cache
        ):
            # No need to refresh
            return

        if "/" in path:
            parent = path[: path.rfind("/")]
            if not parent:
                parent = "/"
        else:
            parent = ""

        self.refresh_tree([parent])
        self.path_to_highlight = path

    def perform_delete(self, paths, description):
        get_runner().send_command_and_wait(
            InlineCommand("delete", paths=paths, description=description),
            dialog_title=tr("Deleting"),
        )

    def perform_mkdir(self, parent_dir, name):
        path = (parent_dir + self.get_dir_separator() + name).replace("//", "/")
        get_runner().send_command_and_wait(
            InlineCommand("mkdir", path=path),
            dialog_title=tr("Creating directory"),
        )

    def supports_trash(self):
        return get_runner().get_backend_proxy().supports_trash()

    def request_focus_into(self, path):
        if not get_runner().ready_for_remote_file_operations(show_message=True):
            return False

        # super().request_focus_into(path)

        if not get_runner().supports_remote_directories():
            assert path == ""
            self.focus_into(path)
        elif self.current_focus == path:
            # refreshes
            self.focus_into(path)
        else:
            self.request_new_focus(path)

        return True

    def request_new_focus(self, path):
        # Overridden in active browser
        self.focus_into(path)

    def cmd_refresh_tree(self):
        if not get_runner().ready_for_remote_file_operations(show_message=True):
            return

        super().cmd_refresh_tree()

    def perform_rename(self, old_name, new_name):
        # TODO:
        raise NotImplementedError()


class DialogRemoteFileBrowser(BaseRemoteFileBrowser):
    def __init__(self, master, dialog):
        super().__init__(master, show_expand_buttons=False)

        self.dialog = dialog
        self.tree["show"] = ("tree", "headings")
        self.tree.configure(displaycolumns=(5,))
        self.tree.configure(height=10)

    def open_file(self, path):
        self.dialog.double_click_file(path)

    def should_open_name_in_thonny(self, name):
        # In dialog, all file types are to be opened in Thonny
        return True


class BackendFileDialog(CommonDialog):
    def __init__(self, master, kind, initial_dir):
        super().__init__(master=master)
        self.result = None

        self.updating_selection = False

        self.kind = kind
        if kind == "open":
            self.title(tr("Open from %s") % get_runner().get_node_label())
        else:
            assert kind == "save"
            self.title(tr("Save to %s") % get_runner().get_node_label())

        background = ttk.Frame(self)
        background.grid(row=0, column=0, sticky="nsew")
        self.columnconfigure(0, weight=1)
        self.rowconfigure(0, weight=1)

        self.browser = DialogRemoteFileBrowser(background, self)
        self.browser.grid(
            row=0,
            column=0,
            columnspan=4,
            sticky="nsew",
            pady=self.get_large_padding(),
            padx=self.get_large_padding(),
        )
        self.browser.configure(borderwidth=1, relief="groove")
        self.browser.tree.configure(selectmode="browse")

        self.name_label = ttk.Label(background, text=tr("File name:"))
        self.name_label.grid(
            row=1,
            column=0,
            pady=(0, self.get_large_padding()),
            padx=self.get_large_padding(),
            sticky="w",
        )

        self.name_var = create_string_var("")
        self.name_entry = ttk.Entry(
            background, textvariable=self.name_var, state="normal" if kind == "save" else "disabled"
        )
        self.name_entry.grid(
            row=1,
            column=1,
            pady=(0, self.get_large_padding()),
            padx=(0, self.get_large_padding()),
            sticky="we",
        )
        self.name_entry.bind("<KeyRelease>", self.on_name_edit, True)

        self.ok_button = ttk.Button(background, text=tr("OK"), command=self.on_ok)
        self.ok_button.grid(
            row=1,
            column=2,
            pady=(0, self.get_large_padding()),
            padx=(0, self.get_large_padding()),
            sticky="e",
        )

        self.cancel_button = ttk.Button(background, text=tr("Cancel"), command=self.on_cancel)
        self.cancel_button.grid(
            row=1,
            column=3,
            pady=(0, self.get_large_padding()),
            padx=(0, self.get_large_padding()),
            sticky="e",
        )

        background.rowconfigure(0, weight=1)
        background.columnconfigure(1, weight=1)

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

        self.tree_select_handler_id = self.browser.tree.bind(
            "<<TreeviewSelect>>", self.on_tree_select, True
        )

        self.browser.request_focus_into(initial_dir)

        self.name_entry.focus_set()

    def on_ok(self, event=None):
        tree = self.browser.tree
        name = self.name_var.get()

        if not name:
            messagebox.showerror(tr("Error"), tr("You need to select a file!"), master=self)
            return

        for node_id in tree.get_children(""):
            if name and name == tree.set(node_id, "name"):
                break
        else:
            node_id = None

        if node_id is not None:
            node_kind = tree.set(node_id, "kind")
            if node_kind != "file":
                messagebox.showerror(tr("Error"), tr("You need to select a file!"), master=self)
                return
            elif self.kind == "save":
                if not messagebox.askyesno(
                    tr("Overwrite?"), tr("Do you want to overwrite '%s' ?") % name, master=self
                ):
                    return

        parent_path = tree.set("", "path")
        if parent_path == "" or parent_path.endswith("/"):
            self.result = parent_path + name
        else:
            self.result = parent_path + "/" + name

        self.destroy()

    def on_cancel(self, event=None):
        self.result = None
        self.destroy()

    def on_tree_select(self, event=None):
        if self.updating_selection:
            return

        if self.browser.get_selected_kind() == "file":
            name = self.browser.get_selected_name()
            if name:
                self.name_var.set(name)

    def on_name_edit(self, event=None):
        self.updating_selection = True
        tree = self.browser.tree
        if self.tree_select_handler_id:
            tree.unbind("<<TreeviewSelect>>", self.tree_select_handler_id)
            self.tree_select_handler_id = None

        name = self.name_var.get()
        for node_id in tree.get_children(""):
            if name == tree.set(node_id, "name"):
                tree.selection_add(node_id)
            else:
                tree.selection_remove(node_id)

        self.updating_selection = False
        self.tree_select_handler_id = tree.bind("<<TreeviewSelect>>", self.on_tree_select, True)

    def double_click_file(self, path):
        assert path.endswith(self.name_var.get())
        self.on_ok()

    def set_initial_focus(self, node=None) -> bool:
        self.name_entry.focus_set()
        return True


class NodeChoiceDialog(CommonDialog):
    def __init__(self, master, prompt):
        super().__init__(master=master)
        self.result = None

        self.title(prompt)

        background = ttk.Frame(self)
        background.grid(row=0, column=0, sticky="nsew")
        self.columnconfigure(0, weight=1)
        self.rowconfigure(0, weight=1)

        local_caption = get_local_files_root_text()
        remote_caption = get_runner().get_node_label()

        button_width = max(len(local_caption), len(remote_caption)) + 10

        self.local_button = ttk.Button(
            background,
            text=" \n" + local_caption + "\n ",
            width=button_width,
            command=self.on_local,
        )
        self.local_button.grid(row=0, column=0, pady=20, padx=20)

        self.remote_button = ttk.Button(
            background,
            text=" \n" + remote_caption + "\n ",
            width=button_width,
            command=self.on_remote,
        )
        self.remote_button.grid(row=1, column=0, pady=(0, 20), padx=20)

        self.local_button.focus_set()

        self.bind("<Escape>", self.on_cancel, True)
        self.bind("<Return>", self.on_return, True)
        self.bind("<Down>", self.on_down, True)
        self.bind("<Up>", self.on_up, True)
        self.protocol("WM_DELETE_WINDOW", self.on_cancel)

    def on_local(self, event=None):
        self.result = "local"
        self.destroy()

    def on_remote(self, event=None):
        self.result = "remote"
        self.destroy()

    def on_return(self, event=None):
        if self.focus_get() == self.local_button:
            self.on_local(event)
        elif self.focus_get() == self.remote_button:
            self.on_remote(event)

    def on_down(self, event=None):
        if self.focus_get() == self.local_button:
            self.remote_button.focus_set()

    def on_up(self, event=None):
        if self.focus_get() == self.remote_button:
            self.local_button.focus_set()

    def on_cancel(self, event=None):
        self.result = None
        self.destroy()


def ask_backend_path(master, dialog_kind):
    proxy = get_runner().get_backend_proxy()
    if not proxy:
        return None

    assert proxy.supports_remote_files()

    dlg = BackendFileDialog(master, dialog_kind, proxy.get_cwd())
    show_dialog(dlg, master)
    return dlg.result


def choose_node_for_file_operations(master, prompt):
    if get_runner().supports_remote_files():
        dlg = NodeChoiceDialog(master, prompt)
        show_dialog(dlg, master)
        if dlg.result == "remote" and not get_runner().ready_for_remote_file_operations(
            show_message=True
        ):
            return None
        return dlg.result
    else:
        return "local"


def get_local_files_root_text():
    global _LOCAL_FILES_ROOT_TEXT

    if not _LOCAL_FILES_ROOT_TEXT:
        # translation can't be done in module load time
        _LOCAL_FILES_ROOT_TEXT = tr("This computer")

    return _LOCAL_FILES_ROOT_TEXT


def get_file_handler_conf_key(extension):
    return "file_default_handlers.%s" % extension


def show_hidden_files():
    return get_workbench().get_option(HIDDEN_FILES_OPTION)
