from core.fileoperations import CopyFiles, MoveFiles
from core.github import find_repos, GitHubRepo
from core.os_ import open_terminal_in_directory, open_native_file_manager, \
	get_popen_kwargs_for_opening
from core.util import strformat_dict_values, listdir_absolute
from core.quicksearch_matchers import path_starts_with, basename_starts_with, \
	contains_chars, contains_chars_after_separator
from core.trash import move_to_trash
from fman import *
from getpass import getuser
from io import BytesIO
from itertools import chain, islice
from os import mkdir, rename, listdir, remove
from os.path import join, isfile, exists, splitdrive, basename, normpath, \
	isdir, split, dirname, realpath, expanduser, samefile, isabs, pardir, \
	islink
from PyQt5.QtCore import QFileInfo, QUrl
from PyQt5.QtGui import QDesktopServices
from shutil import copy, move, rmtree
from subprocess import Popen, DEVNULL, PIPE
from tempfile import TemporaryDirectory
from zipfile import ZipFile

import fman
import json
import os
import re
import sys

class About(ApplicationCommand):
	def __call__(self):
		msg = "fman version: " + FMAN_VERSION
		msg += "\n" + self._get_registration_info()
		show_alert(msg)
	def _get_registration_info(self):
		user_json_path = join(DATA_DIRECTORY, 'Local', 'User.json')
		try:
			with open(user_json_path, 'r') as f:
				data = json.load(f)
			return 'Registered to %s.' % data['email']
		except (FileNotFoundError, ValueError, KeyError):
			return 'Not registered.'

class Help(ApplicationCommand):

	aliases = ('Help', 'Show keyboard shortcuts', 'Show key bindings')

	def __call__(self):
		QDesktopServices.openUrl(QUrl('https://fman.io/docs/key-bindings?s=f'))

class _CorePaneCommand(DirectoryPaneCommand):
	def __init__(self, *args, **kwargs):
		super().__init__(*args, **kwargs)
		# The field `ui` is useful for automated tests: Tests can overwrite it
		# with a stub implementation to run without an actual GUI.
		self.ui = fman
	def toggle_selection(self):
		file_under_cursor = self.pane.get_file_under_cursor()
		if file_under_cursor:
			self.pane.toggle_selection(file_under_cursor)

class MoveCursorDown(_CorePaneCommand):
	def __call__(self, toggle_selection=False):
		if toggle_selection:
			self.toggle_selection()
		self.pane.move_cursor_down()

class MoveCursorUp(_CorePaneCommand):
	def __call__(self, toggle_selection=False):
		if toggle_selection:
			self.toggle_selection()
		self.pane.move_cursor_up()

class MoveCursorHome(_CorePaneCommand):
	def __call__(self, toggle_selection=False):
		self.pane.move_cursor_home(toggle_selection)

class MoveCursorEnd(_CorePaneCommand):
	def __call__(self, toggle_selection=False):
		self.pane.move_cursor_end(toggle_selection)

class MoveCursorPageUp(_CorePaneCommand):
	def __call__(self, toggle_selection=False):
		self.pane.move_cursor_page_up(toggle_selection)
		self.pane.move_cursor_up()

class MoveCursorPageDown(_CorePaneCommand):
	def __call__(self, toggle_selection=False):
		self.pane.move_cursor_page_down(toggle_selection)
		self.pane.move_cursor_down()

class ToggleSelection(_CorePaneCommand):
	def __call__(self):
		self.toggle_selection()

class MoveToTrash(_CorePaneCommand):

	aliases = ('Delete', 'Move to trash', 'Move to recycle bin')

	def __call__(self):
		to_delete = self.get_chosen_files()
		if not to_delete:
			show_alert('No file is selected!')
			return
		if len(to_delete) > 1:
			description = 'these %d items' % len(to_delete)
		else:
			description = to_delete[0]
		trash = 'Recycle Bin' if PLATFORM == 'Windows' else 'Trash'
		choice = self.ui.show_alert(
			"Do you really want to move %s to the %s?" % (description, trash),
			YES | NO, YES
		)
		if choice & YES:
			move_to_trash(*to_delete)

class DeletePermanently(DirectoryPaneCommand):
	def __call__(self):
		to_delete = self.get_chosen_files()
		if not to_delete:
			show_alert('No file is selected!')
			return
		if len(to_delete) > 1:
			description = 'these %d items' % len(to_delete)
		else:
			description = to_delete[0]
		choice = show_alert(
			"Do you really want to PERMANENTLY delete %s? This action cannot "
			"be undone!" % description,
			YES | NO, YES
		)
		if choice & YES:
			for file_path in to_delete:
				if isdir(file_path):
					def handle_error(func, path, exc_info):
						if not isinstance(exc_info[1], FileNotFoundError):
							raise
					rmtree(file_path, onerror=handle_error)
				else:
					remove(file_path)

class GoUp(_CorePaneCommand):

	aliases = ('Go up', 'Go to parent directory')

	def __call__(self):
		current_dir = self.pane.get_path()
		drive = splitdrive(current_dir)[1]
		if not drive or drive == os.sep:
			# We catch this case because the callback below doesn't handle it.
			# Consider: current_dir is '/'. Say the cursor is at /bin. We want
			# it to stay there. But the callback would attempt to place it at /.
			# This doesn't make sense.
			return
		callback = lambda: self.pane.place_cursor_at(current_dir)
		self.pane.set_path(dirname(current_dir), callback)

class Open(_CorePaneCommand):
	def __call__(self):
		file_under_cursor = self.pane.get_file_under_cursor()
		if file_under_cursor:
			_open(self.pane, file_under_cursor)
		else:
			show_alert('No file is selected!')

class OpenListener(DirectoryPaneListener):
	def on_doubleclicked(self, file_path):
		_open(self.pane, file_path)

def _open(pane, file_path):
	if isdir(file_path):
		pane.set_path(realpath(file_path))
	else:
		if PLATFORM == 'Linux':
			use_qt = False
			try:
				Popen(
					[file_path], stdin=DEVNULL, stdout=DEVNULL, stderr=DEVNULL
				)
			except (OSError, ValueError):
				use_qt = True
		else:
			use_qt = True
		if use_qt:
			QDesktopServices.openUrl(QUrl.fromLocalFile(file_path))

class OpenWithEditor(_CorePaneCommand):

	aliases = ('Edit',)

	_PLATFORM_APPLICATIONS_FILTER = {
		'Mac': 'Applications (*.app)',
		'Windows': 'Applications (*.exe)',
		'Linux': 'Applications (*)'
	}
	def __call__(self, create_new=None):
		if create_new is not None:
			# TODO: Remove this migration in October 2017
			show_alert(
				'Error: Command open_with_editor no longer supports argument '
				'`create_new`. Please update your Key Bindings.json to use '
				'the new command create_and_edit_file instead.'
			)
			return
		self._open_with_editor(self.pane.get_file_under_cursor())
	def _open_with_editor(self, file_path):
		if not exists(file_path):
			show_alert('No file is selected!')
			return
		settings = load_json('Core Settings.json', default={})
		editor = settings.get('editor', {})
		if not editor:
			choice = self.ui.show_alert(
				'Editor is currently not configured. Please pick one.',
				OK | CANCEL, OK
			)
			if choice & OK:
				editor_path = self.ui.show_file_open_dialog(
					'Pick an Editor', self._get_applications_directory(),
					self._PLATFORM_APPLICATIONS_FILTER[PLATFORM]
				)
				if editor_path:
					editor = get_popen_kwargs_for_opening('{file}', editor_path)
					settings['editor'] = editor
					save_json('Core Settings.json')
		if editor:
			popen_kwargs = strformat_dict_values(editor, {'file': file_path})
			Popen(**popen_kwargs)
	def _get_applications_directory(self):
		if PLATFORM == 'Mac':
			return '/Applications'
		elif PLATFORM == 'Windows':
			result = r'c:\Program Files'
			if not exists(result):
				result = splitdrive(sys.executable)[0] + '\\'
			return result
		elif PLATFORM == 'Linux':
			return '/usr/bin'
		raise NotImplementedError(PLATFORM)

class CreateAndEditFile(OpenWithEditor):

	aliases = ('New file', 'Create file', 'Create and edit file')

	def __call__(self):
		file_under_cursor = self.pane.get_file_under_cursor()
		if isfile(file_under_cursor):
			default_name = basename(file_under_cursor)
		else:
			default_name = ''
		file_name, ok = \
			show_prompt('Enter file name to create/edit:', default_name)
		if ok and file_name:
			file_to_edit = join(self.pane.get_path(), file_name)
			if not exists(file_to_edit):
				open(file_to_edit, 'w').close()
			self.pane.place_cursor_at(file_to_edit)
			self._open_with_editor(file_to_edit)

class _TreeCommand(_CorePaneCommand):
	def __call__(self, files=None, dest_dir=None):
		if files is None:
			files = self.get_chosen_files()
			src_dir = self.pane.get_path()
		else:
			src_dir=None
		if dest_dir is None:
			dest_dir = self.other_pane.get_path()
		proceed = self._confirm_tree_operation(files, dest_dir, src_dir)
		if proceed:
			dest_dir, dest_name = proceed
			self._call(files, dest_dir, src_dir, dest_name)
	def _call(self, files, dest_dir, src_dir=None, dest_name=None):
		raise NotImplementedError()
	@property
	def other_pane(self):
		panes = self.pane.window.get_panes()
		return panes[(panes.index(self.pane) + 1) % len(panes)]
	def _confirm_tree_operation(self, files, dest_dir, src_dir):
		if not files:
			show_alert('No file is selected!')
			return
		if len(files) == 1:
			file_, = files
			dest_name = basename(file_) if isfile(file_) else ''
			files_descr = '"%s"' % basename(file_)
		else:
			dest_name = ''
			files_descr = '%d files' % len(files)
		descr_verb = self.__class__.__name__
		message = '%s %s to' % (descr_verb, files_descr)
		dest = normpath(join(dest_dir, dest_name))
		dest, ok = self.ui.show_prompt(message, dest)
		if ok:
			if not isabs(dest):
				dest = join(src_dir, dest)
			if exists(dest):
				if isdir(dest):
					return dest, None
				else:
					if len(files) == 1:
						return split(dest)
					else:
						self.ui.show_alert(
							'You cannot %s multiple files to a single file!' %
							descr_verb.lower()
						)
			else:
				if len(files) == 1:
					return split(dest)
				else:
					choice = self.ui.show_alert(
						'%s does not exist. Do you want to create it '
						'as a directory and %s the files there?' %
						(dest, descr_verb.lower()), YES | NO, YES
					)
					if choice & YES:
						return dest, None

class Copy(_TreeCommand):
	def _call(self, files, dest_dir, src_dir=None, dest_name=None):
		CopyFiles(self.ui, files, dest_dir, src_dir, dest_name)()

class Move(_TreeCommand):
	def _call(self, files, dest_dir, src_dir=None, dest_name=None):
		MoveFiles(self.ui, files, dest_dir, src_dir, dest_name)()

class DragAndDropListener(DirectoryPaneListener):
	def on_files_dropped(self, files, dest_dir, is_copy_not_move):
		command = 'copy' if is_copy_not_move else 'move'
		self.pane.run_command(command, {'files': files, 'dest_dir': dest_dir})

class Rename(_CorePaneCommand):
	def __call__(self):
		file_under_cursor = self.pane.get_file_under_cursor()
		if file_under_cursor:
			self.pane.edit_name(file_under_cursor)
		else:
			show_alert('No file is selected!')

class RenameListener(DirectoryPaneListener):
	def on_name_edited(self, file_path, new_name):
		old_name = basename(file_path)
		if not new_name or new_name == old_name:
			return
		is_relative = \
			os.sep in new_name or new_name in (pardir, '.') \
			or (PLATFORM == 'Windows' and '/' in new_name)
		if is_relative:
			show_alert(
				'Relative paths are not supported. Please use Move (F6) '
				'instead.'
			)
			return
		new_path = join(dirname(file_path), new_name)
		do_rename = True
		if exists(new_path):
			# Don't show dialog when "Foo" was simply renamed to "foo":
			if not samefile(new_path, file_path):
				response = show_alert(
					new_name + ' already exists. Do you want to overwrite it?',
					buttons=YES|NO, default_button=NO
				)
				do_rename = response & YES
		if do_rename:
			try:
				rename(file_path, new_path)
			except OSError as e:
				if isinstance(e, PermissionError):
					message = 'Access was denied trying to rename %s to %s.'
				else:
					message = 'Could not rename %s to %s.'
				show_alert(message % (old_name, new_name))
			else:
				self.pane.place_cursor_at(new_path)

class CreateDirectory(_CorePaneCommand):

	aliases = (
		'New folder', 'Create folder', 'New directory', 'Create directory'
	)

	def __call__(self):
		name, ok = self.ui.show_prompt("New folder (directory)")
		if ok and name:
			dir_path = join(self.pane.get_path(), name)
			try:
				mkdir(dir_path)
			except FileExistsError:
				if isdir(dir_path):
					show_alert("This directory already exists!")
				else:
					show_alert("A file with this name already exists!")
			self.pane.place_cursor_at(dir_path)

class OpenTerminal(_CorePaneCommand):

	aliases = (
		'Terminal', 'Shell', 'Open terminal', 'Open shell'
	)

	def __call__(self):
		open_terminal_in_directory(self.pane.get_path())

class OpenNativeFileManager(_CorePaneCommand):
	def __call__(self):
		open_native_file_manager(self.pane.get_path())

class CopyPathsToClipboard(_CorePaneCommand):
	def __call__(self):
		chosen_files = self.get_chosen_files()
		if not chosen_files:
			show_alert('No file is selected!')
			return
		files = '\n'.join(map(normpath, chosen_files))
		clipboard.clear()
		clipboard.set_text(files)

class CopyToClipboard(_CorePaneCommand):
	def __call__(self):
		files = self.get_chosen_files()
		if files:
			clipboard.copy_files(files)
		else:
			show_alert('No file is selected!')

class Cut(_CorePaneCommand):
	def __call__(self):
		if PLATFORM == 'Mac':
			show_alert(
				"Sorry, macOS doesn't support cutting files. Please press "
				"⌘-C (copy) followed by ⌘-⌥-V (move)."
			)
			return
		files = self.get_chosen_files()
		if files:
			clipboard.cut_files(files)
		else:
			show_alert('No file is selected!')

class Paste(_CorePaneCommand):
	def __call__(self):
		files = clipboard.get_files()
		if not files:
			return
		if clipboard.files_were_cut():
			self.pane.run_command('paste_cut')
		else:
			dest = self.pane.get_path()
			self.pane.run_command('copy', {'files': files, 'dest_dir': dest})

class PasteCut(_CorePaneCommand):
	def __call__(self):
		files = clipboard.get_files()
		if not any(map(exists, files)):
			# This can happen when the paste-cut has already been performed.
			return
		dest_dir = self.pane.get_path()
		self.pane.run_command('move', {'files': files, 'dest_dir': dest_dir})

class SelectAll(_CorePaneCommand):
	def __call__(self):
		self.pane.select_all()

class Deselect(_CorePaneCommand):
	def __call__(self):
		self.pane.clear_selection()

class ToggleHiddenFiles(_CorePaneCommand):

	aliases = ('Toggle hidden files', 'Show / hide hidden files')

	def __init__(self, *args, **kwargs):
		super().__init__(*args, **kwargs)
		settings = load_json('Panes.json', default=[], save_on_quit=True)
		default = {'show_hidden_files': False}
		pane_index = self.pane.window.get_panes().index(self.pane)
		for _ in range(pane_index - len(settings) + 1):
			settings.append(default.copy())
		self.pane_info = settings[pane_index]
		self.pane._add_filter(self.should_display)
	def should_display(self, file_path):
		if self.show_hidden_files:
			return True
		if PLATFORM == 'Mac' and file_path == '/Volumes':
			return True
		return not _is_hidden(file_path)
	def __call__(self):
		self.show_hidden_files = not self.show_hidden_files
		self.pane._invalidate_filters()
	@property
	def show_hidden_files(self):
		return self.pane_info['show_hidden_files']
	@show_hidden_files.setter
	def show_hidden_files(self, value):
		self.pane_info['show_hidden_files'] = value

def _is_hidden(file_path):
	return QFileInfo(file_path).isHidden()

class _OpenInPaneCommand(_CorePaneCommand):
	def __call__(self):
		panes = self.pane.window.get_panes()
		num_panes = len(panes)
		if num_panes < 2:
			raise NotImplementedError()
		this_pane = panes.index(self.pane)
		source_pane = panes[self.get_source_pane(this_pane, num_panes)]
		if source_pane is self.pane:
			to_open = source_pane.get_file_under_cursor()
		else:
			# This for instance happens when the right pane is active and the
			# user asks to "open in the right pane". The source pane in this
			# case is the left pane. The cursor in the left pane is not visible
			# (because the right pane is active) - but it still exists and might
			# be over a directory! If we opened the directory under the cursor,
			# we would thus open a subdirectory of the left pane. That's not
			# what we want. We want to open the directory of the left pane:
			to_open = source_pane.get_path()
		if not isdir(to_open):
			to_open = dirname(to_open)
		dest_pane = panes[self.get_destination_pane(this_pane, num_panes)]
		dest_pane.set_path(to_open)
	def get_source_pane(self, this_pane, num_panes):
		raise NotImplementedError()
	def get_destination_pane(self, this_pane, num_panes):
		raise NotImplementedError()

class OpenInRightPane(_OpenInPaneCommand):
	def get_source_pane(self, this_pane, num_panes):
		if this_pane == num_panes - 1:
			return this_pane - 1
		return this_pane
	def get_destination_pane(self, this_pane, num_panes):
		return min(this_pane + 1, num_panes - 1)

class OpenInLeftPane(_OpenInPaneCommand):
	def get_source_pane(self, this_pane, num_panes):
		if this_pane > 0:
			return this_pane
		return 1
	def get_destination_pane(self, this_pane, num_panes):
		return max(this_pane - 1, 0)

class ShowVolumes(_CorePaneCommand):

	aliases = ('Show volumes', 'Show drives')

	def __call__(self, pane_index=None):
		if pane_index is None:
			pane = self.pane
		else:
			pane = self.pane.window.get_panes()[pane_index]
		pane.set_path(_get_volumes_path(), callback=pane.focus)

def _get_volumes_path():
	if PLATFORM == 'Mac':
		return '/Volumes'
	elif PLATFORM == 'Windows':
		# Go to "My Computer":
		return ''
	elif PLATFORM == 'Linux':
		if isdir('/media'):
			contents = listdir('/media')
			user_name = _get_user()
			if contents == [user_name]:
				return join('/media', user_name)
			else:
				return '/media'
		else:
			return '/mnt'
	else:
		raise NotImplementedError(PLATFORM)

def _get_user():
	try:
		return getuser()
	except:
		return basename(expanduser('~'))

class GoTo(_CorePaneCommand):
	def __call__(self):
		visited_paths = load_json('Visited Paths.json', default={})
		if not visited_paths:
			visited_paths.update({
				path: 0 for path in self._get_default_paths()
			})
		get_items = SuggestLocations(visited_paths)
		result = show_quicksearch(get_items, self._get_tab_completion)
		if result:
			query, suggested_dir = result
			path = ''
			if suggested_dir:
				path = expanduser(suggested_dir)
			if not isdir(path):
				path = expanduser(query)
			if not exists(path):
				# Maybe the user copy-pasted and there's some extra whitespace:
				path = path.rstrip()
			if isfile(path):
				self.pane.set_path(
					dirname(path),
					callback=lambda path=path: self.pane.place_cursor_at(path)
				)
			elif isdir(path):
				self.pane.set_path(path)
	def _get_tab_completion(self, curr_suggestion):
		result = curr_suggestion
		if not result.endswith(os.sep):
			result += os.sep
		return result
	def _get_default_paths(self):
		home_dir = expanduser('~')
		result = list(self._get_nonhidden_subdirs(home_dir))
		if PLATFORM == 'Windows':
			for candidate in (r'C:\Program Files', r'C:\Program Files (x86)'):
				if isdir(candidate):
					result.append(candidate)
		elif PLATFORM == 'Mac':
			result.append('/Volumes')
		elif PLATFORM == 'Linux':
			media_user = join('/media', _get_user())
			if exists(media_user):
				result.append(media_user)
			elif exists('/media'):
				result.append('/media')
			if exists('/mnt'):
				result.append('/mnt')
			# We need to add more suggestions on Linux, because unlike Windows
			# and Mac, we (currently) do not integrate with the OS's native
			# search functionality:
			result.extend(islice(self._traverse_by_mtime(home_dir), 500))
			result.extend(
				islice(self._traverse_by_mtime(
					'/', exclude={'/proc', '/sys'}), 500
				)
			)
		return result
	def _get_nonhidden_subdirs(self, dir_path):
		for file_name in listdir(dir_path):
			file_path = join(dir_path, file_name)
			if isdir(file_path) and not _is_hidden(file_path):
				yield join(dir_path, file_name)
	def _traverse_by_mtime(self, dir_path, exclude=None):
		if exclude is None:
			exclude = set()
		to_visit = [(os.stat(dir_path), dir_path)]
		already_yielded = set()
		while to_visit:
			stat, parent = to_visit.pop()
			if parent in exclude:
				continue
			yield parent
			try:
				parent_contents = listdir(parent)
			except OSError:
				continue
			for file_name in parent_contents:
				if file_name.startswith('.'):
					continue
				file_path = join(parent, file_name)
				try:
					if not isdir(file_path) or islink(file_path):
						continue
				except OSError:
					continue
				try:
					stat = os.stat(file_path)
				except OSError:
					pass
				else:
					already_yielded.add(stat.st_ino)
					to_visit.append((stat, file_path))
			to_visit.sort(key=lambda tpl: tpl[0].st_mtime)

class GoToListener(DirectoryPaneListener):
	def __init__(self, *args, **kwargs):
		super().__init__(*args, **kwargs)
		self.is_first_path_change = True
	def on_path_changed(self):
		if self.is_first_path_change:
			# on_path_changed() is also called when fman starts. Since this is
			# not a user-initiated path change, we don't want to count it:
			self.is_first_path_change = False
			return
		new_path = unexpand_user(self.pane.get_path())
		visited_paths = \
			load_json('Visited Paths.json', default={}, save_on_quit=True)
		visited_paths[new_path] = visited_paths.get(new_path, 0) + 1

def unexpand_user(path, expanduser_=expanduser):
	home_dir = expanduser_('~')
	if path.startswith(home_dir):
		path = '~' + path[len(home_dir):]
	return path

class SuggestLocations:

	_MATCHERS = (
		path_starts_with, basename_starts_with,
		contains_chars_after_separator(os.sep), contains_chars
	)

	def __init__(self, visited_paths, file_system=None):
		if file_system is None:
			# Encapsulating filesystem-related functionality in a separate field
			# allows us to use a different implementation for testing.
			file_system = FileSystem()
		self.visited_paths = visited_paths
		self.fs = file_system
	def __call__(self, query):
		possible_dirs = self._gather_dirs(query)
		items = self._filter_matching(possible_dirs, query)
		return self._remove_nonexistent(items)
	def _gather_dirs(self, query):
		path = normpath(self.fs.expanduser(query))
		if PLATFORM == 'Windows':
			# Windows completely ignores trailing spaces in directory names at
			# all times. Make our implementation reflect this:
			path = path.rstrip(' ')
			# Handle the case where the user has entered a drive such as 'E:'
			# without the trailing backslash:
			if path == splitdrive(path)[0]:
				path += '\\'
		if isabs(path):
			get_subdirs = lambda dir_: self._sort(self._gather_subdirs(dir_))
			if self.fs.isdir(path):
				dir_ = self._realcase(path)
				return [self._unexpand_user(dir_)] + get_subdirs(dir_)
			elif self.fs.isdir(dirname(path)):
				return get_subdirs(self._realcase(dirname(path)))
		result = set(self.visited_paths)
		if len(query) > 2:
			"""Compensate for directories not yet in self.visited_paths:"""
			fs_folders = islice(self.fs.find_folders_starting_with(query), 100)
			result.update(self._sort(map(self._unexpand_user, fs_folders))[:10])
		return self._sort(result)
	def _sort(self, dirs):
		return sorted(dirs, key=lambda dir_: (
			-self.visited_paths.get(dir_, 0), len(dir_), dir_.lower()
		))
	def _filter_matching(self, dirs, query):
		result = [[] for _ in self._MATCHERS]
		for dir_ in dirs:
			for i, matcher in enumerate(self._MATCHERS):
				match = matcher(dir_.lower(), query.lower())
				if match is not None:
					result[i].append(QuicksearchItem(dir_, highlight=match))
					break
		return list(chain.from_iterable(result))
	def _remove_nonexistent(self, items):
		for item in items:
			dir_ = item.value
			if self.fs.isdir(self.fs.expanduser(dir_)):
				yield item
			else:
				try:
					del self.visited_paths[dir_]
				except KeyError:
					pass
	def _gather_subdirs(self, dir_):
		try:
			dir_contents = self.fs.listdir(dir_)
		except OSError:
			pass
		else:
			for name in dir_contents:
				file_path = join(dir_, name)
				if self.fs.isdir(file_path):
					yield self._unexpand_user(file_path)
	def _realcase(self, path):
		# NB: `path` must exist!
		is_case_sensitive = PLATFORM == 'Linux'
		if is_case_sensitive:
			return path
		dir_ = dirname(path)
		if dir_ == path:
			# We're at the root of the file system.
			return path
		dir_ = self._realcase(dir_)
		try:
			dir_contents = self.fs.listdir(dir_)
		except OSError:
			matching_names = []
		else:
			matching_names = \
				[f for f in dir_contents if f.lower() == basename(path).lower()]
		if not matching_names:
			return path
		return join(dir_, matching_names[0])
	def _unexpand_user(self, path):
		return unexpand_user(path, self.fs.expanduser)

class FileSystem:
	def isdir(self, path):
		return isdir(path)
	def expanduser(self, path):
		return expanduser(path)
	def listdir(self, path):
		try:
			return listdir(path)
		except PermissionError:
			if PLATFORM == 'Windows' and self._is_documents_and_settings(path):
				# Python can't listdir("C:\Documents and Settings"). In fact, no
				# Windows program can. But "C:\{DaS}\<Username>" does work, and
				# displays "C:\Users\<Username>". For consistency, treat DaS
				# like a symlink to \Users:
				return listdir(splitdrive(path)[0] + r'\Users')
			raise
	def samefile(self, f1, f2):
		return samefile(f1, f2)
	def find_folders_starting_with(self, pattern, timeout_secs=0.02):
		if PLATFORM == 'Mac':
			from objc import loadBundle
			ns = {}
			loadBundle(
				'CoreServices.framework', ns,
				bundle_identifier='com.apple.CoreServices'
			)
			pred = ns['NSPredicate'].predicateWithFormat_argumentArray_(
				"kMDItemContentType == 'public.folder' && "
				"kMDItemFSName BEGINSWITH[c] %@", [pattern]
			)
			query = ns['NSMetadataQuery'].alloc().init()
			query.setPredicate_(pred)
			query.setSearchScopes_(ns['NSArray'].arrayWithObject_('/'))
			query.startQuery()
			ns['NSRunLoop'].currentRunLoop().runUntilDate_(
				ns['NSDate'].dateWithTimeIntervalSinceNow_(timeout_secs)
			)
			query.stopQuery()
			for item in query.results():
				yield item.valueForAttribute_("kMDItemPath")
		elif PLATFORM == 'Windows':
			import adodbapi
			from pythoncom import com_error
			try:
				conn = adodbapi.connect(
					"Provider=Search.CollatorDSO;"
					"Extended Properties='Application=Windows';"
				)
				cursor = conn.cursor()
				# adodbapi claims to support "paramstyles", which would let us
				# pass parameters as an extra arg to .execute(...), without
				# having to worry about escaping them. Alas, adodbapi raises an
				# error when this feature is used. We thus have to escape the
				# param ourselves:
				def escape(param):
					return re.subn(r'([%_\[\]\^])', r'[\1]', param)[0]
				cursor.execute(
					"SELECT TOP 5 System.ItemPathDisplay FROM SYSTEMINDEX "
					"WHERE "
					"System.ItemType = 'Directory' AND "
					"System.ItemNameDisplay LIKE %r "
					"ORDER BY System.ItemPathDisplay"
					% (escape(pattern) + '%')
				)
				for row in iter(cursor.fetchone, None):
					value = row['System.ItemPathDisplay']
					# Seems to be None sometimes:
					if value:
						yield value
			except (adodbapi.Error, com_error):
				pass
	def _is_documents_and_settings(self, path):
		return splitdrive(normpath(path))[1].lower() == \
			   '\\documents and settings'

class CommandPalette(_CorePaneCommand):

	_MATCHERS = (contains_chars_after_separator(' '), contains_chars)

	_KEY_SYMBOLS_MAC = {
		'Cmd': '⌘', 'Alt': '⌥', 'Ctrl': '⌃', 'Shift': '⇧', 'Backspace': '⌫',
		'Up': '↑', 'Down': '↓', 'Left': '←', 'Right': '→', 'Enter': '↩'
	}

	def __call__(self):
		result = show_quicksearch(self._suggest_commands)
		if result:
			command = result[1]
			if command:
				command()
	def _suggest_commands(self, query):
		result = [[] for _ in self._MATCHERS]
		for cmd_name, aliases, command in self._get_all_commands():
			for alias in aliases:
				this_alias_matched = False
				for i, matcher in enumerate(self._MATCHERS):
					match = matcher(alias.lower(), query.lower())
					if match is not None:
						hint = ', '.join(self._get_shortcuts_for_command(cmd_name))
						item = QuicksearchItem(command, alias, match, hint)
						result[i].append(item)
						this_alias_matched = True
						break
				if this_alias_matched:
					# Don't check the other aliases:
					break
		for results in result:
			results.sort(key=lambda item: (len(item.title), item.title))
		return chain.from_iterable(result)
	def _get_all_commands(self):
		result = []
		for cmd_name in self.pane.get_commands():
			# https://docs.python.org/3/faq/programming.html#why-do-lambdas-
			# defined-in-a-loop-with-different-values-all-return-the-same-result
			aliases = self.pane.get_command_aliases(cmd_name)
			command = lambda cmd=cmd_name: self.pane.run_command(cmd)
			result.append((cmd_name, aliases, command))
		for cmd_name in get_application_commands():
			aliases = get_application_command_aliases(cmd_name)
			# https://docs.python.org/3/faq/programming.html#why-do-lambdas-
			# defined-in-a-loop-with-different-values-all-return-the-same-result
			command = lambda cmd=cmd_name: run_application_command(cmd)
			result.append((cmd_name, aliases, command))
		return result
	def _get_shortcuts_for_command(self, command):
		for binding in load_json('Key Bindings.json'):
			if binding['command'] == command:
				shortcut = binding['keys'][0]
				if PLATFORM == 'Mac':
					shortcut = self._insert_mac_key_symbols(shortcut)
				yield shortcut
	def _insert_mac_key_symbols(self, shortcut):
		keys = shortcut.split('+')
		return ''.join(self._KEY_SYMBOLS_MAC.get(key, key) for key in keys)

class Quit(ApplicationCommand):

	aliases = ('Quit', 'Exit')

	def __call__(self):
		sys.exit(0)

class InstallLicenseKey(DirectoryPaneCommand):
	def __call__(self):
		license_key = join(self.pane.get_path(), 'User.json')
		if not exists(license_key):
			show_alert(
				'Could not find license key file "User.json" in the current '
				'directory %s.' % self.pane.get_path()
			)
			return
		destination_directory = join(DATA_DIRECTORY, 'Local')
		copy(license_key, destination_directory)
		show_alert(
			"Thank you! To complete the registration, please restart fman. You "
			"should no longer see the annoying popup when it starts."
		)

class ZenOfFman(ApplicationCommand):
	def __call__(self):
		show_alert(
			"The Zen of fman\n"
			"https://fman.io/zen\n\n"
			"Looks matter\n"
			"Speed counts\n"
			"Extending must be easy\n"
			"Customisability is important\n"
			"But not at the expense of speed\n"
			"I/O is better asynchronous\n"
			"Updates should be transparent and continuous\n"
			"Development speed matters more than program size"
		)

class OpenDataDirectory(DirectoryPaneCommand):
	def __call__(self):
		self.pane.set_path(DATA_DIRECTORY)

class GoBack(DirectoryPaneCommand):
	def __call__(self):
		HistoryListener.INSTANCES[self.pane].go_back()

class GoForward(DirectoryPaneCommand):
	def __call__(self):
		HistoryListener.INSTANCES[self.pane].go_forward()

class HistoryListener(DirectoryPaneListener):

	INSTANCES = {}

	def __init__(self, *args, **kwargs):
		super().__init__(*args, **kwargs)
		self._history = History()
		self.INSTANCES[self.pane] = self
	def go_back(self):
		try:
			path = self._history.go_back()
		except ValueError:
			return
		self.pane.set_path(path)
	def go_forward(self):
		try:
			path = self._history.go_forward()
		except ValueError:
			return
		self.pane.set_path(path)
	def on_path_changed(self):
		self._history.path_changed(self.pane.get_path())

class History:
	def __init__(self):
		self._paths = []
		self._curr_path = -1
		self._ignore_next_path_change = False
	def go_back(self):
		if self._curr_path <= 0:
			raise ValueError()
		self._curr_path -= 1
		self._ignore_next_path_change = True
		return self._paths[self._curr_path]
	def go_forward(self):
		if self._curr_path >= len(self._paths) - 1:
			raise ValueError()
		self._curr_path += 1
		self._ignore_next_path_change = True
		return self._paths[self._curr_path]
	def path_changed(self, path):
		if self._ignore_next_path_change:
			self._ignore_next_path_change = False
			return
		self._curr_path += 1
		del self._paths[self._curr_path:]
		self._paths.append(path)

class InstallPlugin(ApplicationCommand):
	def __init__(self, *args, **kwargs):
		super().__init__(*args, **kwargs)
		self._plugin_repos = None
	def __call__(self, github_repo=None):
		if github_repo:
			with StatusMessage('Fetching GitHub repo %s...' % github_repo):
				repo = GitHubRepo.fetch(github_repo)
		else:
			if self._plugin_repos is None:
				with StatusMessage('Fetching available plugins...'):
					self._plugin_repos = find_repos(topics=['fman', 'plugin'])
			result = show_quicksearch(self._get_matching_repos)
			repo = result[1] if result else None
		if repo:
			with StatusMessage('Downloading %s...' % repo.name):
				try:
					ref = repo.get_latest_release()
				except LookupError as no_release_yet:
					ref = repo.get_latest_commit()
				zipball_contents = repo.download_zipball(ref)
			dest_dir = self._install_plugin(repo.name, zipball_contents)
			# Save some data in case we want to update the plugin later:
			self._record_plugin_installation(dest_dir, repo.url, ref)
			success = load_plugin(dest_dir)
			if success:
				show_alert('Plugin %r was successfully installed.' % repo.name)
	def _get_matching_repos(self, query):
		installed_plugins = set(
			basename(plugin_dir) for plugin_dir in _get_thirdparty_plugins()
		)
		for repo in self._plugin_repos:
			if repo.name in installed_plugins:
				continue
			match = contains_chars(repo.name.lower(), query.lower())
			if match or not query:
				hint = '%d ★' % repo.num_stars if repo.num_stars else ''
				yield QuicksearchItem(
					repo, repo.name, match, hint=hint,
					description=repo.description
				)
	def _install_plugin(self, name, zipball_contents):
		dest_dir = join(_THIRDPARTY_PLUGINS_DIR, name)
		if exists(dest_dir):
			raise ValueError('Plugin %s seems to already be installed.' % name)
		with ZipFile(BytesIO(zipball_contents), 'r') as zipfile:
			with TemporaryDirectory() as temp_dir:
				zipfile.extractall(temp_dir)
				dir_in_zip, = listdir(temp_dir)
				move(join(temp_dir, dir_in_zip), dest_dir)
		return dest_dir
	def _record_plugin_installation(self, plugin_dir, repo_url, ref):
		plugin_json = join(plugin_dir, 'Plugin.json')
		if exists(plugin_json):
			with open(plugin_json, 'r') as f:
				data = json.load(f)
		else:
			data = {}
		data['url'] = repo_url
		data['ref'] = ref
		with open(plugin_json, 'w') as f:
			json.dump(data, f)

_THIRDPARTY_PLUGINS_DIR = join(DATA_DIRECTORY, 'Plugins', 'Third-party')

def _get_thirdparty_plugins():
	return _list_plugins(_THIRDPARTY_PLUGINS_DIR)

def _list_plugins(dir_path):
	try:
		return list(filter(isdir, listdir_absolute(dir_path)))
	except FileNotFoundError:
		return []

class RemovePlugin(ApplicationCommand):

	aliases = ('Remove plugin', 'Uninstall plugin')

	def __init__(self, *args, **kwargs):
		super().__init__(*args, **kwargs)
		self._installed_plugins = None
	def __call__(self):
		self._installed_plugins = _get_thirdparty_plugins()
		if not self._installed_plugins:
			show_alert("You don't seem to have any plugins installed.")
		else:
			result = show_quicksearch(self._get_matching_plugins)
			if result:
				plugin_dir = result[1]
				if plugin_dir:
					unload_plugin(plugin_dir)
					rmtree(plugin_dir)
					show_alert(
						'Plugin %r was successfully removed.'
						% basename(plugin_dir)
					)
	def _get_matching_plugins(self, query):
		for plugin_dir in self._installed_plugins:
			plugin_name = basename(plugin_dir)
			match = contains_chars(plugin_name.lower(), query.lower())
			if match or not query:
				yield QuicksearchItem(plugin_dir, plugin_name, highlight=match)

class ReloadPlugins(ApplicationCommand):
	def __call__(self):
		plugins = _get_plugins()
		for plugin in reversed(plugins):
			try:
				unload_plugin(plugin)
			except KeyError as plugin_had_not_been_loaded:
				pass
		for plugin in plugins:
			load_plugin(plugin)
		num_plugins = len(plugins)
		plural = 's' if num_plugins > 1 else ''
		show_status_message(
			'Reloaded %d plugin%s.' % (num_plugins, plural), timeout_secs=2
		)

def _get_plugins():
	return _get_thirdparty_plugins() + _get_user_plugins()

def _get_user_plugins():
	result = []
	settings_plugin = ''
	for plugin_dir in _list_plugins(join(DATA_DIRECTORY, 'Plugins', 'User')):
		if basename(plugin_dir) == 'Settings':
			settings_plugin = plugin_dir
		else:
			result.append(plugin_dir)
	# According to the fman docs, the Settings plugin is loaded last:
	if settings_plugin:
		result.append(settings_plugin)
	return result

class ListPlugins(DirectoryPaneCommand):
	def __call__(self):
		result = show_quicksearch(self._get_matching_plugins)
		if result:
			plugin_dir = result[1]
			if plugin_dir:
				self.pane.set_path(plugin_dir)
	def _get_matching_plugins(self, query):
		result = []
		for plugin_dir in _get_thirdparty_plugins():
			plugin_name = basename(plugin_dir)
			match = contains_chars(plugin_name.lower(), query.lower())
			if match or not query:
				plugin_json = join(plugin_dir, 'Plugin.json')
				with open(plugin_json, 'r') as f:
					ref = json.load(f).get('ref', '')
				is_sha = len(ref) == 40
				if is_sha:
					ref = ref[:8]
				result.append(QuicksearchItem(
					plugin_dir, plugin_name, highlight=match, hint=ref
				))
		for plugin_dir in _get_user_plugins():
			plugin_name = basename(plugin_dir)
			match = contains_chars(plugin_name.lower(), query.lower())
			if match or not query:
				result.append(
					QuicksearchItem(plugin_dir, plugin_name, highlight=match)
				)
		return sorted(result, key=lambda qsi: qsi.title)

class StatusMessage:
	def __init__(self, message):
		self._message = message
	def __enter__(self):
		show_status_message(self._message)
	def __exit__(self, *_):
		clear_status_message()

if PLATFORM == 'Mac':
	class GetInfo(DirectoryPaneCommand):
		def __call__(self):
			files = self.get_chosen_files() or [self.pane.get_path()]
			self._run_applescript(
				'on run args\n'
				'	tell app "Finder"\n'
				'		activate\n'
				'		repeat with f in args\n'
				'			open information window of '
							'(posix file (contents of f) as alias)\n'
				'		end\n'
				'	end\n'
				'end\n',
				files
			)
		def _run_applescript(self, script, args=None):
			if args is None:
				args = []
			process = Popen(
				['osascript', '-'] + args, stdin=PIPE,
				stdout=DEVNULL, stderr=DEVNULL
			)
			process.communicate(script.encode('ascii'))