const m = require('makerjs')
const u = require('./utils')
const a = require('./assert')
const o = require('./operation')
const Point = require('./point')
const prep = require('./prepare')
const anchor = require('./anchor').parse
const filter = require('./filter').parse

const binding = (base, bbox, point, units) => {

    let bind = a.trbl(point.meta.bind || 0, `${point.meta.name}.bind`)(units)
    // if it's a mirrored key, we swap the left and right bind values
    if (point.meta.mirrored) {
        bind = [bind[0], bind[3], bind[2], bind[1]]
    }

    const bt = Math.max(bbox.high[1], 0) + Math.max(bind[0], 0)
    const br = Math.max(bbox.high[0], 0) + Math.max(bind[1], 0)
    const bd = Math.min(bbox.low[1], 0) - Math.max(bind[2], 0)
    const bl = Math.min(bbox.low[0], 0) - Math.max(bind[3], 0)

    if (bind[0] || bind[1]) base = u.union(base, u.rect(br, bt))
    if (bind[1] || bind[2]) base = u.union(base, u.rect(br, -bd, [0, bd]))
    if (bind[2] || bind[3]) base = u.union(base, u.rect(-bl, -bd, [bl, bd]))
    if (bind[3] || bind[0]) base = u.union(base, u.rect(-bl, bt, [bl, 0]))

    return base
}

const rectangle = (config, name, points, outlines, units) => {

    // prepare params
    a.unexpected(config, `${name}`, ['size', 'corner', 'bevel'])
    const size = a.wh(config.size, `${name}.size`)(units)
    const rec_units = prep.extend({
        sx: size[0],
        sy: size[1]
    }, units)
    const corner = a.sane(config.corner || 0, `${name}.corner`, 'number')(rec_units)
    const bevel = a.sane(config.bevel || 0, `${name}.bevel`, 'number')(rec_units)

    // return shape function and its units
    return [() => {

        const error = (dim, val) => `Rectangle for "${name}" isn't ${dim} enough for its corner and bevel (${val} - 2 * ${corner} - 2 * ${bevel} <= 0)!`
        const [w, h] = size
        const mod = 2 * (corner + bevel)
        const cw = w - mod
        a.assert(cw >= 0, error('wide', w))
        const ch = h - mod
        a.assert(ch >= 0, error('tall', h))

        let rect = new m.models.Rectangle(cw, ch)
        if (bevel) {
            rect = u.poly([
                [-bevel, 0],
                [-bevel, ch],
                [0, ch + bevel],
                [cw, ch + bevel],
                [cw + bevel, ch],
                [cw + bevel, 0],
                [cw, -bevel],
                [0, -bevel]
            ])
        }
        if (corner > 0) rect = m.model.outline(rect, corner, 0)
        rect = m.model.moveRelative(rect, [-cw/2, -ch/2])
        const bbox = {high: [w/2, h/2], low: [-w/2, -h/2]}

        return [rect, bbox]
    }, rec_units]
}

const circle = (config, name, points, outlines, units) => {

    // prepare params
    a.unexpected(config, `${name}`, ['radius'])
    const radius = a.sane(config.radius, `${name}.radius`, 'number')(units)
    const circ_units = prep.extend({
        r: radius
    }, units)

    // return shape function and its units
    return [() => {
        let circle = u.circle([0, 0], radius)
        const bbox = {high: [radius, radius], low: [-radius, -radius]}
        return [circle, bbox]
    }, circ_units]
}

const polygon = (config, name, points, outlines, units) => {

    // prepare params
    a.unexpected(config, `${name}`, ['points'])
    const poly_points = a.sane(config.points, `${name}.points`, 'array')()

    // return shape function and its units
    return [point => {
        const parsed_points = []
        // the poly starts at [0, 0] as it will be positioned later
        // but we keep the point metadata for potential mirroring purposes
        let last_anchor = new Point(0, 0, 0, point.meta)
        let poly_index = -1
        for (const poly_point of poly_points) {
            const poly_name = `${name}.points[${++poly_index}]`
            last_anchor = anchor(poly_point, poly_name, points, last_anchor)(units)
            parsed_points.push(last_anchor.p)
        }
        let poly = u.poly(parsed_points)
        const bbox = u.bbox(parsed_points)
        return [poly, bbox]
    }, units]
}

const outline = (config, name, points, outlines, units) => {

    // prepare params
    a.unexpected(config, `${name}`, ['name', 'origin'])
    a.assert(outlines[config.name], `Field "${name}.name" does not name an existing outline!`)
    const origin = anchor(config.origin || {}, `${name}.origin`, points)(units)
    
    // return shape function and its units
    return [() => {
        let o = u.deepcopy(outlines[config.name])
        o = origin.unposition(o)
        const bbox = m.measure.modelExtents(o)
        return [o, bbox]
    }, units]
}

const whats = {
    rectangle,
    circle,
    polygon,
    outline
}

const expand_shorthand = (config, name, units) => {
    if (a.type(config.expand)(units) == 'string') {
        const prefix = config.expand.slice(0, -1)
        const suffix = config.expand.slice(-1)
        const valid_suffixes = [')', '>', ']']
        a.assert(valid_suffixes.includes(suffix), `If field "${name}" is a string, ` +
            `it should end with one of [${valid_suffixes.map(s => `'${s}'`).join(', ')}]!`)
        config.expand = prefix
        config.joints = config.joints || valid_suffixes.indexOf(suffix)
    }
    
    if (a.type(config.joints)(units) == 'string') {
        if (config.joints == 'round') config.joints = 0
        if (config.joints == 'pointy') config.joints = 1
        if (config.joints == 'beveled') config.joints = 2
    }
}

exports.parse = (config, points, units) => {

    // output outlines will be collected here
    const outlines = {}

    // the config must be an actual object so that the exports have names
    config = a.sane(config, 'outlines', 'object')()
    for (let [outline_name, parts] of Object.entries(config)) {

        // placeholder for the current outline
        outlines[outline_name] = {models: {}}

        // each export can consist of multiple parts
        // either sub-objects or arrays are fine...
        if (a.type(parts)() == 'array') {
            parts = {...parts}
        }
        parts = a.sane(parts, `outlines.${outline_name}`, 'object')()
        
        for (let [part_name, part] of Object.entries(parts)) {
            
            const name = `outlines.${outline_name}.${part_name}`

            // string part-shortcuts are expanded first
            if (a.type(part)() == 'string') {
                part = o.operation(part, {outline: Object.keys(outlines)})
            }

            // process keys that are common to all part declarations
            const operation = u[a.in(part.operation || 'add', `${name}.operation`, ['add', 'subtract', 'intersect', 'stack'])]
            const what = a.in(part.what || 'outline', `${name}.what`, ['rectangle', 'circle', 'polygon', 'outline'])
            const bound = !!part.bound
            const asym = a.asym(part.asym || 'source', `${name}.asym`)

            // `where` is delayed until we have all, potentially what-dependent units
            // default where is [0, 0], as per filter parsing
            const original_where = part.where // need to save, so the delete's don't get rid of it below
            const where = units => filter(original_where, `${name}.where`, points, units, asym)
            
            const original_adjust = part.adjust // same as above
            const fillet = a.sane(part.fillet || 0, `${name}.fillet`, 'number')(units)
            expand_shorthand(part, `${name}.expand`, units)
            const expand = a.sane(part.expand || 0, `${name}.expand`, 'number')(units)
            const joints = a.in(a.sane(part.joints || 0, `${name}.joints`, 'number')(units), `${name}.joints`, [0, 1, 2])
            const scale = a.sane(part.scale || 1, `${name}.scale`, 'number')(units)

            // these keys are then removed, so ops can check their own unexpected keys without interference
            delete part.operation
            delete part.what
            delete part.bound
            delete part.asym
            delete part.where
            delete part.adjust
            delete part.fillet
            delete part.expand
            delete part.joints
            delete part.scale

            // a prototype "shape" maker (and its units) are computed
            const [shape_maker, shape_units] = whats[what](part, name, points, outlines, units)
            const adjust = start => anchor(original_adjust || {}, `${name}.adjust`, points, start)(shape_units)

            // and then the shape is repeated for all where positions
            for (const w of where(shape_units)) {
                const point = adjust(w.clone())
                let [shape, bbox] = shape_maker(point) // point is passed for mirroring metadata only...
                if (bound) {
                    shape = binding(shape, bbox, point, shape_units)
                }
                shape = point.position(shape) // ...actual positioning happens here
                outlines[outline_name] = operation(outlines[outline_name], shape)
            }

            if (scale !== 1) {
                outlines[outline_name] = m.model.scale(outlines[outline_name], scale)
            }
    
            if (expand) {
                outlines[outline_name] = m.model.outline(
                    outlines[outline_name], Math.abs(expand), joints, (expand < 0), {farPoint: u.farPoint}
                )
            }

            if (fillet) {
                for (const [index, chain] of m.model.findChains(outlines[outline_name]).entries()) {
                    outlines[outline_name].models[`fillet_${part_name}_${index}`] = m.chain.fillet(chain, fillet)
                }
            }
        }

        // final adjustments
        m.model.originate(outlines[outline_name])
        m.model.simplify(outlines[outline_name])

    }

    return outlines
}   
