#!/usr/bin/env python3
__doc__ = 'Make features.fea file'
# TODO: add conditional compilation, compare to fea, compile to ttf
__url__ = 'https://github.com/silnrsi/pysilfont'
__copyright__ = 'Copyright (c) 2017 SIL International  (https://www.sil.org)'
__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)'
__author__ = 'Martin Hosken, Alan Ward'

import silfont.ufo as ufo
from collections import OrderedDict
from silfont.feax_parser import feaplus_parser
from xml.etree import ElementTree as et
import re

from silfont.core import execute

def getbbox(g):
    res = (65536, 65536, -65536, -65536)
    if g['outline'] is None:
        return (0, 0, 0, 0)
    for c in g['outline'].contours:
        for p in c['point']:
            if 'type' in p.attrib:      # any actual point counts
                x = float(p.get('x', '0'))
                y = float(p.get('y', '0'))
                res = (min(x, res[0]), min(y, res[1]), max(x, res[2]), max(y, res[3]))
    return res

class Glyph(object) :
    def __init__(self, name, advance=0, bbox=None) :
        self.name = name
        self.anchors = {}
        self.is_mark = False
        self.advance = int(float(advance))
        self.bbox = bbox or (0, 0, 0, 0)

    def add_anchor(self, info) :
        self.anchors[info['name']] = (int(float(info['x'])), int(float(info['y'])))

    def decide_if_mark(self) :
        for a in self.anchors.keys() :
            if a.startswith("_") :
                self.is_mark = True
                break

def decode_element(e):
    '''Convert plist element into python structures'''
    res = None
    if e.tag == 'string':
        return e.text
    elif e.tag == 'integer':
        return int(e.text)
    elif e.tag== 'real':
        return float(e.text)
    elif e.tag == 'array':
        res = [decode_element(x) for x in e]
    elif e.tag == 'dict':
        res = {}
        for p in zip(e[::2], e[1::2]):
            res[p[0].text] = decode_element(p[1])
    return res

class Font(object) :
    def __init__(self, defines = None):
        self.glyphs = OrderedDict()
        self.classes = OrderedDict()
        self.all_aps = OrderedDict()
        self.fontinfo = {}
        self.kerns = {}
        self.defines = {} if defines is None else defines

    def readaps(self, filename, omitaps='', params = None) :
        omittedaps = set(omitaps.replace(',',' ').split())  # allow comma- and/or space-separated list
        if filename.endswith('.ufo') :
            f = ufo.Ufont(filename, params = params)
            self.fontinfo = {}
            for k, v in f.fontinfo._contents.items():
                self.fontinfo[k] = decode_element(v[1])
            skipglyphs = set(f.lib.getval('public.skipExportGlyphs', []))
            for g in f.deflayer :
                if g in skipglyphs:
                    continue
                ufo_g = f.deflayer[g]
                advb = ufo_g['advance']
                adv = advb.width if advb is not None and advb.width is not None else 0
                bbox = getbbox(ufo_g)
                glyph = Glyph(g, advance=adv, bbox=bbox)
                self.glyphs[g] = glyph
                if 'anchor' in ufo_g._contents :
                    for a in ufo_g._contents['anchor'] :
                        if a.element.attrib['name'] not in omittedaps:
                            glyph.add_anchor(a.element.attrib)
                            self.all_aps.setdefault(a.element.attrib['name'], []).append(glyph)
            if hasattr(f, 'groups'):
                for k, v in f.groups._contents.items():
                    self.classes[k.lstrip('@')] = decode_element(v[1])
            if hasattr(f, 'kerning'):
                for k, v in f.kerning._contents.items():
                    key = k.lstrip('@')
                    if key in self.classes:
                        key = "@" + key
                    subelements = decode_element(v[1])
                    kerndict = {}
                    for s, n in subelements.items():
                        skey = s.lstrip('@')
                        if skey in self.classes:
                            skey = "@" + skey
                        kerndict[skey] = n
                    self.kerns[key] = kerndict
        elif filename.endswith('.xml') :
            currGlyph = None
            currPoint = None
            self.fontinfo = {}
            for event, elem in et.iterparse(filename, events=('start', 'end')):
                if event == 'start':
                    if elem.tag == 'glyph':
                        name = elem.get('PSName', '')
                        if name:
                            currGlyph = Glyph(name)
                            self.glyphs[name] = currGlyph
                        currPoint = None
                    elif elem.tag == 'point':
                        currPoint = {'name' : elem.get('type', '')}
                    elif elem.tag == 'location' and currPoint is not None:
                        currPoint['x'] = int(elem.get('x', 0))
                        currPoint['y'] = int(elem.get('y', 0))
                    elif elem.tag == 'font':
                        n = elem.get('name', '')
                        x = n.split('-')
                        if len(x) == 2:
                            self.fontinfo['familyName'] = x[0]
                            self.fontinfo['openTypeNamePreferredFamilyName'] = x[0]
                            self.fontinfo['styleMapFamilyName'] = x[0]
                            self.fontinfo['styleName'] = x[1]
                            self.fontinfo['openTypeNamePreferredSubfamilyName'] = x[1]
                            self.fontinfo['postscriptFullName'] = "{0} {1}".format(*x)
                        self.fontinfo['postscriptFontName'] = n
                elif event == 'end':
                    if elem.tag == 'point':
                        if currGlyph and currPoint['name'] not in omittedaps:
                            currGlyph.add_anchor(currPoint)
                            self.all_aps.setdefault(currPoint['name'], []).append(currGlyph)
                        currPoint = None
                    elif elem.tag == 'glyph':
                        currGlyph = None

    def read_classes(self, fname, classproperties=False):
        doc = et.parse(fname)
        for c in doc.findall('.//class'):
            class_name = c.get('name')
            m = re.search('\[(\d+)\]$', class_name)
            # support fixedclasses like make_gdl.pl via AP.pm
            if m:
                class_nm = class_name[0:m.start()]
                ix = int(m.group(1))
            else:
                class_nm = class_name
                ix = None
            cl = self.classes.setdefault(class_nm, [])
            for e in c.get('exts', '').split() + [""]:
                for g in c.text.split():
                    if g+e in self.glyphs or (e == '' and g.startswith('@')):
                        if ix:
                            cl.insert(ix, g+e)
                        else:
                            cl.append(g+e)
        if not classproperties:
            return
        for c in doc.findall('.//property'):
            for e in c.get('exts', '').split() + [""]:
                for g in c.text.split():
                    if g+e in self.glyphs:
                        cname = c.get('name') + "_" + c.get('value')
                        self.classes.setdefault(cname, []).append(g+e)
                    
    def make_classes(self, ligmode) :
        for name, g in self.glyphs.items() :
            # pull off suffix and make classes
            # TODO: handle ligatures
            base = name
            if ligmode is None or 'comp' not in ligmode or "_" not in name:
                pos = base.rfind('.')
                while pos > 0 :
                    old_base = base
                    ext = base[pos+1:]
                    base = base[:pos]
                    ext_class_nm = "c_" + ext
                    if base in self.glyphs and old_base in self.glyphs:
                        glyph_lst = self.classes.setdefault(ext_class_nm, [])
                        if not old_base in glyph_lst:
                            glyph_lst.append(old_base)
                            self.classes.setdefault("cno_" + ext, []).append(base)
                    pos = base.rfind('.')
            if ligmode is not None and "_" in name:
                comps = name.split("_")
                if "comp" in ligmode or "." not in comps[-1]:
                    base = comps.pop(-1 if "last" in ligmode else 0)
                    cname = base.replace(".", "_")
                    noname = "_".join(comps)
                    if base in self.glyphs and noname in self.glyphs:
                        glyph_lst = self.classes.setdefault("clig_"+cname, [])
                        if name not in glyph_lst:
                            glyph_lst.append(name)
                            self.classes.setdefault("cligno_"+cname, []).append(noname)
            if g.is_mark :
                self.classes.setdefault('GDEF_marks', []).append(name)
            else :
                self.classes.setdefault('GDEF_bases', []).append(name)

    def make_marks(self) :
        for name, g in self.glyphs.items() :
            g.decide_if_mark()

    def order_classes(self):
        # return ordered list of classnames as desired for FEA

        # Start with alphabetical then correct:
        #   1. Put classes like "cno_whatever" adjacent to "c_whatever"
        #   2. Classes can be defined in terms of other classes but FEA requires that
        #      classes be defined before they can be referenced.

        def sortkey(x):
            key1 = 'c_' + x[4:] if x.startswith('cno_') else x
            return (key1, x)

        classes = sorted(self.classes.keys(), key=sortkey)
        links = {}  # key = classname; value = list of other classes that include this one
        counts = {} # key = classname; value = count of un-output classes that this class includes
        for name in classes:
            y = [c[1:] for c in self.classes[name] if c.startswith('@')]  #list of included classes
            counts[name] = len(y)
            for c in y:
                links.setdefault(c, []).append(name)

        outclasses = []
        while len(classes) > 0:
            foundone = False
            for name in classes:
                if counts[name] == 0:
                    foundone = True
                    # output this class
                    outclasses.append(name)
                    classes.remove(name)
                    # adjust counts of classes that include this one
                    if name in links:
                        for n in links[name]:
                            counts[n] -= 1
                    # It may now be possible to output some we skipped earlier,
                    # so start over from the beginning of the list
                    break
            if not foundone:
                # all remaining classes include un-output classes and thus there is a loop somewhere
                raise ValueError("Class reference loop(s) found: " + ", ".join(classes))
        return outclasses

    def addComment(self, parser, text):
        cmt = parser.ast.Comment("# " + text, location=None)
        cmt.pretext = "\n"
        parser.add_statement(cmt)

    def append_classes(self, parser) :
        # normal glyph classes
        self.addComment(parser, "Main Classes")
        for name in self.order_classes():
            gc = parser.ast.GlyphClass(None, location=None)
            for g in self.classes[name] :
                gc.append(g)
            gcd = parser.ast.GlyphClassDefinition(name, gc, location=None)
            parser.add_statement(gcd)
            parser.define_glyphclass(name, gcd)

    def _addGlyphsToClass(self, parser, glyphs, gc, anchor, definer):
        if len(glyphs) > 1 :
            val = parser.ast.GlyphClass(glyphs, location=None)
        else :
            val = parser.ast.GlyphName(glyphs[0], location=None)
        classdef = definer(gc, anchor, val, location=None)
        gc.addDefinition(classdef)
        parser.add_statement(classdef)

    def append_positions(self, parser):
        # create base and mark classes, add to fea file dicts and parser symbol table
        bclassdef_lst = []
        mclassdef_lst = []
        self.addComment(parser, "Positioning classes and statements")
        for ap_nm, glyphs_w_ap in self.all_aps.items() :
            self.addComment(parser, "AP: " + ap_nm)
            # e.g. all glyphs with U AP
            if not ap_nm.startswith("_"):
                if any(not x.is_mark for x in glyphs_w_ap):
                    gcb = parser.set_baseclass(ap_nm)
                    parser.add_statement(gcb)
                if any(x.is_mark for x in glyphs_w_ap):
                    gcm = parser.set_baseclass(ap_nm + "_MarkBase")
                    parser.add_statement(gcm)
            else:
                gc = parser.set_markclass(ap_nm)

            # create lists of glyphs that use the same point (name and coordinates)
            # that can share a class definition
            anchor_cache = OrderedDict()
            markanchor_cache = OrderedDict()
            for g in glyphs_w_ap :
                p = g.anchors[ap_nm]
                if g.is_mark and not ap_nm.startswith("_"):
                    markanchor_cache.setdefault(p, []).append(g.name)
                else:
                    anchor_cache.setdefault(p, []).append(g.name)

            if ap_nm.startswith("_"):
                for p, glyphs_w_pt in anchor_cache.items():
                    anchor = parser.ast.Anchor(p[0], p[1], location=None)
                    self._addGlyphsToClass(parser, glyphs_w_pt, gc, anchor, parser.ast.MarkClassDefinition)
            else:
                for p, glyphs_w_pt in anchor_cache.items():
                    anchor = parser.ast.Anchor(p[0], p[1], location=None)
                    self._addGlyphsToClass(parser, glyphs_w_pt, gcb, anchor, parser.ast.BaseClassDefinition)
                for p, glyphs_w_pt in markanchor_cache.items():
                    anchor = parser.ast.Anchor(p[0], p[1], location=None)
                    self._addGlyphsToClass(parser, glyphs_w_pt, gcm, anchor, parser.ast.BaseClassDefinition)

#TODO: provide more argument info
argspec = [
    ('infile', {'nargs': '?', 'help': 'Input UFO or file'}, {'def': None, 'type': 'filename'}),
    ('-i', '--input', {'required': 'True', 'help': 'Fea file to merge'}, {}),
    ('-o', '--output', {'help': 'Output fea file'}, {}),
    ('-c', '--classfile', {'help': 'Classes file'}, {}),
    ('-L', '--ligmode', {'help': 'Parse ligatures: last - use last element as class name, first - use first element as class name, lastcomp, firstcomp - final variants are part of the component not the whole ligature'}, {}),
    ('-D', '--define', {'action': 'append', 'help': 'Add option definition to pass to fea code --define=var=val'}, {}),
    # ('--debug', {'help': 'Drop into pdb', 'action': 'store_true'}, {}),
    ('--classprops', {'help': 'Include property elements from classes file', 'action': 'store_true'}, {}),
    ('--omitaps', {'help': 'names of attachment points to omit (comma- or space-separated)', 'default': '', 'action': 'store'}, {})
]

def doit(args) :
    defines = dict(x.split('=') for x in args.define) if args.define else {}
    font = Font(defines)
    # if args.debug:
    #     import pdb; pdb.set_trace()
    if "checkfix" not in args.params:
        args.paramsobj.sets["main"]["checkfix"] = "None"
    if args.infile is not None:
        font.readaps(args.infile, args.omitaps, args.paramsobj)

    font.make_marks()
    font.make_classes(args.ligmode)
    if args.classfile:
        font.read_classes(args.classfile, classproperties = args.classprops)

    p = feaplus_parser(None, font.glyphs, font.fontinfo, font.kerns, font.defines)
    doc_ufo = p.parse() # returns an empty ast.FeatureFile

    # Add goodies from the font
    font.append_classes(p)
    font.append_positions(p)

    # parse the input fea file
    if args.input :
        doc_fea = p.parse(args.input)
    else:
        doc_fea = doc_ufo

    # output as doc.asFea()
    if args.output :
        with open(args.output, "w") as of :
            of.write(doc_fea.asFea())

def cmd(): execute(None, doit, argspec)
if __name__ == '__main__': cmd()
