import * as opentype from 'opentype.js';
import * as lru from 'lru-cache';

import { Font, LigatureData, FlattenedLookupTree, LookupTree, Options } from './types';
import mergeTrees from './merge';
import walkTree from './walk';
import mergeRange from './mergeRange';

import buildTreeGsubType6Format1 from './processors/6-1';
import buildTreeGsubType6Format2 from './processors/6-2';
import buildTreeGsubType6Format3 from './processors/6-3';
import buildTreeGsubType8Format1 from './processors/8-1';
import flatten from './flatten';

class FontImpl implements Font {
    private _font: opentype.Font;
    private _lookupTrees: { tree: FlattenedLookupTree; processForward: boolean; }[] = [];
    private _glyphLookups: { [glyphId: string]: number[] } = {};
    private _cache?: lru<string, LigatureData | [number, number][]>;

    constructor(font: opentype.Font, options: Required<Options>) {
        this._font = font;

        if (options.cacheSize > 0) {
            this._cache = new lru({
                max: options.cacheSize,
                length: ((val: LigatureData | [number, number][], key: string) => key.length) as any
            });
        }

        const caltFeatures = this._font.tables.gsub && this._font.tables.gsub.features.filter(f => f.tag === 'calt') || [];
        const lookupIndices: number[] = caltFeatures
            .reduce((acc, val) => [...acc, ...val.feature.lookupListIndexes], []);

        const allLookups = this._font.tables.gsub && this._font.tables.gsub.lookups || [];
        const lookupGroups = allLookups.filter((l, i) => lookupIndices.some(idx => idx === i));

        for (const [index, lookup] of lookupGroups.entries()) {
            const trees: LookupTree[] = [];
            switch (lookup.lookupType) {
                case 6:
                    for (const [index, table] of lookup.subtables.entries()) {
                        switch (table.substFormat) {
                            case 1:
                                trees.push(buildTreeGsubType6Format1(table, allLookups, index));
                                break;
                            case 2:
                                trees.push(buildTreeGsubType6Format2(table, allLookups, index));
                                break;
                            case 3:
                                trees.push(buildTreeGsubType6Format3(table, allLookups, index));
                                break;
                        }
                    }
                    break;
                case 8:
                    for (const [index, table] of lookup.subtables.entries()) {
                        trees.push(buildTreeGsubType8Format1(table, index));
                    }
                    break;
            }

            const tree = flatten(mergeTrees(trees));

            this._lookupTrees.push({
                tree,
                processForward: lookup.lookupType !== 8
            });

            for (const glyphId of Object.keys(tree)) {
                if (!this._glyphLookups[glyphId]) {
                    this._glyphLookups[glyphId] = [];
                }

                this._glyphLookups[glyphId].push(index);
            }
        }
    }

    findLigatures(text: string): LigatureData {
        const cached = this._cache && this._cache.get(text);
        if (cached && !Array.isArray(cached)) {
            return cached;
        }

        const glyphIds: number[] = [];
        for (const char of text) {
            glyphIds.push(this._font.charToGlyphIndex(char));
        }

        // If there are no lookup groups, there's no point looking for
        // replacements. This gives us a minor performance boost for fonts with
        // no ligatures
        if (this._lookupTrees.length === 0) {
            return {
                inputGlyphs: glyphIds,
                outputGlyphs: glyphIds,
                contextRanges: []
            };
        }

        const result = this._findInternal(glyphIds.slice());
        const finalResult: LigatureData = {
            inputGlyphs: glyphIds,
            outputGlyphs: result.sequence,
            contextRanges: result.ranges
        };
        if (this._cache) {
            this._cache.set(text, finalResult);
        }

        return finalResult;
    }

    findLigatureRanges(text: string): [number, number][] {
        // Short circuit the process if there are no possible ligatures in the
        // font
        if (this._lookupTrees.length === 0) {
            return [];
        }

        const cached = this._cache && this._cache.get(text);
        if (cached) {
            return Array.isArray(cached) ? cached : cached.contextRanges;
        }

        const glyphIds: number[] = [];
        for (const char of text) {
            glyphIds.push(this._font.charToGlyphIndex(char));
        }

        const result = this._findInternal(glyphIds);
        if (this._cache) {
            this._cache.set(text, result.ranges);
        }

        return result.ranges;
    }

    private _findInternal(sequence: number[]): { sequence: number[]; ranges: [number, number][]; } {
        const ranges: [number, number][] = [];

        let nextLookup = this._getNextLookup(sequence, 0);
        while (nextLookup.index !== null) {
            const lookup = this._lookupTrees[nextLookup.index];
            if (lookup.processForward) {
                let lastGlyphIndex = nextLookup.last;
                for (let i = nextLookup.first; i < lastGlyphIndex; i++) {
                    const result = walkTree(lookup.tree, sequence, i, i);
                    if (result) {
                        for (let j = 0; j < result.substitutions.length; j++) {
                            const sub = result.substitutions[j];
                            if (sub !== null) {
                                sequence[i + j] = sub;
                            }
                        }

                        mergeRange(
                            ranges,
                            result.contextRange[0] + i,
                            result.contextRange[1] + i
                        );

                        // Substitutions can end up extending the search range
                        if (i + result.length >= lastGlyphIndex) {
                            lastGlyphIndex = i + result.length + 1;
                        }

                        i += result.length - 1;
                    }
                }
            } else {
                // We don't need to do the lastGlyphIndex tracking here because
                // reverse processing isn't allowed to replace more than one
                // character at a time.
                for (let i = nextLookup.last - 1; i >= nextLookup.first; i--) {
                    const result = walkTree(lookup.tree, sequence, i, i);
                    if (result) {
                        for (let j = 0; j < result.substitutions.length; j++) {
                            const sub = result.substitutions[j];
                            if (sub !== null) {
                                sequence[i + j] = sub;
                            }
                        }

                        mergeRange(
                            ranges,
                            result.contextRange[0] + i,
                            result.contextRange[1] + i
                        );

                        i -= result.length - 1;
                    }
                }
            }

            nextLookup = this._getNextLookup(sequence, nextLookup.index + 1);
        }

        return { sequence, ranges };
    }

    /**
     * Returns the lookup and glyph range for the first lookup that might
     * contain a match.
     *
     * @param sequence Input glyph sequence
     * @param start The first input to try
     */
    private _getNextLookup(sequence: number[], start: number): { index: number | null; first: number; last: number; } {
        const result: { index: number | null; first: number; last: number; } = {
            index: null,
            first: Infinity,
            last: -1
        };

        // Loop through each glyph and find the first valid lookup for it
        for (let i = 0; i < sequence.length; i++) {
            const lookups = this._glyphLookups[sequence[i]];
            if (!lookups) {
                continue;
            }

            for (let j = 0; j < lookups.length; j++) {
                const lookupIndex = lookups[j];
                if (lookupIndex >= start) {
                    // Update the lookup information if it's the one we're
                    // storing or earlier than it.
                    if (result.index === null || lookupIndex <= result.index) {
                        result.index = lookupIndex;

                        if (result.first > i) {
                            result.first = i;
                        }

                        result.last = i + 1;
                    }

                    break;
                }
            }
        }

        return result;
    }
}

/**
 * Load the font with the given name. The returned value can be used to find
 * ligatures for the font.
 *
 * @param name Font family name for the font to load
 */
export async function load(name: string, options?: Options): Promise<Font> {
    // We just grab the first font variant we find for now.
    // TODO: allow users to specify information to pick a specific variant
    const [fontInfo] = await import('font-finder').then(fontFinder => fontFinder.listVariants(name));

    if (!fontInfo) {
        throw new Error(`Font ${name} not found`);
    }

    return loadFile(fontInfo.path, options);
}

/**
 * Load the font at the given file path. The returned value can be used to find
 * ligatures for the font.
 *
 * @param path Path to the file containing the font
 */
export async function loadFile(path: string, options?: Options): Promise<Font> {
    const font = await import('util').then(util => util.promisify<string, opentype.Font | undefined>(opentype.load)(path));
    return new FontImpl(font!, {
        cacheSize: 0,
        ...options
    });
}

/**
 * Load the font from it's binary data. The returned value can be used to find
 * ligatures for the font.
 *
 * @param buffer ArrayBuffer of the font to load
 */
export function loadBuffer(buffer: ArrayBuffer, options?: Options): Font {
    const font = opentype.parse(buffer);
    return new FontImpl(font, {
        cacheSize: 0,
        ...options
    });
}

export { Font, LigatureData, Options };
