'use strict';

class TimelineEngine {
    /**
     * @param  {Theme} theme
     * @param  {Renderer} renderer
     * @param  {Legend} legend
     * @param  {Element} threshold
     * @param  {Object} request
     * @param  {Number} eventHeight
     * @param  {Number} horizontalMargin
     */
    constructor(theme, renderer, legend, threshold, request, eventHeight = 36, horizontalMargin = 10) {
        this.theme = theme;
        this.renderer = renderer;
        this.legend = legend;
        this.threshold = threshold;
        this.request = request;
        this.scale = renderer.width / request.end;
        this.eventHeight = eventHeight;
        this.horizontalMargin = horizontalMargin;
        this.labelY = Math.round(this.eventHeight * 0.48);
        this.periodY = Math.round(this.eventHeight * 0.66);
        this.FqcnMatcher = /\\([^\\]+)$/i;
        this.origin = null;

        this.createEventElements = this.createEventElements.bind(this);
        this.createBackground = this.createBackground.bind(this);
        this.createPeriod = this.createPeriod.bind(this);
        this.render = this.render.bind(this);
        this.renderEvent = this.renderEvent.bind(this);
        this.renderPeriod = this.renderPeriod.bind(this);
        this.onResize = this.onResize.bind(this);
        this.isActive = this.isActive.bind(this);

        this.threshold.addEventListener('change', this.render);
        this.legend.addEventListener('change', this.render);

        window.addEventListener('resize', this.onResize);

        this.createElements();
        this.render();
    }

    onResize() {
        this.renderer.measure();
        this.setScale(this.renderer.width / this.request.end);
    }

    setScale(scale) {
        if (scale !== this.scale) {
            this.scale = scale;
            this.render();
        }
    }

    createElements() {
        this.origin = this.renderer.setFullVerticalLine(this.createBorder(), 0);
        this.renderer.add(this.origin);

        this.request.events
            .filter(event => event.category === 'section')
            .map(this.createBackground)
            .forEach(this.renderer.add);

        this.request.events
            .map(this.createEventElements)
            .forEach(this.renderer.add);
    }

    createBackground(event) {
        const subrequest = event.name === '__section__.child';
        const background = this.renderer.create('rect', subrequest ? 'timeline-subrequest' : 'timeline-border');

        event.elements = Object.assign(event.elements || {}, { background });

        return background;
    }

    createEventElements(event) {
        const { name, category, duration, memory, periods } = event;
        const border = this.renderer.setFullHorizontalLine(this.createBorder(), 0);
        const lines = periods.map(period => this.createPeriod(period, category));
        const label = this.createLabel(this.getShortName(name), duration, memory, periods[0]);
        const title = this.renderer.createTitle(name);
        const group = this.renderer.group([title, border, label].concat(lines), this.theme.getCategoryColor(event.category));

        event.elements = Object.assign(event.elements || {}, { group, label, border });

        this.legend.add(event.category)

        return group;
    }

    createLabel(name, duration, memory, period) {
        const label = this.renderer.createText(name, period.start * this.scale, this.labelY, 'timeline-label');
        const sublabel = this.renderer.createTspan(`  ${duration} ms / ${memory} MiB`, 'timeline-sublabel');

        label.appendChild(sublabel);

        return label;
    }

    createPeriod(period, category) {
        const timeline = this.renderer.createPath(null, 'timeline-period', this.theme.getCategoryColor(category));

        period.draw = category === 'section' ? this.renderer.setSectionLine : this.renderer.setPeriodLine;
        period.elements = Object.assign(period.elements || {}, { timeline });

        return timeline;
    }

    createBorder() {
        return this.renderer.createPath(null, 'timeline-border');
    }

    isActive(event) {
        const { duration, category } = event;

        return duration >= this.threshold.value && this.legend.isActive(category);
    }

    render() {
        const events = this.request.events.filter(this.isActive);
        const width = this.renderer.width + this.horizontalMargin * 2;
        const height = this.eventHeight * events.length;

        // Set view box
        this.renderer.setViewBox(-this.horizontalMargin, 0, width, height);

        // Show 0ms origin
        this.renderer.setFullVerticalLine(this.origin, 0);

        // Render all events
        this.request.events.forEach(event => this.renderEvent(event, events.indexOf(event)));
    }

    renderEvent(event, index) {
        const { name, category, duration, memory, periods, elements } = event;
        const { group, label, border, background } = elements;
        const visible = index >= 0;

        group.setAttribute('visibility', visible ? 'visible' : 'hidden');

        if (background) {
            background.setAttribute('visibility', visible ? 'visible' : 'hidden');

            if (visible) {
                const [min, max] = this.getEventLimits(event);

                this.renderer.setFullRectangle(background, min * this.scale, max * this.scale);
            }
        }

        if (visible) {
            // Position the group
            group.setAttribute('transform', `translate(0, ${index * this.eventHeight})`);

            // Update top border
            this.renderer.setFullHorizontalLine(border, 0);

            // render label and ensure it doesn't escape the viewport
            this.renderLabel(label, event);

            // Update periods
            periods.forEach(this.renderPeriod);
        }
    }

    renderLabel(label, event) {
        const width = this.getLabelWidth(label);
        const [min, max] = this.getEventLimits(event);
        const alignLeft = (min * this.scale) + width <= this.renderer.width;

        label.setAttribute('x', (alignLeft ? min : max) * this.scale);
        label.setAttribute('text-anchor', alignLeft ? 'start' : 'end');
    }

    renderPeriod(period) {
        const { elements, start, duration } = period;

        period.draw(elements.timeline, start * this.scale, this.periodY, Math.max(duration * this.scale, 1));
    }

    getLabelWidth(label) {
        if (typeof label.width === 'undefined') {
            label.width = label.getBBox().width;
        }

        return label.width;
    }

    getEventLimits(event) {
        if (typeof event.limits === 'undefined') {
            const { periods } = event;

            event.limits = [
                periods[0].start,
                periods[periods.length - 1].end
            ];
        }

        return event.limits;
    }

    getShortName(name) {
        const matches = this.FqcnMatcher.exec(name);

        if (matches) {
            return matches[1];
        }

        return name;
    }
}

class Legend {
    constructor(element, theme) {
        this.element = element;
        this.theme = theme;

        this.toggle = this.toggle.bind(this);
        this.createCategory = this.createCategory.bind(this);

        this.categories = [];
        this.theme.getDefaultCategories().forEach(this.createCategory);
    }

    add(category) {
        this.get(category).classList.add('present');
    }

    createCategory(category) {
        const element = document.createElement('button');
        element.className = `timeline-category active`;
        element.style.borderColor = this.theme.getCategoryColor(category);
        element.innerText = category;
        element.value = category;
        element.type = 'button';
        element.addEventListener('click', this.toggle);

        this.element.appendChild(element);

        this.categories.push(element);

        return element;
    }

    toggle(event) {
        event.target.classList.toggle('active');

        this.emit('change');
    }

    isActive(category) {
        return this.get(category).classList.contains('active');
    }

    get(category) {
        return this.categories.find(element => element.value === category) || this.createCategory(category);
    }

    emit(name) {
        this.element.dispatchEvent(new Event(name));
    }

    addEventListener(name, callback) {
        this.element.addEventListener(name, callback);
    }

    removeEventListener(name, callback) {
        this.element.removeEventListener(name, callback);
    }
}

class SvgRenderer {
    /**
     * @param  {SVGElement} element
     */
    constructor(element) {
        this.ns = 'http://www.w3.org/2000/svg';
        this.width = null;
        this.viewBox = {};
        this.element = element;

        this.add = this.add.bind(this);

        this.setViewBox(0, 0, 0, 0);
        this.measure();
    }

    setViewBox(x, y, width, height) {
        this.viewBox = { x, y, width, height };
        this.element.setAttribute('viewBox', `${x} ${y} ${width} ${height}`);
    }

    measure() {
        this.width = this.element.getBoundingClientRect().width;
    }

    add(element) {
        this.element.appendChild(element);
    }

    group(elements, className) {
        const group = this.create('g', className);

        elements.forEach(element => group.appendChild(element));

        return group;
    }

    setHorizontalLine(element, x, y, width) {
        element.setAttribute('d', `M${x},${y} h${width}`);

        return element;
    }

    setVerticalLine(element, x, y, height) {
        element.setAttribute('d', `M${x},${y} v${height}`);

        return element;
    }

    setFullHorizontalLine(element, y) {
        return this.setHorizontalLine(element, this.viewBox.x, y, this.viewBox.width);
    }

    setFullVerticalLine(element, x) {
        return this.setVerticalLine(element, x, this.viewBox.y, this.viewBox.height);
    }

    setFullRectangle(element, min, max) {
        element.setAttribute('x', min);
        element.setAttribute('y', this.viewBox.y);
        element.setAttribute('width', max - min);
        element.setAttribute('height', this.viewBox.height);
    }

    setSectionLine(element, x, y, width, height = 4, markerSize = 6) {
        const totalHeight = height + markerSize;
        const maxMarkerWidth = Math.min(markerSize, width / 2);
        const widthWithoutMarker = Math.max(0, width - (maxMarkerWidth * 2));

        element.setAttribute('d', `M${x},${y + totalHeight} v${-totalHeight} h${width} v${totalHeight} l${-maxMarkerWidth} ${-markerSize} h${-widthWithoutMarker} Z`);
    }

    setPeriodLine(element, x, y, width, height = 4, markerWidth = 2, markerHeight = 4) {
        const totalHeight = height + markerHeight;
        const maxMarkerWidth = Math.min(markerWidth, width);

        element.setAttribute('d', `M${x + maxMarkerWidth},${y + totalHeight} h${-maxMarkerWidth} v${-totalHeight} h${width} v${height} h${maxMarkerWidth-width}Z`);
    }

    createText(content, x, y, className) {
        const element = this.create('text', className);

        element.setAttribute('x', x);
        element.setAttribute('y', y);
        element.textContent = content;

        return element;
    }

    createTspan(content, className) {
        const element = this.create('tspan', className);

        element.textContent = content;

        return element;
    }

    createTitle(content) {
        const element = this.create('title');

        element.textContent = content;

        return element;
    }

    createPath(path = null, className = null, color = null) {
        const element = this.create('path', className);

        if (path) {
            element.setAttribute('d', path);
        }

        if (color) {
            element.setAttribute('fill', color);
        }

        return element;
    }

    create(name, className = null) {
        const element = document.createElementNS(this.ns, name);

        if (className) {
            element.setAttribute('class', className);
        }

        return element;
    }
}

class Theme {
    constructor(element) {
        this.reservedCategoryColors = {
            'default': '#737373',
            'section': '#a3a3a3',
            'event_listener': '#54aeff',
            'template': '#4ac26b',
            'doctrine': '#fd8c73',
            'messenger_middleware': '#ff8182',
            'controller.argument_value_resolver': '#c297ff',
            'http_client': '#d4a72c',
        };

        this.customCategoryColors = [
            '#d4a72c', // dark yellow
            '#ffaba8', // light red
            '#e6af05', // yellow
            '#6fdd8b', // light green
            '#76e3ea', // cyan
            '#a475f9', // light purple
        ];

        this.getCategoryColor = this.getCategoryColor.bind(this);
        this.getDefaultCategories = this.getDefaultCategories.bind(this);
    }

    getDefaultCategories() {
        return Object.keys(this.reservedCategoryColors);
    }

    getCategoryColor(category) {
        return this.reservedCategoryColors[category] || this.getRandomColor(category);
    }

    getRandomColor(category) {
        // instead of pure randomness, colors are assigned deterministically based on the
        // category name, to ensure that each custom category always displays the same color
        return this.customCategoryColors[this.hash(category) % this.customCategoryColors.length];
    }

    // copied from https://github.com/darkskyapp/string-hash
    hash(string) {
        var hash = 5381;
        var i = string.length;

        while(i) {
            hash = (hash * 33) ^ string.charCodeAt(--i);
        }

        return hash >>> 0;
    }
}
