#!/usr/bin/env python
"""Dmenu launcher with history sorted by frecency.

Usage:
    dmenu-frecency [--read-apps]

Options:
    --read-apps         rereads all .desktop files.
"""

from docopt import docopt
import os
import sys
import xdg.BaseDirectory
from xdg.DesktopEntry import DesktopEntry
from subprocess import Popen, PIPE
from datetime import datetime
from collections import defaultdict
import pickle
import re
import gzip
import json
import tempfile
import shlex


CONFIG_DIR = xdg.BaseDirectory.save_config_path('dmenu-frecency')


# Python 2 compatibility
try:
    FileNotFoundError
except NameError:
    FileNotFoundError = IOError


class Application:
    def __init__(self, name, command_line, mtime=None, path=None, is_desktop=False):
        self.name = name
        self.path = path
        self.command_line = command_line
        self.is_desktop = is_desktop
        self.show_command = False
        if mtime is None:
            self.mtime = datetime.now()
        else:
            self.mtime = mtime

    def run(self):
        if os.fork() == 0:
            if self.path:
                os.chdir(self.path)
            os.execvp(os.path.expanduser(self.command_line[0]), self.command_line)

    def __lt__(self, other):
        return (self.is_desktop, self.mtime) < (other.is_desktop, other.mtime)

    def __eq__(self, other):
        return self.name == other.name

    def __hash__(self):
        return hash(self.name)

    def __str__(self):
        return "<Application: {} {!r}>".format(self.name, self.command_line)


STATE_VERSION = 4


def get_command(desktop_entry):
    tokens = []
    for token in shlex.split(desktop_entry.getExec()):
        if token == '%i':
            if desktop_entry.getIcon():
                tokens.append('--icon')
                tokens.append(desktop_entry.getIcon())
        else:
            i = 0
            newtok = ""
            nc = len(token)
            while i < nc:
                c = token[i]
                if c == '%' and i < nc - 1:
                    i += 1
                    code = token[i]
                    if code == 'c' and desktop_entry.getName():
                        newtok += desktop_entry.getName()
                    elif code == '%':
                        newtok += '%'
                else:
                    newtok += c
                i += 1
            if newtok:
                tokens.append(newtok)
    return tuple(tokens)


class LauncherState:
    STATE_FILENAME = os.path.join(CONFIG_DIR, 'state')

    def __init__(self, config):
        self.version = STATE_VERSION
        self.config = config
        self.find_apps()
        self.apps_generated_at = datetime.now()
        self.visits = defaultdict(list)
        self.visit_count = defaultdict(int)
        self.app_last_visit = None
        self.frecency_cache = {}

    def apps_by_frecency(self):
        app_last_visit = self.app_last_visit if self.config['preselect-last-visit'] else None
        if app_last_visit is not None:
            yield app_last_visit
        for app, frec in sorted(self.frecency_cache.items(), key=lambda x: (-x[1], x[0])):
            if app_last_visit is None or app_last_visit != app:
                yield app
        for app in self.sorted_apps:
            if app not in self.frecency_cache:
                if app_last_visit is None or app_last_visit != app:
                    yield app

    def add_visit(self, app):
        if not app.is_desktop and app.command_line in self.command_apps:
            app = self.command_apps[app.command_line]
            app.show_command = True
        try:
            self.sorted_apps.remove(app)
        except ValueError:
            pass # not in list
        vs = self.visits[app]
        now = datetime.now()
        vs.append(now)
        self.visit_count[app] += 1
        self.visits[app] = vs[-self.config['frecency-visits']:]
        self.app_last_visit = app if self.config['preselect-last-visit'] else None
    
    def update_frecencies(self):
        for app in self.visits.keys():
            self.frecency_cache[app] = self.frecency(app)

    def frecency(self, app):
        points = 0
        for v in self.visits[app]:
            days_ago = (datetime.now() - v).days
            if days_ago < 4:
                points += 100
            elif days_ago < 14:
                points += 70
            elif days_ago < 31:
                points += 50
            elif days_ago < 90:
                points += 30
            else:
                points += 10

        return int(self.visit_count[app] * points / len(self.visits[app]))

    @classmethod
    def load(cls, config):
        try:
            with gzip.open(cls.STATE_FILENAME, 'rb') as f:
                obj = pickle.load(f)
            version = getattr(obj, 'version', 0)
            if version < STATE_VERSION:
                new_obj = cls(config)
                if version <= 1:
                    for app, vs in obj.visits.items():
                        vc = obj.visit_count[app]
                        app.is_desktop = True
                        new_obj.visit_count[app] = vc
                        new_obj.visits[app] = vs
                new_obj.find_apps()
                new_obj.clean_cache()
                new_obj.update_frecencies()
                new_obj.config = config
                return new_obj
            else:
                obj.config = config
                return obj
        except FileNotFoundError:
            return cls(config)

    def save(self):
        with tempfile.NamedTemporaryFile(
                'wb',
                dir=os.path.dirname(self.STATE_FILENAME),
                delete=False) as tf:
            tempname = tf.name
            with gzip.open(tempname, 'wb') as gzipf:
                pickle.dump(self, gzipf)
        os.rename(tempname, self.STATE_FILENAME)

    def find_apps(self):
        self.apps = {}
        self.command_apps = {}
        if self.config['scan-desktop-files']:
            for applications_directory in xdg.BaseDirectory.load_data_paths("applications"):
                if os.path.exists(applications_directory):
                    for dirpath, dirnames, filenames in os.walk(applications_directory):
                        for f in filenames:
                            if f.endswith('.desktop'):
                                full_filename = os.path.join(dirpath, f)
                                self.add_desktop(full_filename)

        if self.config['scan-path']:
            for pathdir in os.environ["PATH"].split(os.pathsep):
                pathdir = pathdir.strip('"')
                if not os.path.isdir(pathdir):
                    continue

                for f in os.listdir(pathdir):
                    filename = os.path.join(pathdir, f)
                    if os.path.isfile(filename) and os.access(filename, os.X_OK):
                        app = Application(
                                name=f,
                                command_line=(f,),
                                mtime=datetime.fromtimestamp(os.path.getmtime(filename)))
                        self.add_app(app)
        self.sorted_apps = sorted(self.apps.values(), reverse=True)

    def add_desktop(self, filename):
        try:
            d = DesktopEntry(filename)
            if d.getHidden() or d.getNoDisplay() or d.getTerminal() or d.getType() != 'Application':
                return
            app = Application(
                    name=d.getName(),
                    command_line=get_command(d),
                    mtime=datetime.fromtimestamp(os.path.getmtime(filename)),
                    is_desktop=True)
            if d.getPath():
                app.path = d.getPath()
            self.add_app(app)
        except (xdg.Exceptions.ParsingError,
                xdg.Exceptions.DuplicateGroupError,
                xdg.Exceptions.DuplicateKeyError,
                ValueError) as e:
            sys.stderr.write("Failed to parse desktop file '{}': {!r}\n".format(filename, e))

    def add_app(self, app):
        if app.command_line not in self.command_apps:
            self.apps[app.name] = app
            self.command_apps[app.command_line] = app

    def clean_cache(self):
        for app in list(self.frecency_cache.keys()):
            if app.is_desktop and app.name not in self.apps:
                del self.frecency_cache[app]
        if self.app_last_visit is not None and self.app_last_visit.name not in self.apps:
            self.app_last_visit = None


class DmenuFrecency:
    CONFIG_FILENAME = os.path.join(CONFIG_DIR, 'config.json')
    DEFAULT_CONFIG = {
        'dmenu': 'dmenu',
        'dmenu-args': ['-i'],
        'cache-days': 1,
        'frecency-visits': 10,
        'preselect-last-visit': False,
        'scan-desktop-files': True,
        'scan-path': False,
    }
    NAME_WITH_COMMAND = re.compile(r"(.+) \([^()]+\)")

    def __init__(self, arguments):
        self.read_apps = arguments['--read-apps']
        self.load_config()
        self.state = LauncherState.load(self.config)
        assert self.state, "Failed to load state."

    def load_config(self):
        self.config = {}
        self.config.update(self.DEFAULT_CONFIG)
        try:
            with open(self.CONFIG_FILENAME, 'r') as f:
                self.config.update(json.load(f))
        except FileNotFoundError:
            with open(self.CONFIG_FILENAME, 'w') as f:
                json.dump(self.config, f, sort_keys=True, indent=4)
                f.write('\n')

    def main(self):
        if self.read_apps:
            self.state.find_apps()
            self.state.clean_cache()
            self.state.save()
            return

        dmenu = Popen([self.config['dmenu']] + self.config['dmenu-args'], stdin=PIPE, stdout=PIPE)
        for app in self.state.apps_by_frecency():
            app_name = app.name.encode('utf-8')
            dmenu.stdin.write(app_name)
            if app.show_command and app.name != app.command_line[0]:
                dmenu.stdin.write(" ({})".format(' '.join(app.command_line)).encode('utf-8'))
            dmenu.stdin.write(b'\n')
        stdout, stderr = dmenu.communicate()
        result = stdout.decode('utf-8').strip()

        if not result:
            return

        if result in self.state.apps:
            app = self.state.apps[result]
        else:
            m = self.NAME_WITH_COMMAND.match(result)
            if m and m.group(1) in self.state.apps:
                app = self.state.apps[m.group(1)]
            else:
                app = Application(
                        name=result,
                        command_line=tuple(shlex.split(result)))

        app.run()

        self.state.add_visit(app)
        self.state.update_frecencies()

        if (datetime.now() - self.state.apps_generated_at).days >= self.config['cache-days']:
            self.state.find_apps()
            self.state.clean_cache()
        self.state.save()


if __name__ == '__main__':
    arguments = docopt(__doc__, version="0.1")
    DmenuFrecency(arguments).main()