/**
 * This file is part of Satie music engraver <https://github.com/jnetterf/satie>.
 * Copyright (C) Joshua Netterfield <joshua.ca> 2015 - present.
 * 
 * Satie is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as
 * published by the Free Software Foundation, either version 3 of the
 * License, or (at your option) any later version.
 * 
 * Satie is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Affero General Public License for more details.
 * 
 * You should have received a copy of the GNU Affero General Public License
 * along with Satie.  If not, see <http://www.gnu.org/licenses/>.
 */

/**
 * @file models/musicxml/import.ts tools for converting MXMLJSON to SatieJSON
 */

import {ScoreTimewise, Attributes, Note, Backup, Forward, Time, Direction, parseScore}
    from "musicxml-interfaces";
import {buildNote} from "musicxml-interfaces/builders";
import {map, reduce, some, filter, minBy, times, every, forEach, startsWith, endsWith} from "lodash";
import * as invariant from "invariant";

import {Document} from "./document";

import {IMeasure, IMeasurePart, IModel, Type} from "./document";

import {IFactory} from "./private_factory";
import {ILayoutOptions} from "./private_layoutOptions";
import {MAX_SAFE_INTEGER} from "./private_util";
import {IChord, barDivisionsDI, divisions as calcDivisions} from "./private_chordUtil";
import {scoreParts} from "./private_part";
import {lcm} from "./private_util";
import {requireFont, whenReady} from "./private_fontManager";

import validate from "./engine_processors_validate";
import ScoreHeader from "./engine_scoreHeader";
import {makeFactory} from "./engine_setup";

/*---- Exports ----------------------------------------------------------------------------------*/

export function stringToDocument(src: string, factory: IFactory) {
    let mxmljson = parseScore(src);
    if ((mxmljson as any).error) {
        throw (mxmljson as any).error;
    }
    let document = timewiseStructToDocument(mxmljson, factory);
    if (document.error) {
        throw document.error;
    }

    let contextOptions: ILayoutOptions = {
        attributes: null,
        document,
        fixup: null,
        header: document.header,
        lineCount: NaN, // YYY
        lineIndex: NaN, // YYY
        measures: document.measures,
        modelFactory: factory,
        postprocessors: [],
        preprocessors: factory.preprocessors,
        preview: false,
        print: null,
        singleLineMode: false,
    };
    validate(contextOptions);
    ScoreHeader.prototype.overwriteEncoding.call(document.header);

    return document;
}

/**
 * Converts a timewise MXMLJSON score to an uninitialized Satie score.
 * See also Models.importXML.
 * 
 * @param score produced by github.com/jnetterf/musicxml-interfaces
 * @returns A structure that can be consumed by a score. If an error occurred
 *          error will be set, and all other properties will be null.
 */
export function timewiseStructToDocument(score: ScoreTimewise, factory: IFactory): Document {
    try {
        let header = _extractMXMLHeader(score);
        let partData = _extractMXMLPartsAndMeasures(score, factory);
        if (partData.error) {
            return new Document(null, null, null, null, new Error(partData.error));
        }

        return new Document(header, partData.measures, partData.parts, factory);
    } catch (error) {
        return new Document(null, null, null, null, error);
    }
}

/*---- Private ----------------------------------------------------------------------------------*/

export function _extractMXMLHeader(m: ScoreTimewise): ScoreHeader {
    let header = new ScoreHeader({
        credits: m.credits,
        defaults: m.defaults,
        identification: m.identification,
        movementNumber: m.movementNumber,
        movementTitle: m.movementTitle,
        partList: m.partList,
        work: m.work
    });

    // Add credits to help exporters don't record credits, but do record movementTitle.
    if ((!header.credits || !header.credits.length) && header.movementTitle) {
        header.title = header.movementTitle;
    }

    return header;
}

export function _extractMXMLPartsAndMeasures(input: ScoreTimewise, factory: IFactory):
        {measures?: IMeasure[]; parts?: string[]; error?: string} {

    let parts: string[] = map(scoreParts(input.partList), inPart => inPart.id);
    let createModel: typeof factory.create = factory.create.bind(factory);

    // TODO/STOPSHIP - sync division count in each measure
    let divisions = 768; // XXX: lilypond-regression 41g.xml does not specify divisions
    let gStaves = 0;
    let chordBeingBuilt: IChord = null;
    let lastAttribs: Attributes = null;
    let maxVoice = 0;

    let measures: IMeasure[] = map(input.measures,
            (inMeasure, measureIdx) => {

        let measure = {
            idx: measureIdx,
            implicit: inMeasure.implicit,
            nonControlling: inMeasure.nonControlling,
            number: inMeasure.number,
            parts: <{[key: string]: IMeasurePart}> {},
            uuid: Math.floor(Math.random() * MAX_SAFE_INTEGER),
            width: inMeasure.width,
            version: 0
        };

        if (Object.keys(inMeasure.parts).length === 1 && "" in inMeasure.parts) {
            // See lilypond-regression >> 41g.
            inMeasure.parts[parts[0]] = inMeasure.parts[""];
            delete inMeasure.parts[""];
        }
        let linkedParts = map(inMeasure.parts, (val, key) => {
            if (!some(parts, part => part === key)) {
                // See lilypond-regression >> 41h.
                return null;
            }
            let output: IMeasurePart = {
                staves: [],
                voices: []
            };
            invariant(!(key in measure.parts), "Duplicate part ID %s", key);
            measure.parts[key] = output;
            invariant(!!key, "Part ID must be defined");

            return {
                division: 0,
                divisionPerStaff: <number[]>[],
                divisionPerVoice: <number[]>[],
                id: key,
                idx: 0,
                input: val,
                lastNote: <IChord> null,
                output: output,
                times: <Time[]> [{
                    beatTypes: [4],
                    beats: ["4"]
                }]
            };
        });

        linkedParts = filter(linkedParts, p => !!p);

        let commonDivisions = reduce(linkedParts, (memo, part) => {
            return reduce(part.input, (memo, input) => {
                if (input._class === "Attributes" && input.divisions) {
                    return lcm(memo, input.divisions);
                }
                return memo;
            }, memo);
        }, divisions);

        // Lets normalize divisions here.
        forEach(linkedParts, part => {
            let previousDivisions = divisions;
            forEach(part.input, input => {
                if (input.divisions) {
                    previousDivisions = input.divisions;
                    input.divisions = commonDivisions;
                }
                if (input.count) {
                    input.count *= commonDivisions / previousDivisions;
                }
                if (input.duration) {
                    input.duration *= commonDivisions / previousDivisions;
                }
            });
        });

        let target = linkedParts[0];
        // Create base structure
        while (!done()) {
            // target is accessed outside loop in syncStaffDivisions
            target = minBy(linkedParts, part => part.idx === part.input.length ?
                    MAX_SAFE_INTEGER : part.division);
            invariant(!!target, "Target not specified");
            let input = target.input[target.idx];
            let prevStaff = 1;
            switch (input._class) {
                case "Note":
                    let note: Note = input;

                    // TODO: is this the case even if voice/staff don't match up?
                    if (!!note.chord) {
                        invariant(!!chordBeingBuilt, "Cannot add chord to a previous note without a chord");
                        chordBeingBuilt.push(note);
                    } else {
                        // Notes go in the voice context.
                        let voice = note.voice || 1;
                        let staff = note.staff || 1;
                        prevStaff = staff;
                        if (!(voice in target.output.voices)) {
                            createVoice(voice, target.output);
                            maxVoice = Math.max(voice, maxVoice);
                        }
                        // Make sure there is a staff segment reserved for the given staff
                        if (!(staff in target.output.staves)) {
                            createStaff(staff, target.output);
                        }

                        // Check target voice division and add spacing if needed
                        target.divisionPerVoice[voice] = target.divisionPerVoice[voice] || 0;
                        invariant(target.division >= target.divisionPerVoice[voice],
                                "Ambiguous voice timing: all voices must be monotonic.");
                        if (target.divisionPerVoice[voice] < target.division) {
                            // Add rest
                            let divisionsInVoice = target.divisionPerVoice[voice];
                            // This beautiful IIFE is needed because of undefined behaviour for
                            // block-scoped variables in modules.
                            let restModel = ((divisionsInVoice: number) =>
                                    factory.fromSpec(
                                        buildNote(note => note
                                            .printObject(false)
                                        .rest({})
                                        .duration(target.division - divisionsInVoice)))
                                    )
                                (divisionsInVoice);

                            let division = target.divisionPerVoice[voice];
                            restModel[0].duration = target.division - division;
                            target.output.voices[voice].push(restModel);
                            target.divisionPerVoice[voice] = target.division;
                        }

                        // Add the note to the voice segment and register it as the
                        // last inserted note
                        let newNote = factory.fromSpec(input);
                        target.output.voices[voice].push(newNote);
                        chordBeingBuilt = newNote;

                        // Update target division
                        let divs: number;
                        try {
                            divs = calcDivisions([input], {
                                time: target.times[0],
                                divisions
                            });
                        } catch(err) {
                            console.warn("Guessing count from duration");
                            divs = input.duration;
                        }
                        target.divisionPerVoice[voice] += divs;
                        target.division += divs;
                    }

                    break;
                case "Attributes":
                case "Barline":
                case "Direction":
                case "FiguredBass":
                case "Grouping":
                case "Harmony":
                case "Print":
                case "Sound":
                    const staff = input._class === "Harmony" && !input.staff ? prevStaff :
                        input.staff || 1; // Explodes to all staves at a later point.
                    prevStaff = staff;
                    if (!(staff in target.output.staves)) {
                        target.output.staves[staff] = <any> [];
                        target.output.staves[staff].owner = staff;
                        target.output.staves[staff].ownerType = "staff";
                    }
                    let newModel = factory.fromSpec(input);

                    // Check if this is metadata:
                    if (input._class === "Direction") {
                        let direction = newModel as any as Direction;
                        let words = direction.directionTypes.length === 1 && direction.directionTypes[0].words;
                        if (words && words.length === 1) {
                            let maybeMeta = words[0].data.trim();
                            if (startsWith(maybeMeta, "SATIE_SONG_META = ") && endsWith(maybeMeta, ";")) {
                                // let songMeta = JSON.parse(maybeMeta.replace(/^SATIE_SONG_META = /, "").replace(/;$/, ""));
                                break; // Do not actually import as direction
                            } else if (startsWith(maybeMeta, "SATIE_MEASURE_META = ") && endsWith(maybeMeta, ";")) {
                                let measureMeta = JSON.parse(maybeMeta.replace(/^SATIE_MEASURE_META = /, "").replace(/;$/, ""));
                                measure.uuid = measureMeta.uuid;
                                break; // Do not actually import as direction
                            }
                        }
                    }

                    syncAppendStaff(staff, newModel, input.divisions || divisions);
                    if (input._class === "Attributes") {
                        lastAttribs = <Attributes> input;
                        divisions = lastAttribs.divisions || divisions;
                        let oTimes = lastAttribs.times;
                        if (oTimes && oTimes.length) {
                            target.times = oTimes;
                        }
                        let staves = lastAttribs.staves || 1;
                        gStaves = staves;
                        times(staves, staffMinusOne => {
                            let staff = staffMinusOne + 1;
                            if (!(staff in target.output.staves)) {
                                createStaff(staff, target.output);
                            }
                        });
                    }
                    break;
                case "Forward":
                    let forward = <Forward> input;
                    forEach(target.output.staves, (staff, staffIdx) => {
                        syncAppendStaff(staffIdx, null, input.divisions || divisions);
                    });
                    target.division += forward.duration;
                    break;
                case "Backup":
                    let backup = <Backup> input;
                    forEach(target.output.staves, (staff, staffIdx) => {
                        syncAppendStaff(staffIdx, null, input.divisions || divisions);
                    });
                    target.division -= backup.duration;
                    break;
                default:
                    invariant(false, "Unknown type %s", input._class);
                    break;
            }
            ++target.idx;
        }

        // Finish up

        times(gStaves, staffMinusOne => {
            let staff = staffMinusOne + 1;
            if (!(staff in target.output.staves)) {
                createStaff(staff, target.output);
                maxVoice++;
                let voice = createVoice(maxVoice, target.output);
                let newNote: IChord = <any> factory.create(Type.Chord);
                newNote.push({
                    duration: barDivisionsDI(lastAttribs.times[0], lastAttribs.divisions),
                    rest: {},
                    staff: staff,
                    voice: maxVoice
                });
                voice.push(<any>newNote);
            }
        });

        forEach(linkedParts, part => {
            // Note: target is 'var'-scoped!
            target = part;

            // Set divCounts of final elements in staff segments and divisions of all segments
            forEach(target.output.staves, (staff, staffIdx) => {
                syncAppendStaff(staffIdx, null, divisions);

                let segment = target.output.staves[staffIdx];
                if (segment) {
                    segment.divisions = divisions;
                }
            });
            forEach(target.output.voices, (voice, voiceIdx) => {
                let segment = target.output.voices[voiceIdx];
                if (segment) {
                    segment.divisions = divisions;
                }
            });
        });

        function syncAppendStaff(staff: number, model: IModel, localDivisions: number) {
            let ratio = localDivisions / divisions || 1;
            const divCount = ratio * (target.division - (target.divisionPerStaff[staff] || 0));
            let segment = target.output.staves[staff];
            invariant(!!model && !!segment || !model, "Unknown staff %s");

            if (divCount > 0) {
                if (segment) {
                    if (segment.length) {
                        let model = segment[segment.length - 1];
                        model.divCount = model.divCount || 0;
                        model.divCount += divCount;
                    } else {
                        let model = createModel(Type.Spacer, {
                            divCount,
                            staff
                        });
                        segment.push(model);
                    }
                }
                target.divisionPerStaff[staff] = target.division;
            }

            if (model) {
                if (divCount >= 0 || !divCount) {
                    segment.push(model);
                } else {
                    let offset = divCount;
                    let spliced = false;
                    for (let i = segment.length - 1; i >= 0; --i) {
                        offset += segment[i].divCount;
                        if (offset >= 0) {
                            model.divCount = segment[i].divCount - offset;
                            invariant(isFinite(model.divCount), "Invalid loaded divCount");
                            segment[i].divCount = offset;
                            segment.splice(i + 1, 0, model);
                            spliced = true;
                            break;
                        }
                    }
                    invariant(spliced, "Could not insert %s", model);
                }
            }
        }

        function done() {
            return every(linkedParts, part => {
                return part.idx === part.input.length;
            });
        }

        return measure;
    });

    return {
        measures: measures,
        parts: parts
    };
}

function createVoice(voice: number, output: IMeasurePart) {
    output.voices[voice] = <any> [];
    output.voices[voice].owner = voice;
    output.voices[voice].ownerType = "voice";
    return output.voices[voice];
}

function createStaff(staff: number, output: IMeasurePart) {
    output.staves[staff] = <any> [];
    output.staves[staff].owner = staff;
    output.staves[staff].ownerType = "staff";
    return output.staves[staff];
}

/**
 * Parses a MusicXML document and returns a Document.
 */
export function importXML(src: string,
        cb: (error: Error, document?: Document, factory?: IFactory) => void) {
    requireFont("Bravura", "root://bravura/otf/Bravura.otf");
    requireFont("Alegreya", "root://alegreya/Alegreya-Regular.ttf");
    requireFont("Alegreya", "root://alegreya/Alegreya-Bold.ttf", "bold");
    whenReady((err) => {
        if (err) {
            cb(err);
        } else {
            try {
                let factory = makeFactory();
                cb(null, stringToDocument(src, factory), factory);
            } catch (err) {
                cb(err);
            }
        }
    });
}
