#! /usr/bin/env python3

import io, os, copy, math, codecs, tarfile, optparse, subprocess
from math import cos, sin, atan2, hypot
from random import random, randint
from itertools import *
from collections import deque, defaultdict
tau = math.pi * 2
pj = os.path.join

# depends contains %CONFLICTS%, %DEPENDS%, %OPTDEPENDS%, %PROVIDES%

# desc contains %URL%, %REPLACES%, %LICENSE%,
# %NAME%, %GROUPS%, %BUILDDATE%, %REASON%, %DESC%,
# %SIZE%, %PACKAGER%, %ARCH%, %INSTALLDATE%, %VERSION%


class Node(object):
    def __init__(self, **kwargs):
        self.name     = ''      # matches dict key
        self.links    = set()   # of name strings
        self.inverse  = set()   # of name strings
        self.size     = 0       # bytes, usually
        self.font_pt  = 0       # scaled from size
        self.explicit = False   # True if explicitly installed package
        self.center   = (0,0)   # (x,y)
        self.color    = '#000000'
        self.__dict__.update(kwargs)
    def __repr__(self):
        return repr({'links' : self.links, 
                     'size'  : self.size})
    # cache the slowest of these?
    @property
    def all(self):
        return self.links | self.inverse
    @property
    def dim(self):
        return pt2dim(self.name, self.font_pt)
    @property
    def box(self):
        return bbox(self.center, self.dim)
    @property
    def cd(self):
        # phase this out
        return [self.center, self.dim]

class MockOptions(object):
    def __init__(self):
        self.opt_deps = False

def unrip(path):
    "path -> dict of Nodes"
    tree = eval(open(path).read())
    return bilink_tree(dict((p,Node(**n)) for p,n in list(tree.items())))

class Arch(object):
    def clean(self, n):
        n = n.strip()
        for c in '><:=':
            n = n.partition(c)[0]
        return n
    
    def load_info(self, arch_file):
        info = defaultdict(list)
        mode = None
        for line in (self.clean(l) for l in arch_file):
            if not line:
                continue
            if line.startswith('%'):
                mode = line
                continue
            info[mode].append(line)
        arch_file.close()
        return info
    
    def strip_info(self, info):
        keep = set(['DEPENDS', 'OPTDEPENDS', 'PROVIDES', 'SIZE', 'ISIZE', 'REASON'])
        info = dict((k.strip('%'),v) for k,v in info.items())
        name = info['NAME'][0]
        info = dict((k,v) for k,v in info.items() if k in keep)
        if 'ISIZE' in info:
            info['SIZE'] = info['ISIZE']
        if 'SIZE' in info:
            info['SIZE'] = int(info['SIZE'][0], 10)
        else:
            info['SIZE'] = 0
        if 'REASON' in info:
            info['REASON'] = info['REASON'] == '1'
        else:
            info['REASON'] = False
        return name, info

    def load_tarball(self, tgz):
        db = tarfile.open(tgz)
        packages = defaultdict(dict)
        for member in db.getmembers():
            if not member.isfile():
                continue
            path = member.name
            #raw = codecs.getreader('utf8')(db.extractfile(member))
            raw = io.StringIO(db.extractfile(member).read().decode('utf8'))
            name,sub = os.path.split(path)
            packages[name][sub] = raw
        tree = {}
        for p in packages.values():
            info = {}
            [info.update(self.load_info(v)) for v in list(p.values())]
            name,info = self.strip_info(info)
            tree[name] = info
        return tree
    
    def load_tree(self, dirs):
        dirs = set(filter(os.path.isdir, dirs))
        packages = set(p for r in dirs for p,d,f in os.walk(r) if f)
        tree = {}
        for p in packages - dirs:
            try:
                info = {}
                if os.path.isfile(pj(p, 'depends')): 
                    arch_file = open(pj(p,'depends'), 'r')
                    info.update(self.load_info(arch_file))
                arch_file = open(pj(p,'desc'), 'r')
                info.update(self.load_info(arch_file))
                name, info = self.strip_info(info)
                tree[name] = info
            except KeyboardInterrupt:
                raise
            except:
                print('Error reading package', p)
        return tree
    
    def actually_installed_fn(self, tree):
        "use only on load_tree data, returns function"
        p_set = set(tree)
        errors = {}
        p_tree = dict((p,tree[p]['PROVIDES']) for p in tree if 'PROVIDES' in tree[p])
        def actually(packages):
            installed = set(packages) & p_set
            maybe = set(packages) - installed
            for pack in maybe:
                if pack in errors:
                    installed.add(errors[pack])
                    continue
                provides = sorted(p for p in p_tree if pack in p_tree[p])
                if len(provides) > 1:
                    print('warning: %s found in %s, assuming %s' % (pack, provides, provides[0]))
                    errors[pack] = provides[0]
                if provides:
                    installed.add(provides[0])
                # len 0 means not installed optdep
            return list(installed)
        return actually
    
    def merge_tree(self, tree):
        "merge provides, depends, optdepends"
        options, args = parse()
        tree2 = {}
        actually_installed = self.actually_installed_fn(tree)
        # merge
        for p,info in tree.items():
            tp = defaultdict(list, info)
            deps = tp['DEPENDS']
            if options.opt_deps:
                deps += tp['OPTDEPENDS']
            # remove unused optdeps
            deps = actually_installed(deps)
            tree2[p] = Node(name=p, size=info['SIZE'], links=set(deps), explicit=info['REASON'])
        return tree2
    
    def dbpath(self):
        "super hacky"
        if not os.path.isfile('/etc/pacman.conf'):
            return '/var/lib/pacman/'
        for line in open('/etc/pacman.conf'):
            line = line.strip()
            if not line.startswith('DBPath'):
                continue
            return line.partition('=')[2].strip()
        return '/var/lib/pacman/'

    def local_load(self):
        dbpath = self.dbpath()
        dirs = ['local']
        dirs = [pj(dbpath, d) for d in dirs]
        tree = compress_chains(bilink_tree(self.merge_tree(self.load_tree(dirs))))
        return legal_links(tree)
    
    def repo_load(self):
        options, args = parse()
        packages = args
        dbpath = self.dbpath()
        dirs = ['community', 'core', 'extra', 'multilib']
        dirs = [pj(dbpath, 'sync', d) for d in dirs]
        tars = ['community', 'core', 'extra', 'multilib']
        tars = [pj(dbpath, 'sync', '%s.db' % t) for t in tars]
        #tars = [pj(dbpath, '%s.db.tar.gz' % t) for t in tars]
        tars = [t for t in tars if os.path.isfile(t)]
        if tars:
            tree = {}
            [tree.update(self.load_tarball(t)) for t in tars]
        else:
            tree = self.load_tree(dirs)
        tree = self.merge_tree(tree)
        if not packages:
            return legal_links(compress_chains(bilink_tree(tree)))
        reqs = set()
        if options.show_req:
            reqs = set(k for k,v in tree.items() if set(packages) & v.links)
        deps = set()
        for p in set(packages) | reqs:
            deps.update(full_deps(p, tree))
        tree2 = dict((k,v) for k,v in tree.items() if k in deps | reqs)
        return legal_links(bilink_tree(tree2))

class Debian(object):
    "Expect lots of 'error: unknown' messages. May be due to pseudo-dependencies being filled by 'Provides:' entries."
    
    def load_tree(self, status_path = None):
        "New implementation not extensively tested, your mileage may vary"
        tree = {}
        installed = False
        deps = set()
        name = ''
        size = 0
        if status_path is None:
            status_path = '/var/lib/dpkg/status'
        data = open(status_path, 'r')
        for line in data:
            if line.startswith('Package:'):
                name = line.split(':')[1].strip()
            elif line.startswith('Status'):
                installed = ('install ok installed' in line) or ('install user installed' in line)
            elif line.startswith('Installed-Size:') or line.startswith('Size:'):
                size = int(line.split(':')[1].strip(), 10) * 1024
            elif line.startswith('Depends:'):
                deps = line.partition(':')[2]
                deps = [d for e in deps.split(',') for d in e.split('|')]
                deps = [d.partition('(')[0].strip() for d in deps]
                deps = set(deps) - set('')
            elif line == '\n' and installed and name and size:
                tree[name] = Node(name = name, links = deps, size = size)
                installed = False
                deps = set()
                name = ''
                size = 0
        data.close()
        return tree
    
    def local_load(self, status_path=None):
        tree = legal_links(self.load_tree(status_path))
        tree = compress_chains(bilink_tree(tree))
        return legal_links(tree)
    
    def repo_load(self):
        print('not implemented')
        raise

class Redhat(object):
    "RPM uses a capability/provider model. load_tree() has room for improvement"
    
    def load_tree(self):
        stdout = call_stdout('rpm --query --all --queryformat "%{NAME}<(^_^)>%{SIZE}<(^_^)>[%{REQUIRENAME}, ]<(^_^)>[%{PROVIDENAME}, ]\n"')
        tree = {}
        requirer_lookup = {}
        provider_lookup = {}
        for line in stdout:
            if line == '':
                break
            name,nullo,line = line.partition('<(^_^)>')
            size,nullo,line = line.partition('<(^_^)>')
            size = int(size, 10)
            requires,nullo,provides = line.partition('<(^_^)>')
            requires = set(capability.strip() for capability in requires.split(',')) - set([''])
            for capability in requires:
                if capability not in requirer_lookup:
                    requirer_lookup[capability] = set()
                requirer_lookup[capability] |= set([name])
            provides = set(capability.strip() for capability in provides.split(',')) - set([''])
            for capability in provides:
                if capability not in provider_lookup:
                    provider_lookup[capability] = set()
                provider_lookup[capability].add(name)
            if name in tree:
                tree[name].size += size
            else:
                tree[name] = Node(name = name, links = set(), size = size)
        for capability in set(requirer_lookup) & set(provider_lookup):
            for package in requirer_lookup[capability]:
                tree[package].links |= provider_lookup[capability]
        return tree
    
    def local_load(self):
        tree = legal_links(self.load_tree())
        tree = compress_chains(bilink_tree(tree))
        return legal_links(tree)
    
    def repo_load(self):
        print('not implemented')
        raise

class Crux(object):
    "based entirely on docs, needs to be tested"
    def find_all(self, package):
        raw = call_stdout('pkginfo -i')
        for line in raw.split('\n'):
            yield line.split(' ')[0]
    def find_size(self, package):
        return call_stdout('pkgsize %s' % package)
    def find_deps(self, package):
        return call_stdout('finddeps %s' % package)
    def local_load(self):
        "unfinished"
        tree = {}
        all_packages = []
        for name in all_packages:
            size = self.find_size(name)
            deps = self.find_deps(name)
            tree[name] = Node(name=name, size=size, links=set(deps))
        return tree

class Gentoo(object):
    def local_load(self):
        print('Not yet implemented.')
        return None

class Textfile(object):
    """ VERY ALPHA, frequent to change!
    loads a space delineated text file
    file should be named "data", each line looks like
    node_name size link1 link2 link3 ..."""
    def load_tree(self):
        tree = {}
        filename = 'data'
        for line in open(filename):
            words = line.split()
            node = words[0]
            size = int(words[1])
            links = words[2:]
            tree[node] = Node(name=node, size=size, links=set(links))
        return tree

    def local_load(self):
        tree = legal_links(self.load_tree())
        tree = compress_chains(bilink_tree(tree))
        return legal_links(tree)


class Rtree(object):
    "Why?  Pyrtree is insert-then-query (but mine has no delete), Rtree has crazy C deps."
    def __init__(self, parent=None, box=None, name=None):
        "Only root has no parent, only leaves have names."
        # Half an R-tree.  Not height balanced!  Missing delete().
        bigm = 5
        self.parent = parent
        self.name = name
        self.bigm = bigm
        self.lilm = bigm//2
        self.children = []
        self.box = (0,0,0,0)
        self.last_hit = None
        if box:
            self.box = tuple(box)
    def __lt__(self, other):
        return True  # haaaack
    def show(self, depth=0):
        head = '  ' * depth + str(self.box) + ' '
        if self.name:
            return head + self.name + '\n'
        head += str(len(self.children))
        return head + '\n' + ''.join(c.show(depth+1) for c in self.children)
    def root(self):
        if self.parent is None:
            return self
        return self.parent.root()
    def best_node(self, to_add):
        "smallest place for box to_add, does not change anything"
        return smallest_merge(self.children, to_add)
    def search(self, box=None):
        "yields matching leaf objects, no box for all leaves"
        todo = [self]
        while todo:
            node = todo.pop()
            if box and not in_box(node.box, box):
                continue
            if node.name:
                self.last_hit = node
                yield node
            todo.extend(node.children)
    def search_cache(self, box):
        "assumes consecutive searches will be near each other"
        todo = [self.root()]
        if self.last_hit:
            todo.append(self.last_hit.parent)
            todo.append(self.last_hit)
            #while todo[0].parent:
            #    todo.insert(0, todo[0].parent)
        while todo:
            node = todo.pop()
            if not in_box(node.box, box):
                continue
            if node.name:
                self.last_hit = node
                yield node
            todo.extend(node.children)
    def insert(self, box, name=None):
        "makes a new node"
        target = self.root().choose_leaf(box)
        target.children.append(Rtree(target, box, name))
        target.merge_up(box)
        if len(target.children) > target.bigm:
            target.divide_children()
    def merge_up(self, box):
        # only grows
        new_box = merge(self.box, box)
        if new_box == self.box:
            return
        self.box = new_box
        if self.parent:
            self.parent.merge_up(self.box)
    def is_leaf_node(self):
        return not self.children or bool(self.children[0].name)
    def choose_leaf(self, box):
        n = self
        while not n.is_leaf_node():
            n = n.best_node(box)
        return n
    def adjust(self):
        if len(self.children) > self.bigm:
            self.overflow()
        if self.parent and len(self.children) < self.lilm:
            self.underflow()
    def divide_children(self):
        children2 = [c for c in self.children]
        self.children = []
        while children2:
            c1 = children2.pop()
            newp = Rtree(self, c1.box)
            newp.children.append(c1)
            c1.parent = newp
            self.children.append(newp)
            if not children2:
                break
            c2 = smallest_merge(children2, newp.box)
            newp.children.append(c2)
            children2.remove(c2)
            c2.parent = newp
            newp.box = merge(c1.box, c2.box)
        self.box = merge(*(c.box for c in self.children))
        self.merge_up(self.box)
        # Leaves are too small, but grow soon.  Probably.
    def underflow(self):
        "Not implemented"
        raise
    def delete(self, box):
        "Not implemented."
        raise
    def unbalance(self):
        "histogram of leaf depths"
        hist = dict((i,0) for i in range(100))
        stack = [[self]]
        depth = 0
        while stack:
            if not stack[-1]:
                depth -= 1
                stack.pop()
                continue
            c = stack[-1].pop()
            assert bool(c.name) ^ bool(c.children)
            if c.name:
                hist[depth] += 1
            else:
                stack.append(c.children)
                depth += 1
        return dict((k,v) for k,v in hist.items() if v)
    def leafiness(self):
        "histogram of branch size"
        hist = dict((i,0) for i in range(100))
        stack = [self]
        while stack:
            c = stack.pop()
            if c.name:
                continue
            hist[len(c.children)] += 1
            stack.extend(c.children)
        return dict((k,v) for k,v in hist.items() if v)

def merge(*boxes):
    "new box surrounding inputs"
    fns = (min, min, max, max)
    return tuple(f(p) for f,p in zip(fns, list(zip(*boxes))))

def area(box):
    return box[2]-box[0] * box[3]-box[1]

def smallest_merge(nodes, box):
    "return best node from nodes"
    return min((area(merge(n.box, box)), area(n.box), n) for n in nodes)[2]

def full_deps(package, tree, reverse=False):
    "returns every package in dep tree"
    deps = set()
    to_crawl = deque([package])
    while to_crawl:
        current = to_crawl.popleft()
        if current in deps:
            continue
        deps.add(current)
        if not reverse:
            current_deps = tree[current].links
        else:
            current_deps = tree[current].inverse
        to_crawl.extend(current_deps - deps)
    return list(deps)

def bilink_tree(tree):
    "adds inverse from links"
    for p in tree:
        deps = tree[p].links
        [tree[d].inverse.add(p) for d in deps]
    return tree

def flatten(list_of_lists):
    return list(chain(*list_of_lists))

def single_depends(tree, preserve_explicit=False):
    if preserve_explicit:
        return (p for p in tree if len(tree[p].inverse) == 1 and not tree[p].explicit)
    else:
        return (p for p in tree if len(tree[p].inverse) == 1)

def compress_chains(tree):
    "single depends are absorbed into parent"
    options, args = parse()
    if not options.use_compression:
        return tree
    while True:
        singles = single_depends(tree, options.preserve_explicit)
        try:
            s = next(singles)
        except StopIteration:
            return tree
        parent = list(tree[s].inverse)[0]
        if s == parent:
            #print 'self loop', s
            tree[s].links.remove(s)
            tree[s].inverse.remove(s)
            continue
        #print 'merge', s, 'into', parent
        tree[parent].size += tree[s].size
        #tree[parent].name += ' ' + tree[s].name
        for dep in tree[s].links:
            tree[dep].inverse.remove(s)
            tree[dep].inverse.add(parent)
        tree[parent].links.update(tree[s].links)
        tree[parent].links.remove(s)
        tree.pop(s)

def sum_sizes(packages, tree):
    return sum(tree[p].size for p in packages if p in tree)

def shared_size(package, tree):
    "package and all deps"
    return sum_sizes(full_deps(package, tree), tree)

def biggest_packs(tree):
    packs = [(shared_size(p, tree), p) for p in tree]
    return [p for s,p in sorted(packs, reverse=True)]

def toplevel_packs(tree):
    return set(p for p in tree if not tree[p].inverse)

def packs_by_size(tree, pack_list):
    by_sizes = [(tree[n].size, n) for n in pack_list]
    return sorted(by_sizes, reverse=True)

def legal_links(tree):
    "removes/reports dangling references"
    valid = set(tree)
    for k,v in tree.items():
        invalid1 = v.links - valid
        invalid2 = v.inverse - valid
        if invalid1:
            print('error: unknown', list(invalid1), 'in', k) 
            v.links = v.links - invalid1
        if invalid2:
           print('error: unknown', list(invalid2), 'in', k)
           v.inverse = v.inverse - invalid2
    return tree

#print('worst shared packages:', biggest_packs(tree)[:20])
#print('most crucial packages:', biggest_packs(invert_tree(tree))[:20])

def pt_sizes(tree, min_pt=10, max_pt=100):
    "size in bytes -> size in points"
    sizes = [node.size for p,node in tree.items()]
    min_s,max_s = min(sizes), max(sizes)
    convert = lambda s: int((max_pt-min_pt)*(s-min_s)/(max_s-min_s) + min_pt)
    for p, node in tree.items():
        tree[p].font_pt = convert(node.size)
    return tree

def prioritized(packs):
    "iter of names, sorted by priority"
    # first are the most central, with lots of links
    stats = [(len(node.all), k) for k,node in packs.items()]
    stats = [n for l,n in sorted(stats, reverse=True)]
    # but slip in anyone who's deps are met early
    plotted = set()
    for n in (n for n in stats if n not in plotted):
        yield n
        plotted.add(n)
        deps_met = set(k for k,node in packs.items() if node.all <= plotted)
        for n2 in deps_met - plotted:
            yield n2
        plotted.update(deps_met)

def ran_rad():
    return random() * tau

def bbox(center, dim):
    c,d = center,dim
    cx,cy = center
    dx,dy = dim
    x1,x2 = cx - dx//2, cx + dx//2
    y1,y2 = cy - dy   , cy + dy//3
    return [x1, y1, x2, y2] 

def in_box(bboxA, bboxB):
    a1x,a2y,a3x,a4y = bboxA
    b1x,b2y,b3x,b4y = bboxB
    return (a3x > b1x if a1x<b1x else b3x>a1x) and (a4y > b2y if a2y<b2y else b4y>a2y)

def all_bboxes(packs):
    return [packs[name].box for name in packs]

def normalize(point, origin):
    "weighted"
    dx,dy = point[0]-origin[0], point[1]-origin[1]
    length_sq = dx**2 + dy**2
    return dx/length_sq, dy/length_sq

def link_pull(name, origin_n, packs):
    "average of angles of links"
    origin = packs[origin_n].center
    norm_ps = lambda ps: [normalize(c, origin) for c in ps if c not in [(0,0), origin]]
    rot90 = lambda x,y: (-y,x) if randint(0,1) else (y,-x)
    good_links = packs[name].all
    skew_links = set(packs.keys()) - good_links
    g_centers  = norm_ps(packs[l].center for l in good_links)
    s_centers  = norm_ps(packs[l].center for l in skew_links)
    s_centers  = [rot90(*xy) for xy in s_centers]
    centers = g_centers + s_centers
    if not centers:  
        # new branch, try to avoid existing branches
        centers = norm_ps(packs[l].center for l in list(packs.keys()))
        if not centers:
            return (0,0)
        centers = [(-x,-y) for x,y in centers]
    return list(map(sum, list(zip(*centers))))

def xy2rad(x, y):
    if (x,y) == (0,0):
        return ran_rad()
    return math.atan2(y,x)

def pol2xy(o, a, r):
    return int(o[0] + r * cos(a)), int(o[1] + r * sin(a))

def pt2dim(name, pt):
    x_scale = 0.65
    y_scale = 0.85
    return int(len(name)*pt*x_scale), int(pt*y_scale)

def best_origin(name, pri_hist, packs):
    "returns largest sibling, or root"
    possible = [n for n in pri_hist if n in packs[name].all]
    if not possible:
        return pri_hist[0]  # root package
    return max((packs[n].size, n) for n in possible)[1]

# add search_hyperbola and means of switching

def search(cd, origin, heading, scale, rt):
    "linear binary search recursive closure thingy, returns radius"
    history = []
    def probe(r):
        "returns true if clear"
        cd[0] = pol2xy(origin, heading, r)
        history.append(cd[0])
        bb1 = bbox(*cd)
        #return not any(rt.search_cache(bb1))
        return not any(rt.search(bb1))
    def search3(step, r):
        s2 = step // 2
        if probe(r - s2):
            if step < scale:
                return r-s2
            else:
                return search3(s2, r-s2)
        if probe(r + s2):
            step = s2
        else:
            step *= 2
        return search3(step, r + step)
    return search3(scale, scale), history

def search_spiral(cd, origin, scale, rt, step=5, aspect=0.5):
    "returns angle, radius, history"
    # step controls speed/granularity
    # really should be a logarithmic spiral
    # spiral aspect -> final aspect... holy grail?
    history = []
    def probe(heading, r):
        "returns true if clear"
        cd[0] = pol2xy(origin, heading, r)
        history.append(cd[0])
        bb1 = bbox(*cd)
        return not any(rt.search(bb1))
    angle = ran_rad()
    r = scale
    while not probe(angle, r):
        x = cos(angle) * r
        y = sin(angle) * r
        m = angle + tau/4
        x += cos(m) * scale * step / aspect
        y += sin(m) * scale * step
        angle = xy2rad(x, y)
        r = hypot(x, y)
    return angle, r, history


def place(packs, detail=False):
    "radial placement algo, returns non-overlapping coords"
    # maybe try two different link_pulls for each placement?
    # maybe some control inversion to avoid queue checks?
    pri_cache = []
    def pri():
        for p in prioritized(packs):
            pri_cache.append(p)
            yield p
    prii = pri()
    rt = Rtree()
    p = next(prii)
    rt.insert(bbox((0,0), packs[p].dim), p)
    if detail:
        yield pri_cache[0], [(0,0)]
    else:
        yield pri_cache[0], (0,0)
    for name in prii:
        node = packs[name]
        origin_name = best_origin(name, pri_cache, packs)
        #print('placing', name, 'around', origin_name)
        origin = packs[origin_name].center
        # unplaced links and big text need more room
        scale = len(node.all-set(pri_cache))+1
        scale = max(scale, node.dim[1])
        #heading = xy2rad(*link_pull(name, origin_name, packs))
        #r,history = search(node.cd, origin, heading, scale, rt)
        heading,r,history = search_spiral(node.cd, origin, scale, rt)
        center = pol2xy(origin, heading, r)
        rt.insert(bbox(center, packs[name].dim), name)
        if detail:
            yield name, history
        else:
            yield name, center
    #print(rt.show())
    #print(copy.deepcopy(rt).unbalance())
    #print(copy.deepcopy(rt).leafiness())

def offset_coord(c, d):
    "corrects textbox origin"
    return c[0]-d[0]//2, c[1]  #+d[1]//2

def xml_wrap(tag, inner=None, **kwargs):
    kw = ' '.join('%s="%s"' % (str(k), str(v)) for k,v in kwargs.items())
    if inner is None:
        return '<%s %s/>' % (tag, kw)
    return '<%s %s>%s</%s>' % (tag, kw, inner, tag)

def control_point(p1, p2, drop=None):
    dx = abs(p2[0] - p1[0])
    lower  = (p1,p2)[p1[1]<p2[1]]
    higher = (p2,p1)[p1[1]<p2[1]]
    if drop is None:
        drop = 0.5 + 0.5 * random()
    return (lower[0]+higher[0])//2, lower[1]+dx*drop//2

def quad_spline(p1, p2):
    "boofor DSL in XML"
    c = control_point(p1, p2)
    return 'M%i,%i Q%i,%i %i,%i' % (p1 + c + p2)

def svg_text(node):
    x,y = offset_coord(node.center, node.dim)
    kw = {'font-size': node.font_pt}
    return xml_wrap('text', node.name, x=x, y=y, fill=node.color, **kw)

def svg_spline(point1, point2):
    return xml_wrap('path', None, d=quad_spline(point1, point2))

def all_points(packs):
    "slightly incomplete, guestimates the splines"
    all_bb = flatten((bb[:2],bb[2:]) for bb in all_bboxes(packs))
    all_endpoints = [(packs[p].center, packs[l].center) for p in packs for l in packs[p].all]
    all_controls = [control_point(ep[0], ep[1], 0.5) for ep in all_endpoints]
    return all_bb + all_controls

def recenter(packs, points):
    "shift everything into quadrant 1"
    min_x,min_y = list(map(min, list(zip(*points))))
    for node in packs.values():
        x,y = node.center
        node.center = x-min_x, y-min_y
    return packs

def window_size(points):
    xs,ys = list(zip(*points))
    return max(xs)-min(xs), max(ys)-min(ys)

def all_links(packs):
    paths = []
    for pack in packs:
        links = packs[pack].all
        p1 = packs[pack].center
        paths.extend((p1,packs[l].center) for l in links if l<pack)
    return paths

def svgify(packs):
    options, args = parse()
    toplevel = toplevel_packs(packs)
    bottomlevel = set(packs) - toplevel
    needs, needed_by = [], []
    if args and ((options.mode != 'arch-repo') ^ options.show_req):
        needs     = flatten(full_deps(p, packs, reverse=False) for p in args)
        needed_by = flatten(full_deps(p, packs, reverse=True)  for p in args)
    all_ps = all_points(packs)
    packs = recenter(packs, all_ps)
    width,height = window_size(all_ps)
    for pack,node in packs.items():
        # replace with a lookup structure of some sort
        # { test_fn(): 'color', ... }?  [ (pack_set, 'color'), ... ]?
        # also, order sensitive
        if pack in bottomlevel:
            node.color = options.dependency
        if pack in toplevel:
            node.color = options.toplevel
        if pack in needs:
            node.color = options.highlight[1]
        if pack in needed_by:
            node.color = options.highlight[2]
        if pack in args and ((options.mode != 'arch-repo') ^ options.show_req):
            node.color = options.highlight[0]
    texts = [svg_text(packs[p]) for p in packs]
    paths = [svg_spline(*line) for line in all_links(packs)]
    svg =  xml_wrap('rect', None, x=0, y=0, width=width, height=height, style='fill:%s;' % options.background)
    svg += xml_wrap('g', '\n'.join(paths), style='stroke:%s; stroke-opacity:0.15; fill:none;' % options.link)
    svg += xml_wrap('g', '\n'.join(texts), **{'font-family':'Monospace'})
    svg = xml_wrap('svg', svg, width=width, height=height, xmlns='http://www.w3.org/2000/svg')
    svg = '<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.0//EN" "http://www.w3.org/TR/SVG/DTD/svg10.dtd">\n' + svg
    svg = '<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n' + svg
    filename = os.path.expanduser(options.filename)
    open(filename + '.svg', 'w').write(svg)

def call_status(cmd):
    "returns exit status"
    spp = subprocess.PIPE
    return subprocess.Popen(cmd, shell=True, stdout=spp, stderr=spp).wait()

def call_stdout(cmd):
    "returns stdout"
    subp = subprocess.Popen(cmd, shell=True, stdin=None, stdout=subprocess.PIPE)
    return subp.communicate()[0].split('\n')

def parse():
    default_action = 'autodetect'
    parser = optparse.OptionParser(description='Produces two files, pacgraph.svg and pacgraph.png.  Colors should be entered as hex values like "#ffffff".  SVG named colors may also work, see http://en.wikipedia.org/wiki/Web_colors .  Packages listed in the args are highlighted.')
    parser.add_option('-s', '--svg', dest='svg_only', action='store_true', default=False,
            help='Produce the SVG but do not attempt to rasterize it.')
    parser.add_option('-o', '--opt-deps', dest='opt_deps', action='store_true', default=False,
            help='Include optional dependencies.  May produce a less compact graph.')
    parser.add_option('-e', '--explicits', dest='preserve_explicit', action='store_true', default=False,
            help='Preserve explicitly installed applications from dependency compression.  May produce a less compact graph.')
    parser.add_option('-c', '--console', dest='console', action='store_true', default=False,
            help='Print summary to console, does not draw a graph.  Good for very slow computers.')
    parser.add_option('-r', '--rip', dest='rip', action='store_true', default=False,
            help='Rips a copy of your installed packages to pacgraph.txt.  Good for debugging.')
    parser.add_option('-f', '--file', dest='filename', default='pacgraph',
            help='Override default filename/location.  Do not specify an extension.')
    parser.add_option('--disable-palette', dest='indexed', action='store_false', default=True,
            help='Disables lossy palette compression.')
    
    colors = optparse.OptionGroup(parser, "Theming Options")
    colors.add_option('-b', '--background', dest='background', metavar='COLOR', default='#ffffff',
            help='Background color.')
    colors.add_option('-l', '--link', dest='link', metavar='COLOR', default='#606060',
            help='Color of links between packages.')
    colors.add_option('-t', '--top', dest='toplevel', metavar='COLOR', default='#0000ff',
            help='Color of packages which are not dependencies.')
    colors.add_option('-d', '--dep', dest='dependency', metavar='COLOR', default='#6a6aa2',
            help='Color of packages which are dependencies.')
    colors.add_option('-i', '--highlight', dest='highlight', metavar='COLOR COLOR COLOR', nargs=3,
            default=('#00cc00', '#ff0000', '#700060'),
            help='Color of selected package, selected dependencies, selected needed-by.')
    colors.add_option('-p', '--point', dest='point_size', metavar='INT INT', type='int', nargs=2, default=(10,100),
            help='Takes two integers, for the smallest and largest font size.  Default is -p 10 100.')
    parser.add_option_group(colors)

    beta = optparse.OptionGroup(parser, "Experimental Options")
    beta.add_option('-m', '--mode', dest='mode', default=default_action,
            help='Curently supported modes are arch, arch-repo, debian, redhat and ipkg.  Default is %s.' % default_action)
    beta.add_option('-n', '--no-compression', dest='use_compression', action='store_false', default=True,
            help='Disable all chain compression.')
    beta.add_option('--shared', dest='shared', action='store_true', default=False,
            help='Compare shared libraries.')
    beta.add_option('--show-req-by', dest='show_req', action='store_true', default=False,
            help='Includes required-by of specified packages.  Only works for arch-repo.')
    parser.add_option_group(beta)
    
    options, args = parser.parse_args()
    return options, args

def human_si(number):
    powers = [(2**15,''), (2**25,'k'), (2**35,'M'), (2**45,'G')]
    limit,si = [p for p in powers if p[0] > number][0]
    return str(number//(limit//2**15)) + si +'B'

def console_dump(tree):
    options, args = parse()
    stat_line = human_si(sum(node.size for node in tree.values()))
    print('Total size:', stat_line)
    if options.use_compression:
        tops = toplevel_packs(tree)
    else:
        tops = list(tree.keys())
    if len(tops) == 1:
        tops = list(tree.keys())
    for s,n in packs_by_size(tree, tops):
        print(human_si(s), n)
    return

def secondlevel_sizes(tree):
    # this is slightly lazy and only handles the common case
    # it ignores oddly shaped dep trees
    tops = toplevel_packs(tree)
    seconds = set(tree[t].links for t in tops)
    sec_info = []
    for s in seconds:
        aboves = tree[s].inverse & tops  # lazy!
        size = tree[s].size + sum(tree[i].size for i in aboves)
        sec_info.append((size/len(aboves), s, size, aboves))
    sec_info.sort(reverse=True)
    for null, s, size, aboves in sec_info:
        print('%s: %s ' % (s, human_si(size)))
        print('\t', ' '.join(sorted(aboves)))

def exists(app):
    return call_status('which %s' % app) == 0

def main():
    arch = Arch()
    debian = Debian()
    redhat = Redhat()
    textfile = Textfile()
    options, args = parse()
    filename = os.path.expanduser(options.filename)
    tree = None
    if options.mode == 'autodetect':
        if any(map(exists, 'emerge paludis'.split())):
            print ('Autodetected gentoo.')
            options.mode = 'gentoo'
        if exists('rpm'):
            print ('Autodetected rpm.')
            options.mode = 'redhat'
        if any(map(exists, 'dpkg apt aptitude'.split())):
            print ('Autodetected deb.')
            options.mode = 'debian'
        if exists('ipkg'):
            print ('Autodetected ipkg.')
            options.mode = 'ipkg'
        if exists('pacman'):
            print ('Autodetected Arch.')
            options.mode = 'arch'
    print('Loading package info')
    if options.mode == 'arch':
        tree = arch.local_load()
    if options.mode == 'arch-repo':
        tree = arch.repo_load()
    if options.mode == 'debian':
        tree = debian.local_load()
    if options.mode == 'redhat':
        tree = redhat.local_load()
    if options.mode == 'textfile':
        tree = textfile.local_load()
    if options.mode == 'ipkg':
        tree = debian.local_load(status_path='/usr/lib/ipkg/status')
        # ipkg reports bytes, not KB
        for node in tree.values():
            node.size /= 1024
    if tree is None:
        print('Could not load packages.  Manually specify --mode.')
        return
    stat_line = human_si(sum(v.size for k,v in list(tree.items())))
    if options.rip:
        open(filename + '.txt', 'w').write(repr(tree))
        return
    if options.console:
        console_dump(tree)
        return
    if options.shared:
        secondlevel_sizes(tree)
        return
    print('Placing %i nodes' % len(tree))
    tree = pt_sizes(tree, *options.point_size)
    for n,c in place(tree):
        tree[n].center = c
    tree = recenter(tree, all_points(tree))
    tree[stat_line] = Node(name=stat_line, font_pt=sum(options.point_size)//2)
    print('Saving SVG')
    svgify(tree)
    if options.svg_only:
        return
    print('Rendering PNG')
    png_conversion = ['inkscape -D -e %(fn)s.png %(fn)s.svg',
                      'svg2png %(fn)s.svg %(fn)s.png',
                      'convert %(fn)s.svg %(fn)s.png']
    for cmd_line in png_conversion:
        app = cmd_line.split()[0]
        if not exists(app):
            continue
        call_status(cmd_line % {'fn':filename})
        if options.indexed and exists('mogrify'):
            print('Indexing PNG')
            call_status('mogrify -quality 100 -type optimize -flatten +matte -colors 127 %s.png' % filename)
        return  # after first success
    print('No way to convert SVG to PNG.')
    print('Inkscape, svg2png or imagemagick would be nice.')
    print('Alternatives: "pacgraph-tk" or "pacgraph --console".')
    print()
    console_dump(tree)

if __name__ == "__main__":
    main()
    #import cProfile; cProfile.run("main()", sort=1)

