# -*- coding: utf-8 -*- vim:sw=4:sts=4:et:ai:si:sta:fenc=utf-8

__all__ = ["TODO", "TODOFile", "TiddlyTODOFile", "TODOs"]

try: True, False
except NameError: True, False = 1, 0

import os, re, types
from os import path

from base import scriptdir
from dates import *
from files import TextFile
from config import ShConfigFile
from TiddlyWiki import *

_marker = []

def is_seq(value):
    return type(value) is types.ListType or type(value) is types.TupleType

def quote_nl(text):
    if text is not None:
        text = text.replace("\\", r"\\")
        text = text.replace("\n", r"\n")
    return text

def unquote_nl(text):
    if text is not None:
        text = text.replace(r"\n", "\n")
        text = text.replace(r"\\", "\\")
    return text

class TODO:
    def __init__(self, title=None, desc=None, contexts=None, projects=None, tags=None, todonum=None, todoline=None):
        self.todonum = todonum
        self.title = title
        self.desc = desc
        if title is not None:
            self.text = quote_nl(title + (desc and "\n" + desc or ""))
        self.contexts = contexts or ()
        self.projects = projects or ()
        self.tags = tags or {}
        if todoline is not None: self.parse(todoline)

    RE_CONTEXT = re.compile(r'@([^ ]+)\s+')
    RE_PROJECT = re.compile(r'\+([^ ]+)\s+')
    RE_TAG = re.compile(r'([^= ]+)=([^ ]+)\s+')

    def parse(self, line):
        contexts = []
        projects = []
        tags = {}
        title = ''
        desc = None

        while True:
            mo = self.RE_CONTEXT.match(line)
            if mo is not None:
                contexts.append(mo.group(1))
                line = line[mo.end(0):]
                continue
            mo = self.RE_PROJECT.match(line)
            if mo is not None:
                projects.append(mo.group(1))
                line = line[mo.end(0):]
                continue
            mo = self.RE_TAG.match(line)
            if mo is not None:
                tags[mo.group(1)] = mo.group(2)
                line = line[mo.end(0):]
                continue
            break

        text = line
        pos = text.find("\\n")
        if pos == -1:
            title = text
            desc = None
        else:
            title = text[:pos]
            desc = text[pos+2:]

        self.text = text
        self.title = unquote_nl(title)
        self.desc = unquote_nl(desc)
        self.contexts = contexts
        self.projects = projects
        self.tags = tags

    def to_line(self):
        if self.title is None: return None

        tags = []
        if self.contexts is not None: tags.extend(map(lambda c: "@" + c, self.contexts))
        if self.projects is not None: tags.extend(map(lambda p: "+" + p, self.projects))
        if self.tags is not None: tags.extend(map(lambda (k, v): "%s=%s" % (k, v), self.tags.items()))

        line = ""
        if tags: line = line + " ".join(tags) + " "
        line = line + quote_nl(self.title)
        if self.desc is not None: line = line + "\\n" + quote_nl(self.desc)

        return line

    def to_string(self):
        id = self.tags.get("id", None)
        if self.is_done(): string = "TODO[%s], fait le %s:" % (id, self.tags["done"])
        else: string = "TODO[%s]:" % id

        tags = []
        if self.contexts is not None: tags.extend(map(lambda c: "@" + c, self.contexts))
        if self.projects is not None: tags.extend(map(lambda p: "+" + p, self.projects))
        for tag in self.tags:
            if tag != "id" and tag != "done" and not tag.endswith("_purged"):
                tags.append("%s=%s" % (tag, self.tags[tag]))
        if tags: string = string + " " + " ".join(tags)
        string = string + "\n"

        string = string + self.title
        if self.desc: string = string + "\n\n" + self.desc

        return string

    def __repr__(self):
        return "TODO(%s)" % self.title

    # Accès aux tags
    def __len__(self):
        return len(self.tags)
    def __getitem__(self, key, default=_marker):
        if default is _marker: return self.tags[key]
        else: return self.tags.get(key, default)
    get = __getitem__
    def __setitem__(self, key, value):
        self.tags[key] = value
    def __delitem__(self, key):
        del self.tags[key]
    def __contains__(self, key):
        return key in self.tags
    def has_key(self, key):
        return self.tags.has_key(key)
    def keys(self):
        return self.tags.keys()
    def values(self):
        return self.tags.values()
    def items(self):
        return self.tags.items()

    def is_done(self):
        return self.has_key("done")
    def is_project_purged(self, project):
        return self.has_key("%s_purged" % project)
    def set_project_purged(self, project, date=None):
        if date is None: date = datef()
        self.tags["%s_purged" % project] = date

    def has_projects(self):
        return len(self.projects) != 0
    def is_all_purged(self):
        for project in self.projects:
            if not self.is_project_purged(project): return False
        return True

    def is_project_released(self, project):
        return self.has_key("%s_released" % project)
    def set_project_released(self, project, date=None):
        if date is None: date = datef()
        self.tags["%s_released" % project] = date


    # Filtre
    def filter(self, filters=None, **kw):
        if filters is None: filters = kw
        else: filters.update(kw)
        for filter, value in filters.items():
            if filter == "project":
                if is_seq(value):
                    projects = value
                    for project in projects:
                        if project not in self.projects: return False
                else:
                    project = value
                    if project not in self.projects: return False
            elif filter == "context":
                if is_seq(value):
                    contexts = value
                    for context in contexts:
                        if context not in self.contexts: return False
                else:
                    context = value
                    if context not in self.contexts: return False
            elif filter == "todonum":
                if self.todonum != value: return False
            else:
                if value is None:
                    if filter in self.tags: return False
                elif value == '*':
                    if filter not in self.tags: return False
                else:
                    if filter not in self.tags or self.tags[filter] != value: return False
        return True

class TODOFile(TextFile):
    def __init__(self, file, DONE_file=False, raise_exception=False):
        TextFile.__init__(self)

        TODO_txt = DONE_file and 'DONE.txt' or 'TODO.txt'
        todo_txt = DONE_file and 'done.txt' or 'todo.txt'
        if path.isdir(file):
            # on a donné le nom d'un répertoire. y chercher un fichier nommé
            # TODO.txt ou todo.txt
            dir = file
            todofile = path.join(dir, TODO_txt)
            if not path.exists(todofile):
                todofile2 = path.join(dir, todo_txt)
                if path.exists(todofile2): todofile = todofile2
        else:
            # on a donné un nom de fichier
            todofile = file
        self.load(todofile, raise_exception=raise_exception)

    def __update_todonum(self):
        todonum = 1
        for todo in self.todos:
            todo.todonum = todonum
            todonum = todonum + 1

    def readlines(self, raise_exception=True):
        TextFile.readlines(self, raise_exception=raise_exception)

        self.todos = []
        for line in self.lines:
            self.todos.append(TODO(todoline=line))
        self.__update_todonum()

    def writelines(self):
        """Ecrire les TODOS et les DONEs dans le fichier
        """
        lines = []
        for todo in self.todos:
            lines.append(todo.to_line())

        self.lines = lines
        TextFile.writelines(self)
    save = writelines

    def __len__(self):
        return len(self.todos)
    def __getitem__(self, index):
        return self.todos[index]

    def list(self, filters=None, **kw):
        if type(filters) is types.IntType: return [self.todos[filters]]
        elif isinstance(filters, TODO):
            todo = filters
            if todo in self.todos: return [todo]
            else:
                id = todo.get("id", None)
                if id is None: return []
                filters = {"id": id}

        if filters is None: filters = kw
        else: filters.update(kw)
        return filter(lambda t: t.filter(filters), self.todos)

    def get(self, filters=None, **kw):
        todos = self.list(filters, **kw)
        if len(todos) == 0: return None
        elif len(todos) > 1: raise ValueError("Plusieurs tâches correspondent aux critères")
        else: return todos[0]

    def remove(self, filters=None, **kw):
        todos = self.list(filters, **kw)
        for todo in todos:
            self.todos.remove(todo)
        self.__update_todonum()

    def put(self, todo):
        if not isinstance(todo, TODO): todo = TODO(todoline=todo)
        self.remove(todo)
        self.todos.append(todo)
        self.__update_todonum()
        return todo

    def get_file(self):
        return self.pf

    def can_release(self):
        return False

    def release(self, released, release_date=None, version=None, changes=None):
        pass

class TiddlyTODOFile:
    RE_DATE = re.compile(r'(\d\d)/(\d\d)/(\d\d\d\d)$')
    RE_ID = re.compile(r'(\d\d)/(\d\d)/(\d\d\d\d)-(\d+)$')

    def __init__(self, file, project):
        if path.isdir(file):
            file = path.join(file, "TODO.html")
        self.tw = TiddlyWiki(file)
        self.project = project

    def get_tiddler(self, id):
        mo = self.RE_ID.match(id)
        if mo is not None: tiddler = u"TODO-%s%s%s-%s" % (mo.group(3), mo.group(2), mo.group(1), mo.group(4))
        else: tiddler = ensure_unicode(id)
        return tiddler

    def get(self, id):
        return tw[self.get_tiddler(id)]

    def remove(self, todo_or_id):
        if isinstance(todo_or_id, TODO):
            todo = todo_or_id
            id = todo.get("id", None)
        else:
            id = todo_or_id
        if id is None: raise ValueError("Il n'est possible de mettre à jour qu'un TODO avec un id")

        self.tw.remove(self.get_tiddler(id))

    def put(self, todo):
        id = todo.get("id", None)
        if id is None: raise ValueError("Il n'est possible de mettre à jour qu'un TODO avec un id")

        tiddler = self.get_tiddler(id)
        modifier = None
        modified = None

        twtext = todo.text
        twtags = map(lambda c: "@" + c, todo.contexts) + map(lambda p: "+" + p, todo.projects)
        if "date" in todo.tags:
            mo = self.RE_DATE.match(todo.tags["date"])
            if mo is not None:
                twtags.append("TODO-%s%s%s" % (mo.group(3), mo.group(2), mo.group(1)))
            else:
                twtags.append("TODO-%s" % todo.tags["date"])
        if todo.is_done():
            donetag = todo.is_project_released(self.project) and "RELEASED" or "DONE"
            twtags.append(donetag)
            twtext = twtext + "\n\nFAIT le %s" % todo.tags["done"]
        else:
            twtags.append("TODO")

        self.tw.put(Tiddler(tiddler, twtext, " ".join(twtags), modifier, modified))

    def save(self):
        self.tw.save()

    def get_file(self):
        return self.tw.twpf

    def can_release(self):
        return True

    def release(self, released, release_date=None, version=None, changes=None):
        release_name = datef("Release-%Y%m%d", release_date)
        tiddler = release_name
        count = 0
        for tiddler in self.tw:
            t = self.tw[tiddler]
            if t.tags_contains(release_name):
                count = count + 1
        if count == 0: tiddler = release_name
        else: tiddler = "%s-%i" % (release_name, count)

        twtext = u"release du %s\nversion: %s\n" % (datef(FR_DATEF, release_date), version)
        if changes:
            twtext = twtext + "\n" + ensure_unicode(changes)
        if released:
            twtext = twtext + "\n"
            for done in released:
                twtext = twtext + "* <<todoLink %s>>\n" % self.get_tiddler(done.get("id", None))
        twtags = [release_name, "release"]

        self.tw.put(Tiddler(tiddler, twtext, " ".join(twtags)))

class TODOs:
    DEFAULTS = {"scriptdir": scriptdir}

    def __init__(self, todorcfile=None):
        if todorcfile is None:
            todorcfile = path.join(os.environ["HOME"], ".utools/todorc")
        if path.exists(todorcfile):
            todorc = ShConfigFile(todorcfile, defaults=self.DEFAULTS)
        else:
            todorc = self.DEFAULTS
        tododir = todorc.get("tododir", path.split(todorcfile)[0])
        todofile = todorc.get("todofile", path.join(tododir, "TODO.txt"))
        donefile = todorc.get("donefile", path.join(tododir, "DONE.txt"))
        projdirs = todorc.get("projdirs", "")

        self.tododir = tododir
        self.todofile = todofile
        self.donefile = donefile
        self.projdirs = filter(None, projdirs.split(":"))

        self.todos = TODOFile(self.todofile)
        self.dones = TODOFile(self.donefile)

    def get_projdir(self, project):
        for projdir in self.projdirs:
            dir = path.join(projdir, project)
            if path.isdir(dir):
                return dir
        return None

    def __len__(self):
        return len(self.todos)
    def __getitem__(self, index):
        return self.todos[index]

    def __str__(self):
        lines = []
        for todo in self.todos:
            lines.append("%i: %s" % (todo.todonum, todo.to_string()))
        return "\n".join(lines)

    def p(self):
        print self

    ## Interface utilisateur
    def list(self, filters=None, **kw):
        return self.todos.list(filters, **kw)

    def get(self, filters=None, **kw):
        todos = self.list(filters, **kw)
        if len(todos) == 0: return None
        elif len(todos) > 1: raise ValueError("Plusieurs tâches correspondent aux critères")
        else: return todos[0]

    def __put_in_projects(self, todo, DONE_file=False):
        for project in todo.projects:
            projdir = self.get_projdir(project)
            if projdir is not None:
                twhtml = path.join(projdir, "TODO.html")
                if path.exists(twhtml):
                    todofile = TiddlyTODOFile(twhtml, project)
                else:
                    if DONE_file:
                        todofile = TODOFile(projdir, DONE_file=False)
                        todofile.remove(todo)
                        todofile.save()
                    todofile = TODOFile(projdir, DONE_file=DONE_file)
                todofile.put(todo)
                todofile.save()

    def put(self, todo):
        todo = self.todos.put(todo)
        self.__put_in_projects(todo)
        self.todos.save()

    def add(self, title=None, desc=None, contexts=None, projects=None, tags=None, set_done=False, todoline=None):
        tags = tags or {}
        # Calculer la date
        date = datef()
        if not tags.has_key("date"): tags["date"] = date
        # Faut-il finir la tâche tout de suite?
        if set_done and not tags.has_key("done"):
            tags["done"] = date
        # Calculer l'id
        if not tags.has_key("id"):
            count = 1
            for todo in self.todos:
                if todo.has_key("id") and todo["id"].startswith(date + "-"): count = count + 1
                elif todo.has_key("date") and todo["date"] == date: count = count + 1
            for todo in self.dones:
                if todo.has_key("id") and todo["id"].startswith(date + "-"): count = count + 1
                elif todo.has_key("date") and todo["date"] == date: count = count + 1
            tags["id"] = "%s-%i" % (date, count)

        if todoline is None:
            todo = TODO(title, desc, contexts, projects, tags)
        else:
            todo = TODO(todoline=todoline)
            todo.tags.update(tags)

        self.put(todo)

    def do(self, filters=None, **kw):
        todos = self.list(filters, **kw)
        date = datef()
        modified = False
        for todo in todos:
            if not todo.is_done():
                todo["done"] = date
                if todo.has_key("pri"): del todo["pri"]
                modified = True
                self.__put_in_projects(todo)
        if modified: self.todos.save()

    def __remove_from_projects(self, todo):
        for project in todo.projects:
            projdir = self.get_projdir(project)
            if projdir is not None:
                twhtml = path.join(projdir, "TODO.html")
                if path.exists(twhtml): todofile = TiddlyTODOFile(twhtml, project)
                else: todofile = TODOFile(projdir)
                todofile.remove(todo)
                todofile.save()

    def remove(self, filters=None, **kw):
        todos = self.list(filters, **kw)
        modified = False
        for todo in todos:
            self.todos.remove(todo)
            modified = True
            self.__remove_from_projects(todo)
        if modified: self.todos.save()

    def purge(self, project=None):
        modified = False
        if project is None:
            # purger les tâches qui ne sont assignées à aucun projet
            todos = self.todos.list(done='*')
            dones = []
            for todo in todos:
                if not todo.has_projects():
                    dones.append(todo)
            for done in dones:
                self.dones.put(done)
                self.todos.remove(done)
                modified = True
                self.__put_in_projects(done, DONE_file=True)
        else:
            # D'abord calculer les tâches qui sont elligibles à la purge
            todos = self.todos.list(project=project, done='*')
            # Puis les marquer comme purgées
            dones = []
            purgeable = []
            for todo in todos:
                if not todo.is_project_purged(project):
                    todo.set_project_purged(project)
                    modified = True
                if todo.is_all_purged(): purgeable.append(todo)
                else: dones.append(todo)
            for done in dones:
                self.__put_in_projects(done)
            for done in purgeable:
                self.dones.put(done)
                self.todos.remove(done)
                modified = True
                self.__put_in_projects(done, DONE_file=True)

        if modified:
            self.todos.save()
            self.dones.save()

    def list_releaseable(self, project):
        """Lister les tâches qui sont concernées par self.release()
        """
        releaseable = []
        # chercher dans self.todos
        purged = self.todos.list(project=project, done='*')
        purged = filter(lambda t: t.is_project_purged(project), purged)
        for todo in purged:
            if not todo.is_project_released(project):
                releaseable.append(todo)
        # puis dans self.dones
        purged = self.dones.list(project=project, done='*')
        purged = filter(lambda t: t.is_project_purged(project), purged)
        for todo in purged:
            if not todo.is_project_released(project):
                releaseable.append(todo)
        return releaseable

    def mark_released(self, project):
        """Marquer les tâches du projet comme released
        Retourner le TODOFile ou TiddlyTODOFile qui correspond au projet
        """
        #released = []
        # D'abord calculer les tâches qui sont elligibles à la release, puis les marquer comme released
        # d'abord pour self.todos
        purged = self.todos.list(project=project, done='*')
        purged = filter(lambda t: t.is_project_purged(project), purged)
        modified = False
        for todo in purged:
            if not todo.is_project_released(project):
                todo.set_project_released(project)
                modified = True
                #released.append(todo)
                # Mettre à jour les projets
                self.__put_in_projects(todo)
        if modified: self.todos.save()
        # puis pour self.dones
        purged = self.dones.list(project=project, done='*')
        purged = filter(lambda t: t.is_project_purged(project), purged)
        modified = False
        for todo in purged:
            if not todo.is_project_released(project):
                todo.set_project_released(project)
                modified = True
                #released.append(todo)
                # Mettre à jour les projets
                self.__put_in_projects(todo, DONE_file=True)
        if modified: self.dones.save()

    def get_todofile(self, project):
        """Obtenir le TODOFile ou le TiddlyTODOFile correspondant au projet, ou
        None si le projet est invalide
        """
        projdir = self.get_projdir(project)
        if projdir is not None:
            twhtml = path.join(projdir, "TODO.html")
            if path.exists(twhtml): todofile = TiddlyTODOFile(twhtml, project)
            else: todofile = TODOFile(projdir)
            return todofile
        return None

    class Report:
        # True si une tâche avec priorité a été rencontrée
        pri = False
        # Nom
        name = None
        # True si une tâche valide a été rencontrée
        valid = False
        # statistiques pour les tâches valides
        valid_open = 0
        valid_closed = 0
        valid_total = 0
        valid_open_perc = 0
        valid_closed_perc = 0
        # statistiques pour les tâches purgées
        purged_open = 0
        purged_closed = 0
        purged_total = 0
        purged_open_perc = 0
        purged_closed_perc = 0
        # statistiques pour toutes les tâches
        open = 0
        closed = 0
        total = 0
        open_perc = 0
        closed_perc = 0

        def __init__(self, name):
            self.name = name

        def update_perc(self):
            if self.valid_total > 0:
                self.valid_open_perc = self.valid_open * 100 / self.valid_total
                self.valid_closed_perc = self.valid_closed * 100 / self.valid_total
            if self.purged_total > 0:
                self.purged_open_perc = self.purged_open * 100 / self.purged_total
                self.purged_closed_perc = self.purged_closed * 100 / self.purged_total
            if self.total > 0:
                self.open_perc = self.open * 100 / self.total
                self.closed_perc = self.closed * 100 / self.total
        def update_open(self, valid=True):
            if valid:
                self.valid_open = self.valid_open + 1
                self.valid_total = self.valid_total + 1
            else:
                self.purged_open = self.purged_open + 1
                self.purged_total = self.purged_total + 1
            self.open = self.open + 1
            self.total = self.total + 1
            self.update_perc()
        def update_closed(self, valid=True):
            if valid:
                self.valid_closed = self.valid_closed + 1
                self.valid_total = self.valid_total + 1
            else:
                self.purged_closed = self.purged_closed + 1
                self.purged_total = self.purged_total + 1
            self.closed = self.closed + 1
            self.total = self.total + 1
            self.update_perc()
        def update(self, todo, valid=False):
            if todo.is_done(): self.update_closed(valid)
            else: self.update_open(valid)
            self.valid = self.valid or valid

        def valid_closed_bar(self, max=20):
            n = self.valid_closed_perc * max / 100
            return "=" * n + (max - n) * " "

        def purged_closed_bar(self, max=20):
            n = self.purged_closed_perc * max / 100
            return "=" * n + (max - n) * " "

        def closed_bar(self, max=20):
            n = self.closed_perc * max / 100
            return "=" * n + (max - n) * " "

    def print_reports(self, title, reports):
        if not reports: return

        print title
        print "-" * len(title)
        print

        def cmp_report(r0, r1):
            r = cmp(r0.pri, r1.pri)
            if r != 0: return r
            r = -cmp(r0.valid_closed_perc, r1.valid_closed_perc)
            if r != 0: return r
            return cmp(r0.name, r1.name)
        reports.sort(cmp_report)

        maxnamelen = reduce(max, map(lambda r: len(r.name), reports))
        for report in reports:
            print "%s %3i%% [%s] %*s %2i / %i tâche(s)" % (
                report.pri and '*' or ' ',
                report.valid_closed_perc,
                report.valid_closed_bar(),
                maxnamelen, report.name,
                report.valid_closed,
                report.valid_total
                )
        print

    def do_report(self):
        """Afficher un rapport sur les tâches
        """
        projects = {}
        contexts = {}
        def update_stats(todo, valid, projects=projects, contexts=contexts):
            for project in todo.projects:
                report = projects.setdefault(project, self.Report("+%s" % project))
                report.update(todo, valid)
            for context in todo.contexts:
                report = contexts.setdefault(context, self.Report("@%s" % context))
                report.update(todo, valid)
        for todo in self.todos: update_stats(todo, True)
        for done in self.dones: update_stats(done, False)

        self.print_reports("Projets avec des tâches", filter(lambda p: p.valid, projects.values()))
        self.print_reports("Contextes avec des tâches", filter(lambda p: p.valid, contexts.values()))

        purged = filter(lambda p: not p.valid, projects.values())
        if purged:
            title = "Projets terminés (pas de tâches)"
            print title
            print "-" * len(title)
            print

            maxnamelen = reduce(max, map(lambda r: len(r.name), purged))
            for report in purged:
                print " %*s: %2i tâche(s)" % (
                    maxnamelen, report.name,
                    report.purged_total
                    )