#!/usr/bin/python

import sys
import pygtk, gobject
pygtk.require('2.0')
import gtk

def normalize(path):
    import os.path
    return os.path.normpath(os.path.abspath(path))

class Patch:
    def __init__(self, repo):
        fields = ['repo_id',
                  'name',
                  'author',
                  'hash',
                  'timestamp',
                  'status',
                  'content']
        for f in fields:
            self.__dict__[f] = None
        self.repo = repo

class Storage:
    def __init__(self):
        pass

    def getRepos(self):
        raise NotImplemented

    def getPatches(self, repo_id):
        raise NotImplemented

    def removeRepo(self, path):
        raise NotImplemented

    def addPatch(self, patch):
        raise NotImplemented

    def patchStatus(self, repo_path, hash, status):
        raise NotImplemented

    def getStatusValues(self):
        raise NotImplemented

    def setStatusValues(self, values):
        raise NotImplemented

    def close(self):
        raise NotImplemented

    def sync(self, repo):
        raise NotImplemented

class SQLiteStorage(Storage):
    tables = [
        """
        CREATE TABLE patches (
          repo_path TEXT,
          name TEXT,
          hash TEXT,
          status TEXT,
          timestamp TEXT,
          author TEXT,
          content TEXT
        )
        """,

        """
        CREATE TABLE status_values (
          value TEXT,
          UNIQUE (value)
        )
        """,
        ]

    indexes = [
        "CREATE INDEX patches_repo_path_idx ON patches(repo_path)",
        "CREATE INDEX patches_hash_idx ON patches(hash)",
        "CREATE INDEX patches_timestamp_idx ON patches(timestamp)",
        ]

    def __init__(self, path):
        from pysqlite2 import dbapi2 as sqlite
        self.db = sqlite.connect(path)
        self._init()

    def close(self):
        self.db.close()

    def _init(self):
        for t in self.tables:
            try:
                self.db.cursor().execute(t)
            except:
                pass

        for idx in self.indexes:
            try:
                self.db.cursor().execute(idx)
            except:
                pass

    def query(self, sql, data=[]):
        c = self.db.cursor()
        c.execute(sql, data)
        c.connection.commit()
        _results = c.fetchall()
        results = []
        try:
            desc = list(enumerate(c.description))
        except TypeError:
            return []
        for r in _results:
            d = {}
            for idx, col in desc:
                d[col[0]] = r[idx]
            results.append(d)
        return results

    def getStatusValues(self):
        return [row['value'] for row in self.query("SELECT value FROM status_values")]

    def setStatusValues(self, values):
        self.query("DELETE FROM status_values")
        for v in values:
            self.query("INSERT INTO status_values (value) VALUES (?)", [v])

    def getRepos(self):
        result = self.query("SELECT DISTINCT repo_path FROM patches")
        return [row['repo_path'] for row in result]

    def removeRepo(self, path):
        self.query("DELETE FROM patches WHERE repo_path = ?",
                   [path])

    def sync(self, repo):
        # Make sure that each patch is in storage, and make sure
        # storage values are loaded into memory.
        db_hashes = {}

        for patch in repo.patches:
            result = self.query("SELECT * FROM patches WHERE repo_path = ? AND " +
                       "hash = ?", [repo.path, patch.hash])
            db_hashes[patch.hash] = 1
            if result:
                # Set the in-memory values from storage.
                db_patch = result[0]
                patch.__dict__.update(db_patch)
            else:
                # Save the patch to storage.
                self.query("INSERT INTO patches (repo_path, name, hash, status, " +
                           "timestamp, author, content) VALUES (?, ?, ?, ?, ?, ?, ?)",
                           [repo.path, patch.name, patch.hash, patch.status,
                            patch.timestamp, patch.author, patch.content])

        # Get all database patches and remove them if they're no
        # longer in the local repository.
        result = self.query("SELECT hash FROM patches WHERE repo_path = ?", [repo.path])
        for row in result:
            hash, = row
            try:
                db_hashes[hash]
            except KeyError:
                self.query("DELETE FROM patches WHERE hash = ? AND repo_path = ?",
                           [hash, repo.path])

    def patchStatus(self, repo_path, hash, status):
        self.query("UPDATE patches SET status = ? WHERE repo_path = ? AND hash = ?",
                   [status, repo_path, hash])

    def savePatchContent(self, patch):
        self.query("UPDATE patches SET content = ? WHERE " +
                   "hash = ? AND repo_path = ?",
                   [patch.content, patch.hash, patch.repo.path])

    def getPatchContent(self, patch_hash, repo_path):
        result = self.query("SELECT content FROM patches WHERE " +
                            "hash = ? AND repo_path = ?",
                            [patch_hash, repo_path])

        if result:
            return result[0]['content']

class DarcsRepo:
    def __init__(self, path, darcs_command='/usr/bin/darcs'):
        import elementtree.ElementTree as ET
        self.parser = ET
        self.path = normalize(path)
        self.patches = []
        self.darcs_command = darcs_command
        self.scan()

    def scan(self):
        import subprocess

        cmd = "%s changes --xml-output --repo=%s" % (self.darcs_command,
                                                     self.path)

        f = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE,
                             stderr=subprocess.PIPE).stdout
        xml = f.read()
        f.close()

        _patches = []
        et = self.parser.fromstring(xml)
        for patch in et.findall('patch'):
            p = Patch(self)
            p.hash = patch.attrib['hash']
            p.author = patch.attrib['author']
            p.timestamp = patch.attrib['date']
            p.name = patch.findall('name')[0].text
            p.repo_path = self.path
            _patches.append((patch.attrib['date'], p))

        self.patches = list(reversed([p for date, p in sorted(_patches)]))

    def getPatch(self, patch_hash):
        if patch_hash.endswith('.gz'):
            patch_hash = patch_hash[:-3]

        import subprocess

        cmd = ("%s diff --diff-opts=\"-C 8\" " + \
               "--repo=%s --match=\"hash %s\"") % (self.darcs_command,
                                                   self.path,
                                                   patch_hash)

        p = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE,
                             stderr=subprocess.PIPE)
        patch = p.stdout.read()
        p.stdout.close()

        return patch

class PatchList(gtk.TreeView):
    def __init__(self, storage):
        self.store = gtk.ListStore(gobject.TYPE_STRING,
                                   gobject.TYPE_STRING,
                                   gobject.TYPE_STRING,
                                   gobject.TYPE_STRING)
        self.storage = storage
        gtk.TreeView.__init__(self, self.store)
        self.set_rules_hint(True)

        self.renderers = {}
        self.cols = {}
        self.repo = None

        self.renderers['patch_name'] = gtk.CellRendererText()
        self.cols['patch_name'] = gtk.TreeViewColumn('Patch Name',
                                                     self.renderers['patch_name'],
                                                     text=0)

        self.renderers['author'] = gtk.CellRendererText()
        self.cols['author'] = gtk.TreeViewColumn('Author',
                                                 self.renderers['author'],
                                                 text=1)

        self.renderers['timestamp'] = gtk.CellRendererText()
        self.cols['timestamp'] = gtk.TreeViewColumn('Timestamp',
                                                    self.renderers['timestamp'],
                                                    text=2)

        c_model = gtk.ListStore(str)
        cell_renderer = gtk.CellRendererCombo()
        cell_renderer.set_property('model', c_model)
        cell_renderer.set_property('text-column', 0)
        cell_renderer.set_property('editable', True)
        cell_renderer.set_property('has-entry', False)
        self.cols['status'] = gtk.TreeViewColumn('Status', cell_renderer, text=3)

        cell_renderer.connect('editing-started', self.populate_status, self.store)
        cell_renderer.connect('edited', self.edit_finished, self.store)

        col_order = ['patch_name', 'author', 'timestamp', 'status']

        for colname in col_order:
            c = self.cols[colname]
            c.set_resizable(True)
            self.append_column(c)

    def edit_finished(self, cell, path, value, model):
        iter = model.get_iter(path)
        model.set(iter, 3, value)
        p = self.repo.patches[int(path)]
        p.status = value
        self.storage.patchStatus(self.repo.path, p.hash, p.status)

    def populate_status(self, cell, editable, path, model):
        iter = model.get_iter(path)
        items = [None] + self.storage.getStatusValues()
        m = cell.get_property('model')
        m.clear()
        items.sort()
        for s in items:
            m.append([s])

    def setRepo(self, repo):
        self.store.clear()

        if not repo:
            return

        self.repo = repo
        for p in repo.patches:
            self.store.append([p.name, p.author, p.timestamp, p.status])

class DiffViewer(gtk.ScrolledWindow):
    def __init__(self):
        gtk.ScrolledWindow.__init__(self)
        self.view = gtk.TextView()
        self.add(self.view)

        buf = self.view.get_buffer()

        colormap = self.get_colormap()
        red = colormap.alloc_color(0xffff, 0, 0)
        red_tag = buf.create_tag("remove",
                                 foreground_gdk=red)

        blue = colormap.alloc_color(0, 0, 0xffff)
        blue_tag = buf.create_tag("add",
                                  foreground_gdk=blue)

        green = colormap.alloc_color(0xe1ff, 0x94ff, 0)
        green_tag = buf.create_tag("replace",
                                   foreground_gdk=green)

        fixed_font = buf.create_tag("fixed_font",
                                    font="Monospace",
                                    editable=False)

    def setText(self, text):
        b = self.view.get_buffer()
        b.set_text(text)

    def clear(self):
        self.view.get_buffer().set_text('')

class ListDialog(gtk.Dialog):
    def __init__(self, title, item_name, things=[], sanitize_cb=None,
                 force_unique=False):
        self.things = things
        self.item_name = item_name
        self.force_unique = force_unique
        gtk.Dialog.__init__(self, title, flags=gtk.DIALOG_MODAL,
                            buttons=(gtk.STOCK_OK, gtk.RESPONSE_OK,
                                     gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL))

        vbox = self.vbox
        self.sanitize_cb = sanitize_cb
        sw = gtk.ScrolledWindow()
        sw.set_shadow_type(gtk.SHADOW_ETCHED_IN)
        sw.set_policy(gtk.POLICY_NEVER, gtk.POLICY_AUTOMATIC)

        __lstore = gtk.ListStore(gobject.TYPE_STRING)
        for thing in things:
            iter = __lstore.append()
            __lstore.set(iter, 0, thing)

        treeview = gtk.TreeView(__lstore)
        treeview.set_rules_hint(True)
        treeview.set_reorderable(True)

        treeview.connect('button-release-event', self.popup)

        sw.add(treeview)

        renderer = gtk.CellRendererText()
        renderer.connect('edited', self.edited, (__lstore,))
        renderer.set_property('editable', True)

        col = gtk.TreeViewColumn(self.item_name, renderer, text=0)
        treeview.append_column(col)

        # Entry.
        entry = gtk.Entry()
        entry.connect('activate', self.addItem, entry, treeview)

        add_button = gtk.Button('Add')
        add_button.connect('clicked', self.addItem, entry, treeview)

        # Box for entry and 'Add' button.
        hbox = gtk.HBox(False, 5)

        # Pack up.
        hbox.pack_start(entry, True, True)
        hbox.pack_start(add_button, False, False)

        vbox.pack_start(sw, True, True)
        vbox.pack_start(hbox, False, False, 5)

        self.set_border_width(7)
        self.set_default_size(300, 350)
        self.show_all()

    def _sanitize(self, value):
        return str(value).strip()

    def _commit(self, lstore):
        self.things[:] = []

        for i, row in enumerate(lstore):
            s = self._sanitize(row[0])
            if self.sanitize_cb:
                s = self.sanitize_cb(s)
            self.things.append(s)

    def edited(self, cell, path_string, new_text, user_data):
        model, = user_data
        model[path_string][0] = self._sanitize(new_text)
        self._commit(model)

    def deleteItem(self, menuitem, user_data):
        path, widget = user_data
        model = widget.get_model()
        del model[path]
        self._commit(model)

    def popup(self, widget, event):
        if event.button > 1:
            # Get treeview path.
            path, _, _, _ = widget.get_path_at_pos(int(event.x), int(event.y))

            m = gtk.Menu()
            item = gtk.MenuItem("Delete")
            item.connect('activate', self.deleteItem, (path, widget))
            m.append(item)
            m.show_all()
            m.popup(None, None, None, event.button, event.time)

    def addItem(self, button, textbox, treeview):
        model = treeview.get_model()
        value = self._sanitize(textbox.get_text())
        textbox.set_text('')

        if value:
            if self.sanitize_cb:
                value = self.sanitize_cb(value)

            in_model = False
            if self.force_unique:
                # Check to see if it's in the model already.
                for m in model:
                    if m[0] == value:
                        in_model = True
                        break

            if not in_model:
                model.append([value])
                self._commit(model)

class PatchTracker(gtk.Window):
    def delete(self, widget, event=None):
        self.storage.close()
        gtk.main_quit()
        return False

    def makeSelector(self):
        return gtk.combo_box_new_text()

    def addRepo(self, selector, repoPath):
        repoPath = normalize(repoPath)
        selector.append_text(repoPath)
        self.refreshButton.set_sensitive(True)

    def _getCurrent(self, combobox):
        model = combobox.get_model()
        index = combobox.get_active()
        if index >= 0:
            return model[index][0]

    def changeRepo(self, combobox):
        path = self._getCurrent(combobox)
        self.patchList.setRepo(self.repos.get(path))
        self.diffViewer.clear()

    def getCurrentRepo(self):
        return self.repos.get(self._getCurrent(self.repoSelector))

    def statusBarShow(self, msg, delay=5000):
        c_id = self.status_bar.get_context_id('me')
        self.status_bar.pop(c_id)
        m_id = self.status_bar.push(c_id, msg)
        gobject.timeout_add(delay, self.status_bar.remove, c_id, m_id)

    def refreshRepo(self, button):
        r = self.getCurrentRepo()
        if not r:
            return

        button.set_sensitive(False)
        r.scan()
        self.storage.sync(r)
        self.patchList.setRepo(r)
        button.set_sensitive(True)
        self.statusBarShow('Patch list refreshed.', 5000)

    def showReposWindow(self, item):
        d = ListDialog('Repository Paths', 'Path',
                       self.repos.keys(), sanitize_cb=normalize,
                       force_unique=True)

        result = d.run()

        if result == gtk.RESPONSE_OK:
            repos = d.things
            r = self.getCurrentRepo()
            if r and (r.path not in repos):
                self.patchList.setRepo(None)
            for repo_path in repos:
                if repo_path not in self.repos.keys():
                    p = normalize(repo_path)
                    self.repos[repo_path] = DarcsRepo(p)
                    self.storage.sync(self.repos[p])
                    self.addRepo(self.repoSelector, normalize(p))

            for k in self.repos.keys():
                if k not in repos:
                    self.storage.removeRepo(k)
                    del self.repos[k]
                    # Find the repo in the repoSelector and remove it.
                    m = self.repoSelector.get_model()
                    for i, row in enumerate(m):
                        if row[0] == k:
                            del m[i]
                            break

            self.repoSelector.set_active(0)

        d.destroy()

    def showStatusValuesWindow(self, item):
        d = ListDialog('Status Values', 'Value',
                       self.storage.getStatusValues(),
                       force_unique=True)

        result = d.run()

        if result == gtk.RESPONSE_OK:
            values = d.things
            self.storage.setStatusValues(values)

        d.destroy()

    def colorize(self, patch, textview):

        lines = patch.split("\n")
        buf = textview.get_buffer()

        for line_num, line in enumerate(lines):
            buf.get_iter_at_line(line_num)
            iter = buf.get_iter_at_line(line_num)
            iter2 = buf.get_iter_at_line_offset(line_num, len(line))
            if line.startswith("-"):
                buf.apply_tag_by_name("remove", iter, iter2)
            elif line.startswith("+"):
                buf.apply_tag_by_name("add", iter, iter2)
            elif line.startswith("!"):
                buf.apply_tag_by_name("replace", iter, iter2)
            else:
                pass

        start = buf.get_iter_at_offset(0)
        end = buf.get_iter_at_offset(len(patch))
        buf.apply_tag_by_name("fixed_font", start, end)

    def showPatch(self, patchList):
        path, col = patchList.get_cursor()
        if path is None:
            self.diffViewer.clear()
            self.currentPatch = None
        else:
            r = self.getCurrentRepo()
            p = r.patches[int(path[0])]

            if self.currentPatch is not p:
                self.currentPatch = p

                if p.content is None:
                    p.content = self.storage.getPatchContent(p.hash, r.path)

                if p.content is None:
                    p.content = r.getPatch(p.hash)
                    self.storage.savePatchContent(p)

                patch = p.content
                self.diffViewer.setText(patch)
                self.colorize(patch, self.diffViewer.view)

    def __init__(self, storage):
        gtk.Window.__init__(self, gtk.WINDOW_TOPLEVEL)
        self.storage = storage
        self.patchList = None
        self.connect("delete_event", self.delete)
        self.set_title("Patch Tracker")
        self.set_default_size(900, 800)
        self.diffViewer = DiffViewer()
        self.currentPatch = None

        vbox = gtk.VBox(False)
        self.add(vbox)

        menubar = gtk.MenuBar()
        vbox.pack_start(menubar, False, False)

        repoSelectorBox = gtk.HBox(False, 4)
        self.refreshButton = gtk.Button('Refresh')
        self.refreshButton.set_sensitive(False)
        self.repoSelector = self.makeSelector()
        repoSelectorBox.pack_start(gtk.Label('Repository:'), False, False, 7)
        repoSelectorBox.pack_start(self.repoSelector, False, False)
        repoSelectorBox.pack_start(self.refreshButton, False, False)
        vbox.pack_start(repoSelectorBox, False, False, 4)
        self.repoSelector.connect('changed', self.changeRepo)
        self.refreshButton.connect('clicked', self.refreshRepo)

        # Create file menu
        fileMenuItem = gtk.MenuItem("File")
        fileMenu = gtk.Menu()

        repoMenuItem = gtk.MenuItem("Repositories...")
        repoMenuItem.connect('activate', self.showReposWindow)
        fileMenu.append(repoMenuItem)

        statusMenuItem = gtk.MenuItem("Status values...")
        statusMenuItem.connect('activate', self.showStatusValuesWindow)
        fileMenu.append(statusMenuItem)

        fileMenu.append(gtk.MenuItem())
        exitItem = gtk.MenuItem("Exit")
        exitItem.connect('activate', self.delete)
        fileMenu.append(exitItem)

        fileMenuItem.set_submenu(fileMenu)
        menubar.append(fileMenuItem)

        sw = gtk.ScrolledWindow()
        sw.set_shadow_type(gtk.SHADOW_ETCHED_IN)
        sw.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
        self.patchList = PatchList(self.storage)
        sw.add(self.patchList)

        repoPaths = self.storage.getRepos()
        self.repos = {}
        for path in repoPaths:
            try:
                self.repos[path] = DarcsRepo(path)
                self.storage.sync(self.repos[path])
                self.addRepo(self.repoSelector, path)
            except:
                self.storage.removeRepo(path)

        self.repoSelector.set_active(0)

        self.patchList.connect('cursor-changed', self.showPatch)

        pane = gtk.VPaned()
        pane.add(sw)
        pane.add(self.diffViewer)
        vbox.pack_start(pane, True, True)
        pane.set_position(500)

        self.status_bar = gtk.Statusbar()
        vbox.pack_start(self.status_bar, False, False, 0)

        self.show_all()

if __name__ == "__main__":
    import os, os.path, sys
    db_path = os.path.join(os.environ['HOME'],
                           '.patchtracker.db')

    if len(sys.argv) > 1:
        db_path = sys.argv[1]

    storage = SQLiteStorage(db_path)
    PatchTracker(storage)
    gtk.main()
