import {
    ASTConstructorDeclaration,
    ASTDef,
    ASTDefFunction,
    ASTDefInteractiveProgram,
    ASTDefProcedure,
    ASTDefProgram,
    ASTDefType
} from './ast';
/* eslint-disable no-underscore-dangle */
import { i18n, i18nPosition } from './i18n';

import { GbsSyntaxError } from './exceptions';
import { SourceReader } from './reader';
import { Token } from './token';

/* Description of a field */
class FieldDescriptor {
    private _typeName: string;
    private _constructorName: string;
    private _index: number;

    public constructor(typeName: string, constructorName: string, index: number) {
        this._typeName = typeName;
        this._constructorName = constructorName;
        this._index = index;
    }

    public get typeName(): string {
        return this._typeName;
    }

    public get constructorName(): string {
        return this._constructorName;
    }

    public get index(): number {
        return this._index;
    }
}

/* Local name categories */
export const LocalVariable = Symbol.for('LocalVariable');
export const LocalParameter = Symbol.for('LocalParameter');
export const LocalIndex = Symbol.for('LocalIndex');

/* Description of a local name */
class LocalNameDescriptor {
    private _category: any;
    private _position: any;

    public constructor(category, position) {
        this._category = category;
        this._position = position;
    }

    // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
    public get category() {
        return this._category;
    }

    // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
    public get position() {
        return this._position;
    }
}

function fail(startPos: SourceReader, endPos: SourceReader, reason: string, args: any[]): void {
    throw new GbsSyntaxError(startPos, endPos, reason, args);
}

/* A symbol table keeps track of definitions, associating:
 * - procedure and function names to their code
 * - type definitions, constructors, and fields
 */
export class SymbolTable {
    private _program: ASTDefProgram | ASTDefInteractiveProgram;
    private _isInteractiveProgram: boolean;
    private _procedures: Record<string, ASTDefProcedure>;
    private _procedureParameters: Record<string, string[]>;
    private _functions: Record<string, ASTDefFunction>;
    private _functionParameters: Record<string, string[]>;
    private _types: Record<string, ASTDefType>;
    private _typeConstructors: Record<string, string[]>;
    private _constructors: Record<string, ASTConstructorDeclaration>;
    private _constructorType: Record<string, string>;
    private _constructorFields: Record<string, string[]>;
    private _localNames: Record<string, LocalNameDescriptor>;
    private _fields: Record<string, FieldDescriptor[]>;

    public constructor() {
        this._program = undefined;

        /* true iff the program is interactive */
        this._isInteractiveProgram = false;

        /* Each procedure name is mapped to its definition */
        this._procedures = {};

        /* Each procedure name is mapped to its parameters */
        this._procedureParameters = {};

        /* Each function name is mapped to its definition */
        this._functions = {};

        /* Each function name is mapped to its parameters */
        this._functionParameters = {};

        /* Each type name is mapped to its definition */
        this._types = {};

        /* Each type name is mapped to a list of constructor names */
        this._typeConstructors = {};

        /* Each constructor name is mapped to its declaration */
        this._constructors = {};

        /* Each constructor name is mapped to its type name */
        this._constructorType = {};

        /* Each constructor name is mapped to a list of field names */
        this._constructorFields = {};

        /* Each field name is mapped to a list of field descriptors.
         * Each field descriptor is of the form
         *   new FieldDescriptor(typeName, constructorName, index)
         * where
         * - 'typeName' is the name of a type
         * - 'constructorName' is the name of a constructor of the given type
         * - 'index' is the index of the given field with respect to the
         *   given constructor (starting from 0)
         */
        this._fields = {};

        /* Local names include parameters, indices and variables,
         * which are only meaningful within a routine.
         *
         * Local names may be bound/referenced in the following places:
         * - formal parameters,
         * - indices of a "foreach",
         * - pattern matching (formal parameters of a "switch"),
         * - assignments and tuple assignments,
         * - reading local variables.
         *
         * _localNames maps a name to a descriptor of the form:
         *   new LocalNameDescriptor(category, position)
         */
        this._localNames = {};
    }

    public get program(): any {
        return this._program;
    }

    public isInteractiveProgram(): boolean {
        return this._isInteractiveProgram;
    }

    public isProcedure(name: string): boolean {
        return name in this._procedures;
    }

    public allProcedureNames(): string[] {
        const names = [];
        for (const name in this._procedures) {
            names.push(name);
        }
        return names.sort();
    }

    public procedureDefinition(name: string): ASTDefProcedure {
        if (this.isProcedure(name)) {
            return this._procedures[name];
        } else {
            throw Error('Undefined procedure.');
        }
    }

    public procedureParameters(name: string): string[] {
        if (this.isProcedure(name)) {
            return this._procedureParameters[name];
        } else {
            throw Error('Undefined procedure.');
        }
    }

    public isFunction(name: string): boolean {
        return name in this._functions;
    }

    public allFunctionNames(): string[] {
        const names = [];
        for (const name in this._functions) {
            names.push(name);
        }
        return names.sort();
    }

    public functionDefinition(name: string): ASTDefFunction {
        if (this.isFunction(name)) {
            return this._functions[name];
        } else {
            throw Error('Undefined function.');
        }
    }

    public functionParameters(name: string): string[] {
        if (this.isFunction(name)) {
            return this._functionParameters[name];
        } else {
            throw Error('Undefined function.');
        }
    }

    public isType(name: string): boolean {
        return name in this._types;
    }

    public typeDefinition(name: string): ASTDefType {
        if (this.isType(name)) {
            return this._types[name];
        } else {
            throw Error('Undefined type.');
        }
    }

    public typeConstructors(name: string): string[] {
        if (this.isType(name)) {
            return this._typeConstructors[name];
        } else {
            throw Error('Undefined type.');
        }
    }

    public isConstructor(name: string): boolean {
        return name in this._constructors;
    }

    public constructorDeclaration(name: string): ASTConstructorDeclaration {
        if (this.isConstructor(name)) {
            return this._constructors[name];
        } else {
            throw Error('Undefined constructor.');
        }
    }

    public constructorType(name: string): string {
        if (this.isConstructor(name)) {
            return this._constructorType[name];
        } else {
            throw Error('Undefined constructor.');
        }
    }

    public constructorFields(name: string): string[] {
        if (this.isConstructor(name)) {
            return this._constructorFields[name];
        } else {
            throw Error('Undefined constructor.');
        }
    }

    public isField(name: string): boolean {
        return name in this._fields;
    }

    public fieldDescriptor(name: string): FieldDescriptor[] {
        if (this.isField(name)) {
            return this._fields[name];
        } else {
            throw Error('Undefined field.');
        }
    }

    public defProgram(definition: ASTDefProgram | ASTDefInteractiveProgram): void {
        if (this._program !== undefined) {
            fail(definition.startPos, definition.endPos, 'program-already-defined', [
                i18nPosition(this._program.startPos),
                i18nPosition(definition.startPos)
            ]);
        }
        this._program = definition;
    }

    public defInteractiveProgram(definition: ASTDefInteractiveProgram): void {
        this.defProgram(definition);
        this._isInteractiveProgram = true;
    }

    public defProcedure(definition: ASTDefProcedure): void {
        const name = definition.name.value;
        if (name in this._procedures) {
            fail(definition.name.startPos, definition.name.endPos, 'procedure-already-defined', [
                name,
                i18nPosition(this._procedures[name].startPos),
                i18nPosition(definition.startPos)
            ]);
        }
        const parameters = [];
        for (const parameter of definition.parameters) {
            parameters.push(parameter.value);
        }
        this._procedures[name] = definition;
        this._procedureParameters[name] = parameters;
    }

    public defFunction(definition: ASTDefFunction): void {
        const name = definition.name.value;
        if (name in this._functions) {
            fail(definition.name.startPos, definition.name.endPos, 'function-already-defined', [
                name,
                i18nPosition(this._functions[name].startPos),
                i18nPosition(definition.startPos)
            ]);
        } else if (name in this._fields) {
            const fieldPos = this._constructors[this._fields[name][0].constructorName].startPos;
            fail(
                definition.name.startPos,
                definition.name.endPos,
                'function-and-field-cannot-have-the-same-name',
                [name, i18nPosition(definition.startPos), i18nPosition(fieldPos)]
            );
        }
        const parameters = [];
        for (const parameter of definition.parameters) {
            parameters.push(parameter.value);
        }
        this._functions[name] = definition;
        this._functionParameters[name] = parameters;
    }

    public defType(definition: ASTDefType): void {
        const typeName = definition.typeName.value;
        if (typeName in this._types) {
            fail(definition.typeName.startPos, definition.typeName.endPos, 'type-already-defined', [
                typeName,
                i18nPosition(this._types[typeName].startPos),
                i18nPosition(definition.startPos)
            ]);
        }
        this._types[typeName] = definition;
        const constructorNames = [];
        for (const constructorDeclaration of definition.constructorDeclarations) {
            this._declareConstructor(typeName, constructorDeclaration);
            constructorNames.push(constructorDeclaration.constructorName.value);
        }
        this._typeConstructors[typeName] = constructorNames;
    }

    public _declareConstructor(
        typeName: string,
        constructorDeclaration: ASTConstructorDeclaration
    ): void {
        const constructorName = constructorDeclaration.constructorName.value;
        if (constructorName in this._constructors) {
            fail(
                constructorDeclaration.constructorName.startPos,
                constructorDeclaration.constructorName.endPos,
                'constructor-already-defined',
                [
                    constructorName,
                    i18nPosition(this._constructors[constructorName].startPos),
                    i18nPosition(constructorDeclaration.startPos)
                ]
            );
        }
        this._constructors[constructorName] = constructorDeclaration;
        this._constructorType[constructorName] = typeName;

        const constructorFields = {};
        const fieldNames: string[] = [];
        let index = 0;
        for (const fieldName of constructorDeclaration.fieldNames) {
            if (fieldName.value in constructorFields) {
                fail(fieldName.startPos, fieldName.endPos, 'repeated-field-name', [
                    constructorName,
                    fieldName.value
                ]);
            }
            constructorFields[fieldName.value] = true;
            fieldNames.push(fieldName.value);
            this._declareField(
                fieldName.startPos,
                fieldName.endPos,
                typeName,
                constructorName,
                fieldName.value,
                index
            );
            index++;
        }
        this._constructorFields[constructorName] = fieldNames;
    }

    public _declareField(
        startPos: SourceReader,
        endPos: SourceReader,
        typeName: string,
        constructorName: string,
        fieldName: string,
        index: number
    ): void {
        if (fieldName in this._functions) {
            fail(startPos, endPos, 'function-and-field-cannot-have-the-same-name', [
                fieldName,
                i18nPosition(this._functions[fieldName].startPos),
                i18nPosition(startPos)
            ]);
        }
        if (!(fieldName in this._fields)) {
            this._fields[fieldName] = [];
        }
        this._fields[fieldName].push(new FieldDescriptor(typeName, constructorName, index));
    }

    /* Adds a new local name, failing if it already exists. */
    public addNewLocalName(localName: Token, category: symbol): void {
        if (localName.value in this._localNames) {
            fail(localName.startPos, localName.endPos, 'local-name-conflict', [
                localName.value,
                i18n(Symbol.keyFor(this._localNames[localName.value].category)),
                i18nPosition(this._localNames[localName.value].position),
                i18n(Symbol.keyFor(category)),
                i18nPosition(localName.startPos)
            ]);
        }
        this.setLocalName(localName, category);
    }

    /* Sets a local name.
     * It fails if it already exists with a different category. */
    public setLocalName(localName: Token, category: symbol): void {
        if (
            localName.value in this._localNames &&
            this._localNames[localName.value].category !== category
        ) {
            fail(localName.startPos, localName.endPos, 'local-name-conflict', [
                localName.value,
                i18n(Symbol.keyFor(this._localNames[localName.value].category)),
                i18nPosition(this._localNames[localName.value].position),
                i18n(Symbol.keyFor(category)),
                i18nPosition(localName.startPos)
            ]);
        }
        this._localNames[localName.value] = new LocalNameDescriptor(category, localName.startPos);
    }

    /* Removes a local name. */
    public removeLocalName(localName: Token): void {
        delete this._localNames[localName.value];
    }

    /* Removes all local names. */
    public exitScope(): void {
        this._localNames = {};
    }

    /* Get the attribute dictionary for a global name.
     *
     * A global name is the names of a global definition:
     *   - the string 'program'
     *   - any procedure name (e.g. 'P')
     *   - any function name (e.g. 'f')
     *   - any type name (e.g. 'A')
     *
     * The result is a dictionary of attributes.
     *
     */
    public getAttributes(globalName: string): Record<string, ASTDef> {
        if (globalName === 'program' && this._program !== undefined) {
            return this._program.attributes;
        } else if (globalName in this._procedures) {
            return this._procedures[globalName].attributes;
        } else if (globalName in this._functions) {
            return this._functions[globalName].attributes;
        } else if (globalName in this._types) {
            return this._types[globalName].attributes;
        } else {
            return {};
        }
    }
}
