#!/usr/bin/env python
"""
stickerbom.py
Copyright 2016 Adam Greig
Licensed under the MIT licence, see LICENSE file for details.
"""

from __future__ import print_function, division, unicode_literals
from collections import defaultdict

import argparse
import os
import math
import cairo
import xml.etree.ElementTree as ET
import sexp


class Module:
    def __init__(self, mod):
        self.fab_lines = []
        self.fab_circs = []
        self.silk_lines = []
        self.silk_circs = []
        self.rect_pads = []
        self.circ_pads = []
        # In each layer, store a dict with keys "lines,circs,rects,arcs",
        # each with a list of tuples
        # Note that *.Cu and *.Mask layers need to be handled on render
        self.graphic_layers = defaultdict(lambda: defaultdict(list))
        self._parse(mod)

    def render(self, cr, layers, fallbacklayers=[]):
        """
        Render the footprint in the board coordinate system.
        """
        cr.save()
        cr.translate(self.at[0], self.at[1])
        cr.set_line_width(0.1)
        if len(self.at) == 3:
            cr.rotate(-self.at[2] * math.pi/180)

        # Switch to drawing fallback layers if not all drawing layers are
        # present in graphic_layers
        if not all(layer in self.graphic_layers for layer in layers):
            layers = fallbacklayers

        for layer in layers:
            if layer in self.graphic_layers:
                for line in self.graphic_layers[layer]["lines"]:
                    cr.move_to(*line[0])
                    cr.line_to(*line[1])
                    cr.stroke()
                for circ in self.graphic_layers[layer]["circs"]:
                    cr.new_sub_path()
                    cr.arc(circ[0][0], circ[0][1], circ[1], 0, 2*math.pi)
                    if layer.endswith(".Cu"):
                        cr.fill()
                    else:
                        cr.stroke()
                for rect in self.graphic_layers[layer]["rects"]:
                    cr.rectangle(rect[0][0], rect[0][1], rect[1][0],
                                 rect[1][1])
                    if layer.endswith(".Cu"):
                        cr.fill()
                    else:
                        cr.stroke()
        cr.restore()

    def render_highlight(self, cr):
        """
        Render a highlight at the footprint's position and of its size.
        """
        cr.save()
        cr.translate(self.at[0], self.at[1])
        if len(self.at) == 3:
            cr.rotate(-self.at[2] * math.pi/180)
        x1, y1, x2, y2 = self.bounds
        a = 0.2
        x1 -= a
        y1 -= a
        x2 += a
        y2 += a
        r = 0.5
        pi2 = math.pi / 2.0
        cr.new_sub_path()
        cr.arc(x1+r, y1+r, r, 2*pi2, 3*pi2)
        cr.arc(x2-r, y1+r, r, 3*pi2, 4*pi2)
        cr.arc(x2-r, y2-r, r, 0*pi2, 1*pi2)
        cr.arc(x1+r, y2-r, r, 1*pi2, 2*pi2)
        cr.close_path()
        cr.fill()
        cr.restore()

    def _parse(self, mod):
        self.at = [float(x) for x in sexp.find(mod, "at")[1:]]
        self.bounds = [0, 0, 0, 0]
        self.layer = sexp.find(mod, "layer")[1]

        for text in sexp.find_all(mod, "fp_text"):
            if text[1] == "reference":
                self.ref = text[2]
            elif text[1] == "value":
                self.val = text[2]

        for graphic in sexp.find_all(mod, "fp_line", "fp_circle"):
            self._parse_graphic(graphic)

        for pad in sexp.find_all(mod, "pad"):
            self._parse_pad(pad)

    def _parse_graphic(self, graphic):
        layer = sexp.find(graphic, "layer")[1]
        end = [float(x) for x in sexp.find(graphic, "end")[1:]]
        self._update_bounds(end)
        if graphic[0] == "fp_line":
            start = [float(x) for x in sexp.find(graphic, "start")[1:]]
            self._update_bounds(start)
            self.graphic_layers[layer]["lines"].append((start, end))
        elif graphic[0] == "fp_circle":
            center = [float(x) for x in sexp.find(graphic, "center")[1:]]
            self._update_bounds(center)
            r = math.sqrt((center[0] - end[0])**2 +
                          (center[1] - end[1])**2)
            self.graphic_layers[layer]["circs"].append((center, r))

    def _parse_pad(self, pad):
        layers = sexp.find(pad, "layers")[1:]
        pad_type = pad[2]
        if pad_type not in ("smd", "thru_hole"):
            return

        at = [float(x) for x in sexp.find(pad, "at")[1:]]
        size = [float(x) for x in sexp.find(pad, "size")[1:]]
        drill = sexp.find(pad, "drill")
        if drill:
            offset = sexp.find(drill, "offset")
            if offset:
                at[0] += float(offset[1])
                at[1] += float(offset[2])
        topleft = at[0] - size[0]/2, at[1] - size[1]/2
        shape = pad[3]
        if shape in ("rect", "oval"):
            for layer in layers:
                self.graphic_layers[layer]["rects"].append((topleft, size))
            self._update_bounds(at, dx=size[0]/2, dy=size[1]/2)
        elif shape == "circle":
            for layer in layers:
                self.graphic_layers[layer]["circs"].append((at, size[0]/2))
            self._update_bounds(at, dx=size[0]/2, dy=size[0]/2)
        else:
            self._update_bounds(at)

    def _update_bounds(self, at, dx=0, dy=0):
        self.bounds[0] = min(self.bounds[0], at[0] - dx)
        self.bounds[1] = min(self.bounds[1], at[1] - dy)
        self.bounds[2] = max(self.bounds[2], at[0] + dx)
        self.bounds[3] = max(self.bounds[3], at[1] + dy)


class PCB:
    def __init__(self, board):
        self.modules = []
        self.edge_lines = []
        self.edge_arcs = []
        self._parse(board)

    def get_mod_sides(self, refs):
        """
        Return a dictionary where the key is a layer name and the value is a
        list of refs on that layer. Normal pcb files should only have modules
        on F.Cu and B.Cu
        """
        mod_sides = defaultdict(list)
        for module in self.modules:
            if module.ref not in refs:
                continue
            mod_sides[module.layer].append(module.ref)
        return mod_sides

    def render(self, cr, where, max_w, max_h, modlayers=[],
               modfallbacklayers=[], highlights=None, flip=None):
        """
        Render the PCB, with the top left corner at `where`,
        occupying at most `max_w` width and `max_h` height,
        and draw a highlight under parts whose reference is in `highlights`.
        """
        cr.save()
        cr.set_line_width(0.1)

        # Set a clip to ensure we occupy at most max_w and max_h
        cr.rectangle(where[0], where[1], max_w, max_h)
        cr.clip()

        # Find bounds on highlighted modules
        hl_bounds = self._find_highlighted_bounds(highlights)
        bound_width = hl_bounds[2] - hl_bounds[0]
        bound_height = hl_bounds[3] - hl_bounds[1]
        bound_centre_x = hl_bounds[0] + bound_width/2
        bound_centre_y = hl_bounds[1] + bound_height/2

        # Scale to either 1.5:1 or smaller if necessary to fit bounds
        scale_x = max_w / max(bound_width, 0.01)
        scale_y = max_h / max(bound_height, 0.01)
        scale = min(min(1.5, scale_x), min(1.5, scale_y))
        cr.scale(scale, scale)

        # Can we shift the top edge of the PCB to the top and not cut off
        # the bottom of the highlight?
        if hl_bounds[3] - self.bounds[1] < max_h/scale:
            shift_y = -self.bounds[1]

        # Can we shift the bottom edge of the PCB to the bottom and not cut off
        # the top of the highlight?
        elif self.bounds[3] - hl_bounds[1] < max_h/scale:
            shift_y = -self.bounds[3] + max_h/scale

        # Otherwise centre the highlighted region vertically
        else:
            shift_y = (max_h/(2*scale))-bound_centre_y

        # Can we shift the left edge of the PCB to the left and not cut off
        # the right of the highlight?
        if hl_bounds[2] - self.bounds[0] < max_w/scale:
            shift_x = -self.bounds[0]

        # Can we shift the right edge of the PCB to the right and not cut off
        # the left of the highlight?
        elif self.bounds[2] - hl_bounds[0] < max_w/scale:
            shift_x = -self.bounds[2] + max_w/scale

        # Otherwise centre the highlighted region horizontally
        else:
            shift_x = (max_w/(2*scale))-bound_centre_x

        cr.translate(shift_x, shift_y)

        # Setting "flip" at all will flip horizontally, specifying "v"
        # specifically will flip vertically
        flip_x = 1.0
        flip_y = 1.0
        if (flip):
            if (flip == "v" or flip == "V"):
                flip_y = -1.0
            else:
                flip_x = -1.0
            cr.scale(flip_x, flip_y)
            # Scale will flip around current origin, so shift back to TL corner
            cr.translate((2*shift_x - (max_w/scale)) * (-flip_x/2 + 0.5),
                         (2*shift_y - (max_h/scale)) * (-flip_y/2 + 0.5))

        # Translate our origin to desired position on page
        cr.translate(where[0]/(scale*flip_x), where[1]/(scale*flip_y))

        # Render highlights below everything else
        cr.set_source_rgb(1.0, 0.5, 0.5)
        for module in self.modules:
            if module.ref in highlights:
                module.render_highlight(cr)

        # Render modules
        cr.set_source_rgb(0, 0, 0)
        for module in self.modules:
            module.render(cr, modlayers, modfallbacklayers)

        # Render edge lines
        for line in self.edge_lines:
            cr.move_to(*line[0])
            cr.line_to(*line[1])
            cr.stroke()

        # Render edge arcs
        for arc in self.edge_arcs:
            cr.new_sub_path()
            cr.arc(*arc)
            cr.stroke()

        cr.restore()

    def _find_highlighted_bounds(self, highlights):
        # Find bounds on highlighted modules
        # TODO: Deal with rotation in modules in a more elegant fashion
        # (Rotation includes bounds, so here we just take the biggest bound,
        #  which is both wasteful for high aspect ratio parts, and wrong for
        #  parts not on a 90' rotation).
        hl_bounds = [self.bounds[2], self.bounds[3],
                     self.bounds[0], self.bounds[1]]
        for module in self.modules:
            if module.ref not in highlights:
                continue
            a = max(module.bounds) * 2
            hl_bounds[0] = min(hl_bounds[0], module.at[0] - a)
            hl_bounds[1] = min(hl_bounds[1], module.at[1] - a)
            hl_bounds[2] = max(hl_bounds[2], module.at[0] + a)
            hl_bounds[3] = max(hl_bounds[3], module.at[1] + a)
        return hl_bounds

    def _parse(self, board):
        for module in sexp.find_all(board, "footprint"):
            self.modules.append(Module(module))

        # We compute the PCB bounds ourselves rather than relying on the file's
        # area tag which seems to sometimes be wrong. First go based on module
        # positions.
        self.bounds = [
            min(m.at[0] for m in self.modules),
            min(m.at[1] for m in self.modules),
            max(m.at[0] for m in self.modules),
            max(m.at[1] for m in self.modules)
        ]

        # We find all the board edges both for drawing and for bounds
        self._parse_edges(board)

        # Add a slight padding to ensure edge lines are properly drawn
        self.bounds[0] -= 1
        self.bounds[1] -= 1
        self.bounds[2] += 1
        self.bounds[3] += 1

        self.width = self.bounds[2] - self.bounds[0]
        self.height = self.bounds[3] - self.bounds[1]

    def _parse_edges(self, board):
        for graphic in sexp.find_all(board, "gr_line", "gr_arc", "gr_circle"):
            layer = sexp.find(graphic, "layer")[1]
            if layer != "Edge.Cuts":
                continue
            if graphic[0] == "gr_line":
                start = [float(x) for x in sexp.find(graphic, "start")[1:]]
                end = [float(x) for x in sexp.find(graphic, "end")[1:]]
                self.edge_lines.append((start, end))
                self._update_bounds(start)
                self._update_bounds(end)
            elif graphic[0] == "gr_arc":
                a = [float(x) for x in sexp.find(graphic, "start")[1:]]
                b = [float(x) for x in sexp.find(graphic, "mid")[1:]]
                c = [float(x) for x in sexp.find(graphic, "end")[1:]]
                # KiCad 6.0 specifies arcs by start, mid, and end, instead of
                # the previous centre, start, and angle.
                # Convert to Cairo's centre, radius, start angle, end angle.
                mid_ab = [(a[0] + b[0])/2.0, (a[1] + b[1])/2.0]
                grad_ab = (b[1] - a[1]) / (b[0] - a[0])
                grad_bc = (c[1] - b[1]) / (c[0] - b[0])
                centre_x = (
                    (grad_ab * grad_bc * (a[1] - c[1]))
                    + (grad_bc * (a[0] + b[0]))
                    - grad_ab * (b[0] + c[0])
                ) / (2 * (grad_bc - grad_ab))
                centre_y = -1/grad_ab * (centre_x - mid_ab[0]) + mid_ab[1]
                centre = [centre_x, centre_y]
                rad = math.sqrt((centre[0] - a[0])**2 +
                                (centre[1] - a[1])**2)
                start_angle = math.atan2(a[1] - centre[1], a[0] - centre[0])
                end_angle = math.atan2(c[1] - centre[1], c[0] - centre[0])
                self.edge_arcs.append((centre[0], centre[1], rad,
                                       start_angle, end_angle))
                self._update_bounds(centre, dx=rad, dy=rad)
            elif graphic[0] == "gr_circle":
                center = [float(x) for x in sexp.find(graphic, "center")[1:]]
                end = [float(x) for x in sexp.find(graphic, "end")[1:]]
                r = math.sqrt((center[0] - end[0])**2 +
                              (center[1] - end[1])**2)
                self.edge_arcs.append((center[0], center[1], r, 0, 2*math.pi))
                self._update_bounds(center, dx=r, dy=r)

    def _update_bounds(self, at, dx=0, dy=0):
        self.bounds[0] = min(self.bounds[0], at[0] - dx)
        self.bounds[1] = min(self.bounds[1], at[1] - dy)
        self.bounds[2] = max(self.bounds[2], at[0] + dx)
        self.bounds[3] = max(self.bounds[3], at[1] + dy)


class BOM:
    def __init__(self, xmlpath, include=[], exclude=[]):
        self.tree = ET.parse(xmlpath)
        self.lines = []
        self.suppliers = {}
        self._find_parts(include, exclude)
        self._generate_lines()

    def _find_parts(self, include, exclude):
        for comp in self.tree.getroot().iter('comp'):
            ref = comp.get('ref')

            if include and ref not in include:
                continue
            if exclude and ref in exclude:
                continue

            val = comp.findtext('value')
            ftp = comp.findtext('footprint')
            fields = {}
            part = {"ref": ref, "val": val, "ftp": ftp, "fields": fields}
            for field in comp.iter('field'):
                supplier = field.get('name')
                code = field.text
                fields[supplier] = code

                if supplier not in self.suppliers:
                    self.suppliers[supplier] = {}
                if code not in self.suppliers[supplier]:
                    self.suppliers[supplier][code] = []
                self.suppliers[supplier][code].append(part)

    def _generate_lines(self):
        for supplier in self.suppliers:
            for code in self.suppliers[supplier]:
                part = self.suppliers[supplier][code][0]
                refs = []
                for part in self.suppliers[supplier][code]:
                    refs.append(part['ref'])
                line = Line(refs, part['val'], part['ftp'], supplier, code)
                self.lines.append(line)


class Line:
    def __init__(self, refs, value, footprint, supplier, code):
        self.refs = refs
        self.value = value
        self.footprint = footprint
        self.supplier = supplier
        self.code = code

        if self.footprint is not None:
            self.footprint = self.footprint.split(":")[1]
        else:
            self.footprint = ""

    def render(self, cr, where, w, h):
        cr.save()

        # Clip to permissible area
        cr.rectangle(where[0], where[1], w, h)
        cr.clip()

        # Draw first line
        cr.set_source_rgb(0, 0, 0)
        cr.select_font_face("Sans", cairo.FONT_SLANT_NORMAL,
                            cairo.FONT_WEIGHT_BOLD)
        cr.set_font_size(3.0)
        cr.move_to(where[0]+3, where[1]+5)
        cr.show_text(" ".join(self.refs))

        # Draw second line
        cr.select_font_face("Sans", cairo.FONT_SLANT_NORMAL,
                            cairo.FONT_WEIGHT_NORMAL)
        cr.set_font_size(3.0)
        cr.move_to(where[0]+3, where[1]+9)
        cr.show_text("{}x  {}  {}"
                     .format(len(self.refs), self.value, self.footprint))

        # Draw third line
        cr.select_font_face("Sans", cairo.FONT_SLANT_NORMAL,
                            cairo.FONT_WEIGHT_NORMAL)
        cr.set_font_size(3.0)
        cr.move_to(where[0]+3, where[1]+12)
        cr.show_text("{} {}".format(self.supplier, self.code))

        cr.restore()


# Forever yields a new (x, y) of successive label top-left positions,
# calling cr.show_page() when the current page is exhausted.
def sheet_positions(cr, label_width, label_height, labels_x, labels_y,
                    margin_top, margin_left, spacing_x, spacing_y):
    while True:
        for x in range(labels_x):
            for y in range(labels_y):
                xx = margin_left + x*(label_width + spacing_x)
                yy = margin_top + y*(label_height + spacing_y)
                yield (xx, yy)
        cr.show_page()


def xmlpath(path):
    if os.path.exists(path):
        return path
    raise TypeError("XML file must exist.")


def pdfpath(path):
    if path[-4:].lower() != ".pdf":
        return path + ".pdf"
    return path


def get_args():
    parser = argparse.ArgumentParser(
        formatter_class=argparse.ArgumentDefaultsHelpFormatter)
    parser.add_argument("xmlpath", type=xmlpath,
                        help="Path to the xml BOM file to be parsed.")
    parser.add_argument("pdfpath", type=pdfpath,
                        help="Path to the pdf file that will "
                             "contain the stickers.")

    parser.add_argument("--label_width", type=float, default=72,
                        help="Width of a label (mm).")
    parser.add_argument("--label_height", type=float, default=63.5,
                        help="Height of a label (mm).")
    parser.add_argument("--labels-x", type=int, default=4,
                        help="Number of columns of labels on a page.")
    parser.add_argument("--labels-y", type=int, default=3,
                        help="Number of rows of labels on a page.")
    parser.add_argument("--margin-top", type=float, default=7.75,
                        help="Margin at the top of the page (mm).")
    parser.add_argument("--margin-left", type=float, default=4.5,
                        help="Margin at the left side of the page (mm).")
    parser.add_argument("--spacing-x", type=float, default=0.0,
                        help="Gap between columns of labels (mm).")
    parser.add_argument("--spacing-y", type=float, default=2.0,
                        help="Gap between rows of labels (mm).")
    parser.add_argument("--page-width", type=float, default=297,
                        help="Width of a page (mm).")
    parser.add_argument("--page-height", type=float, default=210,
                        help="Height of a page (mm).")
    parser.add_argument("--flip-vert", default="h",
                        action="store_const", const="v",
                        help="Flip the bottom view of the board vertically "
                             "rather than horizontally")

    parser.add_argument("--suppliers",
                        default="Farnell,RS,DigiKey,Digikey,Mouser",
                        help="Comma seperated list of names of suppliers "
                             "to output stickers for (Note: this really "
                             "means 'custom schematic symbol property "
                             "field names' to output stickers for).")

    parser.add_argument("--include-parts-without-footprint",
                        action="store_true",
                        help="Include parts that do not have a footprint.")

    group = parser.add_mutually_exclusive_group()
    group.add_argument("-i", "--include", nargs='+', help="parts to include")
    group.add_argument("-e", "--exclude", nargs='+', help="parts to exclude")
    return parser.parse_args()


def main():
    args = get_args()

    bom = BOM(args.xmlpath, include=args.include, exclude=args.exclude)

    with open(args.xmlpath[:-3] + "kicad_pcb") as f:
        pcb = PCB(sexp.parse(f.read()))

    mm_to_pt = 2.835
    ps = cairo.PDFSurface(args.pdfpath,
                          args.page_width*mm_to_pt,
                          args.page_height*mm_to_pt)
    cr = cairo.Context(ps)

    # Scale user units to millimetres
    cr.scale(mm_to_pt, mm_to_pt)

    labels = sheet_positions(cr,
                             args.label_width, args.label_height,
                             args.labels_x, args.labels_y,
                             args.margin_top, args.margin_left,
                             args.spacing_x, args.spacing_y)

    suppliers = [name.strip() for name in args.suppliers.split(",")]

    for line in bom.lines:
        if line.supplier not in suppliers:
            continue
        if not line.footprint and not args.include_parts_without_footprint:
            continue
        label = next(labels)
        line.render(cr,
                    (label[0]+1, label[1]),
                    args.label_width-2, 14)
        sides = pcb.get_mod_sides(line.refs)

        if "F.Cu" in sides and "B.Cu" in sides:
            # if both sides present, split area and draw both
            pcb.render(cr, (label[0]+1, label[1]+14),
                       (args.label_width-4)/2.0, args.label_height-14,
                       ["F.Fab"], ["F.Cu", "*.Cu", "F.SilkS"], sides["F.Cu"])

            pcb.render(cr, (label[0]+3+(args.label_width-3)/2.0, label[1]+14),
                       (args.label_width-4)/2.0, args.label_height-14,
                       ["B.Fab"], ["B.Cu", "*.Cu", "B.SilkS"], sides["B.Cu"],
                       args.flip_vert)

        elif "F.Cu" in sides:
            pcb.render(cr, (label[0]+1, label[1]+14),
                       args.label_width-2, args.label_height-14,
                       ["F.Fab"], ["F.Cu", "*.Cu", "F.SilkS"], sides["F.Cu"])
        elif "B.Cu" in sides:
            pcb.render(cr, (label[0]+1, label[1]+14),
                       args.label_width-2, args.label_height-14,
                       ["B.Fab"], ["B.Cu", "*.Cu", "B.SilkS"], sides["B.Cu"],
                       args.flip_vert)

    cr.show_page()


if __name__ == "__main__":
    main()
