(function (factory, window) {
    if (typeof define === 'function' && define.amd) {
        define(['leaflet'], factory);
    } else if (typeof exports === 'object') {
        module.exports = factory(require('leaflet'));
    }
    if (typeof window !== 'undefined' && window.L) {
        window.L.PolylineOffset = factory(L);
    }
}(function (L) {

function forEachPair(list, callback) {
    if (!list || list.length < 1) { return; }
    for (var i = 1, l = list.length; i < l; i++) {
        callback(list[i-1], list[i]);
    }
}

/**
Find the coefficients (a,b) of a line of equation y = a.x + b,
or the constant x for vertical lines
Return null if there's no equation possible
*/
function lineEquation(pt1, pt2) {
    if (pt1.x === pt2.x) {
        return pt1.y === pt2.y ? null : { x: pt1.x };
    }

    var a = (pt2.y - pt1.y) / (pt2.x - pt1.x);
    return {
        a: a,
        b: pt1.y - a * pt1.x,
    };
}

/**
Return the intersection point of two lines defined by two points each
Return null when there's no unique intersection
*/
function intersection(l1a, l1b, l2a, l2b) {
    var line1 = lineEquation(l1a, l1b);
    var line2 = lineEquation(l2a, l2b);

    if (line1 === null || line2 === null) {
        return null;
    }

    if (line1.hasOwnProperty('x')) {
        return line2.hasOwnProperty('x')
            ? null
            : {
                x: line1.x,
                y: line2.a * line1.x + line2.b,
            };
    }
    if (line2.hasOwnProperty('x')) {
        return {
            x: line2.x,
            y: line1.a * line2.x + line1.b,
        };
    }

    if (line1.a === line2.a) {
        return null;
    }

    var x = (line2.b - line1.b) / (line1.a - line2.a);
    return {
        x: x,
        y: line1.a * x + line1.b,
    };
}

function translatePoint(pt, dist, heading) {
    return {
        x: pt.x + dist * Math.cos(heading),
        y: pt.y + dist * Math.sin(heading),
    };
}

var PolylineOffset = {
    offsetPointLine: function(points, distance) {
        var offsetSegments = [];

        forEachPair(points, L.bind(function(a, b) {
            if (a.x === b.x && a.y === b.y) { return; }

            // angles in (-PI, PI]
            var segmentAngle = Math.atan2(a.y - b.y, a.x - b.x);
            var offsetAngle = segmentAngle - Math.PI/2;

            offsetSegments.push({
                offsetAngle: offsetAngle,
                original: [a, b],
                offset: [
                    translatePoint(a, distance, offsetAngle),
                    translatePoint(b, distance, offsetAngle)
                ]
            });
        }, this));

        return offsetSegments;
    },

    offsetPoints: function(pts, options) {
        var offsetSegments = this.offsetPointLine(L.LineUtil.simplify(pts, options.smoothFactor), options.offset);
        return this.joinLineSegments(offsetSegments, options.offset);
    },

    /**
    Join 2 line segments defined by 2 points each with a circular arc
    */
    joinSegments: function(s1, s2, offset) {
        // TODO: different join styles
        return this.circularArc(s1, s2, offset)
            .filter(function(x) { return x; })
    },

    joinLineSegments: function(segments, offset) {
        var joinedPoints = [];
        var first = segments[0];
        var last = segments[segments.length - 1];

        if (first && last) {
            joinedPoints.push(first.offset[0]);
            forEachPair(segments, L.bind(function(s1, s2) {
                joinedPoints = joinedPoints.concat(this.joinSegments(s1, s2, offset));
            }, this));
            joinedPoints.push(last.offset[1]);
        }

        return joinedPoints;
    },

    segmentAsVector: function(s) {
        return {
            x: s[1].x - s[0].x,
            y: s[1].y - s[0].y,
        };
    },

    getSignedAngle: function(s1, s2) {
        const a = this.segmentAsVector(s1);
        const b = this.segmentAsVector(s2);
        return Math.atan2(a.x * b.y - a.y * b.x, a.x * b.x + a.y * b.y);
    },

    /**
    Interpolates points between two offset segments in a circular form
    */
    circularArc: function(s1, s2, distance) {
        // if the segments are the same angle,
        // there should be a single join point
        if (s1.offsetAngle === s2.offsetAngle) {
            return [s1.offset[1]];
        }

        const signedAngle = this.getSignedAngle(s1.offset, s2.offset);
        // for inner angles, just find the offset segments intersection
        if ((signedAngle * distance > 0) &&
            (signedAngle * this.getSignedAngle(s1.offset, [s1.offset[0], s2.offset[1]]) > 0)) {
            return [intersection(s1.offset[0], s1.offset[1], s2.offset[0], s2.offset[1])];
        }

        // draws a circular arc with R = offset distance, C = original meeting point
        var points = [];
        var center = s1.original[1];
        // ensure angles go in the anti-clockwise direction
        var rightOffset = distance > 0;
        var startAngle = rightOffset ? s2.offsetAngle : s1.offsetAngle;
        var endAngle = rightOffset ? s1.offsetAngle : s2.offsetAngle;
        // and that the end angle is bigger than the start angle
        if (endAngle < startAngle) {
            endAngle += Math.PI * 2;
        }
        var step = Math.PI / 8;
        for (var alpha = startAngle; alpha < endAngle; alpha += step) {
            points.push(translatePoint(center, distance, alpha));
        }
        points.push(translatePoint(center, distance, endAngle));

        return rightOffset ? points.reverse() : points;
    }
}

// Modify the L.Polyline class by overwriting the projection function
L.Polyline.include({
    _projectLatlngs: function (latlngs, result, projectedBounds) {
        var isFlat = latlngs.length > 0 && latlngs[0] instanceof L.LatLng;

        if (isFlat) {
            var ring = latlngs.map(L.bind(function(ll) {
                var point = this._map.latLngToLayerPoint(ll);
                if (projectedBounds) {
                    projectedBounds.extend(point);
                }
                return point;
            }, this));

            // Offset management hack ---
            if (this.options.offset) {
                ring = L.PolylineOffset.offsetPoints(ring, this.options);
            }
            // Offset management hack END ---

            result.push(ring.map(function (xy) {
                    return L.point(xy.x, xy.y);
                }));
        } else {
            latlngs.forEach(L.bind(function(ll) {
                this._projectLatlngs(ll, result, projectedBounds);
            }, this));
        }
    }
});

L.Polyline.include({
    setOffset: function(offset) {
        this.options.offset = offset;
        this.redraw();
        return this;
    }
});

return PolylineOffset;

}, window));
