#!/usr/bin/env python
from __future__ import print_function, unicode_literals
import functools, os, re, shutil, sys
SNEP_LIB_PATH = os.path.realpath(
    os.path.join(os.path.dirname(os.path.realpath(__file__)), "../lib"))
sys.path.insert(0, SNEP_LIB_PATH)
import snep
import snep.merge
import snep.syntaxes
import snep.utils as utils

class SnippetBase(object):
    pass

class Snippet(SnippetBase):

    def __init__(self, element):
        self.type = None
        self.name = element.name
        self.requires = frozenset(
            element.attributes.get("requires", "").split())
        self.element = element

    def __repr__(self):
        return "Snippet({0!r})".format(self.element)

class SpecialSnippet(SnippetBase):
    pass

class MissingSnippet(SpecialSnippet):

    def __init__(self, name):
        self.type = "missing"
        self.name = name
        self.requires = frozenset()

    def __repr__(self):
        return "MissingSnippet({0!r})".format(self.name)

class BuiltinSnippet(SpecialSnippet):

    def __init__(self, type, name):
        if type not in ["mod"]:
            raise ValueError("Unsupported built-in snippet prefix: {0}"
                             .format(type))
        if not re.match("\w+$", name):
            raise ValueError("Invalid module name: {0}".format(name))
        self.type = type
        self.name = name
        self.requires = frozenset()

    def __repr__(self):
        return "BuiltinSnippet({0!r})".format(self.type, self.name)

def find_nonunique_element_name(element):
    if not element.has_unique_elements:
        for name in element.elements:
            if name not in element.unique_elements:
                return name
        assert False

def find_all_snippets(element):
    snips = element.get_element("snips")
    nonunique_element_name = find_nonunique_element_name(snips)
    if nonunique_element_name is not None:
        raise ValueError("non-unique element: {0}"
                         .format(nonunique_element_name))
    return map(Snippet, snips.unique_elements.values())

def get_snippet(snippets, name):
    args = name.split(":", 1)
    if len(args) == 2:
        return BuiltinSnippet(*args)
    return snippets.get(name, MissingSnippet(name))

class SnippetCache(object):

    def __init__(self, doc_cache, search_path):
        self.search_path_stack = list(reversed(search_path))
        self.doc_cache = doc_cache
        self.snip_cache = {}

    def get_snippet(self, name):
        while True:
            snip = self.snip_cache.get(name, None)
            if snip is not None:
                return snip
            snip = get_builtin_snippet(name)
            if snip is not None:
                return snip
            if not self.search_path_stack:
                break
            self._load_document(self.search_path_stack.pop())
        raise ValueError("can't find snippet: {0}".format(name))

    def _load_document(self, fn):
        doc = self.doc_cache[fn]
        for snip in find_all_snippets(doc):
            if snip.name not in self.snip_cache:
                self.snip_cache[snip.name] = snip

def get_deps(get_snippet, requires):
    def neighbors_func(dep):
        return get_snippet(dep).requires
    return utils.reachable_set(requires, neighbors_func)

def get_mods(get_snippet, deps):
    mods = []
    for dep in deps:
        snip = get_snippet(dep)
        if snip.type == "mod":
            mods.append(snip.name)
    mods.sort()
    return mods

def filter_away_mods(get_snippet, deps):
    for dep in deps:
        snip = get_snippet(dep)
        if snip.type != "mod":
            yield dep

def install_required_snips(doc, get_snippet, purge, snips_indices, sort):
    requires_all = doc.get_element("snips").unique_elements
    # requires_only is what is needed if --purge was specified
    requires_only = Snippet(doc).requires or requires_all
    requires = set(requires_only)
    if not purge:
        requires.update(requires_all)
    deps = get_deps(get_snippet, requires)
    for dep in deps:
        if isinstance(get_snippet(dep), MissingSnippet):
            sys.stderr.write("warning: missing snippet: {0}\n"
                             .format(dep))
            sys.stderr.flush()
    mods = get_mods(get_snippet, deps)
    if "imports" in doc.unique_elements:
        doc = doc.replace_element_children(
            "imports", [snep.Text("import {0}\n".format(mod)) for mod in mods])
    elif mods:
        raise ValueError("{0}: doc has imports, but 'imports' "
                         "element is missing".format(doc.origin.filename))

    graph = dict((dep, [x for x in
                        filter_away_mods(get_snippet, get_snippet(dep).requires)])
                 for dep in filter_away_mods(get_snippet, deps))
    for name in requires_all:
        if name not in graph:
            sys.stderr.write("warning: will be deleted: {0}\n".format(name))
            sys.stderr.flush()
    if sort:
        def key(name):
            return name
    else:
        def key(name):
            return (snips_indices.get(name, float("inf")), name)
    snip_names = utils.toposort(graph, key=key, flip=True)
    snips = []
    for name in snip_names:
        if isinstance(get_snippet(name), SpecialSnippet):
            continue
        if snips:
            snips.append(snep.Text("\n"))
        snips.append(get_snippet(name).element)
    return doc.replace_element_children("snips", snips)

def check_for_garbage(snips):
    '''Make sure there's nothing important in the interstitial spaces.'''
    for child in snips.children:
        if isinstance(child, snep.Element):
            continue
        garbage = child.render().strip()
        if not garbage:
            continue
        raise ValueError("{0}: found garbage in interstitial spaces: {1}"
                         .format(child.origin.filename, garbage))

def check_for_nonimports(imports):
    '''Make sure the 'imports' element contains only import-like entries.'''
    for child in imports.children:
        if not isinstance(child, snep.Text):
            raise ValueError("{0}: found non-Text element in imports: {1}"
                             .format(child.origin.filename,
                                     child.render().strip()))
        if not re.match("import\s+[\w.]+$", child.value.strip()):
            raise ValueError("{0}: found non-import in imports: {1}"
                             .format(child.origin.filename,
                                     child.render().strip()))

def get_guess_syntax_kwargs(fn):
    kwargs = {
        "extension": os.path.splitext(fn)[1][1:],
        "shebang": "",
    }
    try:
        shebang = next(open(fn))
        if shebang.startswith("#!"):
            kwargs["shebang"] = shebang
    except StopIteration:
        pass
    return kwargs

def to_json(fn, syntax):
    if syntax is None:
        syntax = snep.syntaxes.guess_syntax(**get_guess_syntax_kwargs(fn))
    doc = snep.parse_doc_file(fn)
    sys.stdout.write(utils.json_pretty(doc.to_json()))
    sys.stdout.write("\n")

def sync(fn1, fn2, purge, dry_run, syntax, direction, sort):
    if syntax is None:
        syntax = snep.syntaxes.guess_syntax(**get_guess_syntax_kwargs(fn1))
        syntax2 = snep.syntaxes.guess_syntax(**get_guess_syntax_kwargs(fn2))
        if syntax != syntax2:
            raise ValueError("detected conflicting syntaxes: {0} vs {1}"
                             .format(syntax, syntax2))

    # TODO: distinguish between orphan snippets that are deleted
    #       and snippets that are deleted but still exist elsewhere
    #
    # TODO: handle synchronizing multiple files together

    doc1 = snep.parse_doc(utils.load_file(fn1), fn1)
    snips1 = doc1.get_element("snips")
    doc2 = snep.parse_doc(utils.load_file(fn2), fn2)
    snips2 = doc2.get_element("snips")
    snips1_indices = dict((name, i) for i, name in
                          enumerate(snips1.unique_elements.keys()))
    snips2_indices = dict((name, i) for i, name in
                          enumerate(snips2.unique_elements.keys()))

    # sanity checks on the document format
    # to avoid accidental loss of data
    check_for_garbage(snips1)
    check_for_garbage(snips2)
    if "imports" in doc1.unique_elements:
        check_for_nonimports(doc1.get_element("imports"))
    if "imports" in doc2.unique_elements:
        check_for_nonimports(doc2.get_element("imports"))
    nonunique_element_name = find_nonunique_element_name(snips1)
    if nonunique_element_name is not None:
        raise ValueError("{0}: non-unique snippet: {1}"
                         .format(fn1, nonunique_element_name))
    nonunique_element_name = find_nonunique_element_name(snips2)
    if nonunique_element_name is not None:
        raise ValueError("{0}: non-unique snippet: {1}"
                         .format(fn2, nonunique_element_name))

    # step I: merge snippets

    common_snips = set(snips1.unique_elements)
    common_snips.intersection_update(snips2.unique_elements)
    conflicting_snips1 = {}
    conflicting_snips2 = {}
    for name in common_snips:
        snip1 = snips1.get_element(name)
        snip2 = snips2.get_element(name)
        if snip1 != snip2:
            conflicting_snips1[name] = snip1.replace_name(None).render()
            conflicting_snips2[name] = snip2.replace_name(None).render()
    if conflicting_snips1:
        if direction == "left":
            merge_result = conflicting_snips2
        elif direction == "right":
            merge_result = conflicting_snips1
        elif direction is None:
            merge_result = snep.merge.interactive_merge(conflicting_snips1,
                                                        conflicting_snips2)
            if merge_result is None:
                sys.stderr.write("Canceled.\n")
                sys.stderr.flush()
                exit(1)
            if frozenset(merge_result) != frozenset(conflicting_snips2):
                sys.stderr.write("Error: snippets were added or removed "
                                 "during merge.\n")
                sys.stderr.flush()
                exit(1)
        else:
            raise Exception("unexpected direction: {!r}".format(direction))
        for name, merge_snip_str in merge_result.items():
            snip = snep.parse_doc(merge_snip_str, name)
            snips1 = snips1.replace_element_children(name, snip.children)
            snips2 = snips2.replace_element_children(name, snip.children)
        doc1 = doc1.replace_element_children("snips", snips1.children)
        doc2 = doc2.replace_element_children("snips", snips2.children)

    # step II: resolve dependencies

    snips = dict(snips1.unique_elements)
    snips.update(snips2.unique_elements)
    for name in snips:
        snips[name] = Snippet(snips[name])
    get_snip = functools.partial(get_snippet, snips)

    doc1 = install_required_snips(doc1, get_snip, purge,
                                  snips1_indices, sort=sort)
    doc2 = install_required_snips(doc2, get_snip, purge,
                                  snips2_indices, sort=sort)

    if dry_run:
        return

    utils.rename(fn1, fn1 + ".orig")
    try:
        utils.save_file(fn1, doc1.render())
        shutil.copymode(fn1 + ".orig", fn1)
    except:
        utils.rename(fn1 + ".orig", fn1)
        raise

    utils.rename(fn2, fn2 + ".orig")
    try:
        utils.save_file(fn2, doc2.render())
        shutil.copymode(fn2 + ".orig", fn2)
    except:
        utils.rename(fn2 + ".orig", fn2)
        raise

def get_path(**kwargs):
    sys.stdout.write("{0}\n".format(SNEP_LIB_PATH))

def argparser():
    import argparse
    p = argparse.ArgumentParser()
    p.add_argument("-s", "--syntax",
                   metavar="SYNTAXES",
                   help="override the markup syntax")
    sp = p.add_subparsers(help="subcommand")

    spp = sp.add_parser("to_json")
    spp.add_argument("fn", metavar="filename")
    spp.set_defaults(func=to_json)

    spp = sp.add_parser("sync")
    spp.add_argument("fn1", metavar="file1")
    spp.add_argument("fn2", metavar="file2")
    g = spp.add_mutually_exclusive_group()
    g.add_argument("-l", "--to-left", dest="direction",
                   action="store_const", const="left")
    g.add_argument("-r", "--to-right", dest="direction",
                   action="store_const", const="right")
    spp.add_argument("-s", "--sort", action="store_true")
    spp.add_argument("--purge", action="store_true")
    spp.add_argument("--dry-run", "-n", action="store_true")
    spp.set_defaults(func=sync)

    spp = sp.add_parser("get_path")
    spp.set_defaults(func=get_path)

    return p

def main():
    parser = argparser()
    args = vars(parser.parse_args())
    func = args.pop("func", None)
    if not func:
        parser.print_usage(sys.stderr)
        exit(1)
    try:
        func(**args)
    except snep.ParseError as e:
        sys.stderr.write("{0}:{1}: {2}\n".format(e.src, e.row, e.msg))
        sys.stderr.flush()
        exit(1)
    except KeyboardInterrupt:
        exit(1)

if __name__ == "__main__":
    main()
