import { startsWith, endsWith } from "./utils";
import { Options } from "./options";

const COLLECTION_TYPE_PREFIX = "Collection(";

export enum EdmTypes {
    Int32 = "Edm.Int32",
    Int16 = "Edm.Int16",
    Boolean = "Edm.Boolean",
    String = "Edm.String",
    Single = "Edm.Single",
    Guid = "Edm.Guid",
    DateTimeOffset = "Edm.DateTimeOffset",
    Date="Edm.Date",
    Double = "Edm.Double",
    TimeOfDay = "Edm.TimeOfDay",
    Decimal = "Edm.Decimal",
    Unknown="Unknown"
}

export class EdmEntityType
{
    namespace?: Namespace;

    constructor(
        public name: string,
        public properties: Record<string, EdmTypeReference>,
        public navProperties: Record<string, EdmEntityTypeReference> = {},
        public keys?: string[],
        public baseType?: EdmEntityType,
        public openType?: boolean
    ) { }

    getFullName = () => getFullName(this);
}

export class EdmComplexType extends EdmEntityType {

}

export class EdmEnumType
{
    namespace?: Namespace;

    constructor(
        public name: string,
        public members: Record<string, string|number>
    ) { }

    getFullName = () => getFullName(this);
}

export class EdmEntityTypeReference {
    constructor(
        public type: EdmEntityType,
        public nullable = true,
        public collection = false
    ) { }

    static fromTypeReference(typeReference: EdmTypeReference) {
        if (typeReference.type instanceof EdmEntityType) {
            return typeReference as any as EdmEntityTypeReference;
        }
        throw new Error("Instance must be reference to EdmEntityType");
    }
}

export class EdmTypeReference {
    constructor(
        public type: EdmTypes | EdmEntityType | EdmEnumType,
        public nullable = true,
        public collection = false
    ) { }
};

export class OperationMetadata
{
    namespace?: Namespace;

    constructor(
        public name: string,
        public isAction: boolean,
        public parameters?: { name: string, type: EdmTypeReference}[],
        public returnType?: EdmTypeReference,
        public bindingTo?: EdmEntityTypeReference
    ) { }

    getFullName = () => getFullName(this);
}

function getFullName(obj: { namespace?: Namespace, name: string }): string {
    if (obj.namespace)
        return [obj.namespace.name, obj.name].join(".");
    return obj.name;
}

export class Namespace {
    operations: OperationMetadata[] = [];
    types: Readonly<Record<string, EdmEntityType | EdmEnumType >> = { };

    constructor(readonly name: string) {}

    addTypes(...types: (EdmEntityType | EdmEnumType)[]) {
        for (let type of types) {
            type.namespace = this;
            (this.types as any)[type.name] = type;
        }
    }

    addOperations(...operations: OperationMetadata[]) {
        for (let operation of operations) {
            operation.namespace = this;
            this.operations.push(operation);
        }
    }
};

type Namespaces = Record<string, Namespace>;

var __metadataCache: Record<string, Readonly<ApiMetadata>> = {};

export function loadMetadata(apiRoot: string, options?: Options, cache = true): Promise<ApiMetadata> {
    if (endsWith(apiRoot, "/"))
        apiRoot = apiRoot.substr(0, apiRoot.length - 1);
    const normalizedApiRoot = apiRoot.toLowerCase(),
          res: ApiMetadata = __metadataCache[normalizedApiRoot];
    if (res == null || !cache) {
        return ApiMetadata.loadAsync(apiRoot, options)
            .then(md => {
                if (cache)
                    __metadataCache[normalizedApiRoot] = md;
                return md;
            });
    }
    return Promise.resolve(res);
}

export class ApiMetadata {
    constructor(
        readonly apiRoot: string,
        readonly containerName: string,
        readonly namespaces: Namespaces = {},
        readonly entitySets: Record<string, EdmEntityType> = {},
        readonly singletons: Record<string, EdmEntityType> = {}
    ) { }

    static loadFromXml(apiRoot:string, metadataXml: string) {
        const parser = new DOMParser();
        const metadataDoc = parser.parseFromString(metadataXml, "text/xml");

        const namespaces = ApiMetadata.parseEntityTypes(metadataDoc);
        const entitySets: Record<string, EdmEntityType> = {};
        const singletons: Record<string, EdmEntityType> = {};
        const container = metadataDoc.querySelector("Schema>EntityContainer")!;
        const containerName = container && getRequiredAttributeValue(container, "Name");
        if (container) {
            const list = container.querySelectorAll("EntitySet,Singleton");
            for (var i = 0; i < list.length; i++) {
                const e = list.item(i)
                const isSingleton = e.tagName.toUpperCase() === "SINGLETON";
                let name = getRequiredAttributeValue(e, "Name");
                let typeName = getRequiredAttributeValue(e, isSingleton ? "Type" : "EntityType");
                const target = isSingleton ? singletons : entitySets;
                target[name] = ApiMetadata.getEntitySetMetadata(typeName, namespaces);
            }
        }
        const list3 = metadataDoc.querySelectorAll("Schema>Function,Schema>Action");
        for (var i = 0; i < list3.length; i++){
            const e = list3.item(i);
            const metadata = ApiMetadata.parseOperationMetadata(e, namespaces);
            const namespaceName = getRequiredAttributeValue(e.parentElement as Element, "Namespace");
            if (!namespaces[namespaceName])
                namespaces[namespaceName] = new Namespace(namespaceName);
            namespaces[namespaceName].addOperations(metadata);
        }

        return new ApiMetadata(apiRoot, containerName, namespaces, entitySets, singletons);
    }

    static async loadAsync(apiRoot: string, options?: Options) {
        const uri = apiRoot + "/$metadata";
        const opt = options || {};
        const fetchApi = opt.fetch || fetch
        const credentials = opt.credentials;
        const response = await fetchApi(uri, { credentials });
        return this.loadFromXml(apiRoot, await response.text());
    }

    private static parseEntityTypes(metadataDoc: Document) {
        let namespaces = {} as Namespaces;

        let entityTypes: Array<{ element: Element, typeMetadata: EdmEntityType }> = [];
        const list = metadataDoc.querySelectorAll("Schema>ComplexType,Schema>EntityType,Schema>EnumType");

        for (var i = 0; i < list.length; i++){
            const e = list.item(i);

            const getOrAddEdmEntityType = function(namespace: string, name: string){
                let namespaceMD = namespaces[namespace]; 
                if (!namespaceMD)
                    namespaces[namespace] = namespaceMD = new Namespace(namespace);
                let typeMetadata = namespaceMD.types[name] as EdmEntityType;
                if (!typeMetadata) {
                    typeMetadata = e.tagName.toLowerCase() == "complextype"
                        ? new EdmComplexType(name, {})
                        : new EdmEntityType(name, {});
                    namespaceMD.addTypes(typeMetadata);
                }
                return typeMetadata;
            }

            let ns = getRequiredAttributeValue(e.parentElement as Element, "Namespace");
            let name = getRequiredAttributeValue(e,"Name");
            if (!(ns in namespaces))
                namespaces[ns] = new Namespace(ns);
            if (e.tagName.toLowerCase() === "enumtype") {
                const enumType = new EdmEnumType(name, ApiMetadata.parseEnumMembers(e));
                namespaces[ns].addTypes(enumType);
            }
            else {
                let typeMetadata = getOrAddEdmEntityType(ns, name);
                typeMetadata.openType = getAttributeBoolValue(e,"OpenType");
                const baseType = getAttributeValue(e,"BaseType");
                if (baseType) {
                    const baseTypeNS = baseType.substring(0, baseType.lastIndexOf("."));
                    const baseTypeName = baseType.substr(baseTypeNS.length + 1);
                    let bt = getOrAddEdmEntityType(baseTypeNS, baseTypeName);
                    typeMetadata.baseType = bt;
                }
                entityTypes.push({ element: e, typeMetadata });
            }
        }

        for (let e of entityTypes) {
            Object.assign(e.typeMetadata, ApiMetadata.getEntityTypeProperties(e.element, namespaces));
            e.typeMetadata.keys = this.parseEntityKeys(e.element);
        }

        return namespaces;
    }

    private static parseEntityKeys(typeElement: Element) {
        var res = new Array<string>();
        var list = typeElement.querySelectorAll("Key>PropertyRef");
        for (var i = 0; i < list.length; i++) {
            res.push(getRequiredAttributeValue(list.item(i), "Name"));
        }
        return res;
    }

    private static parseEnumMembers(element: Element): Record<string, string | number> {
        const res: Record<string, string | number> = {};
        var list = element.querySelectorAll("Member");
        for (var i = 0; i < list.length; i++) {
            const e = list.item(i);
            const name = getRequiredAttributeValue(e, "Name");
            const rawValue = getRequiredAttributeValue(e, "Value");
            res[name] = (rawValue.match(/\d/)) ? parseInt(rawValue) : rawValue;
        }
        return res;
    }

    private static getEntityTypeProperties(typeElement: Element, namespaces: Namespaces) {
        let properties: Record<string, EdmTypeReference> = {};
        let navProperties: Record<string, EdmEntityTypeReference> = {};
        var list = typeElement.querySelectorAll("Property,NavigationProperty")
        for (var i = 0; i < list.length; i++) {
            const e = list.item(i);
            const name = getRequiredAttributeValue(e,"Name");
            let metadata = ApiMetadata.parseType(e, namespaces);
            if(e.tagName.toLowerCase() == "property")
                properties[name] = metadata;
            else
                navProperties[name] = EdmEntityTypeReference.fromTypeReference(metadata);
        }
        return { properties, navProperties };
    }

    getEntitySetMetadata(typeName: string) {
        return ApiMetadata.getEntitySetMetadata(typeName, this.namespaces);
    }

    private static getEntitySetMetadata(typeName: string, namespaces: Namespaces) {
        const res = ApiMetadata.getEdmTypeMetadata(typeName, namespaces);
        if (res instanceof EdmEntityType)
            return res;
        throw new Error("EntitySet item type must be entity");
    }

    getEdmTypeMetadata(typeName: string): EdmEntityType | EdmEnumType {
        return ApiMetadata.getEdmTypeMetadata(typeName, this.namespaces);
    }

    private static getEdmTypeMetadata(typeName: string, namespaces: Namespaces): EdmEntityType | EdmEnumType {
        if (startsWith(typeName,COLLECTION_TYPE_PREFIX))
            typeName = typeName.substring(COLLECTION_TYPE_PREFIX.length, typeName.length - 1)

        const namespace = typeName.substring(0, typeName.lastIndexOf("."));
        const typeNameNoNs = typeName.substr(namespace.length + 1);
        if (namespace == "Edm") {
            let t = (EdmTypes as any)[typeNameNoNs];
            if (!t) throw new Error("Not registred Edm type: " + typeNameNoNs)
            return t;
        }
        const nsMeta = namespaces[namespace];
        if (!nsMeta)
            throw new Error(`Namespace '${namespace}' not found`);
        let typeElement = nsMeta.types[typeNameNoNs];
        return typeElement;
    }

    private static parseOperationMetadata(operationElement: Element, namespaces: Namespaces): OperationMetadata {
        const isAction = operationElement.tagName.toLowerCase() === "action";
        const name = getRequiredAttributeValue(operationElement, "Name");
        const returnTypeElement = operationElement.querySelector("ReturnType");
        const returnType = returnTypeElement
            ? ApiMetadata.parseType(returnTypeElement, namespaces)
            : undefined;
        const parameters = new Array<{ name: string, type: EdmTypeReference }>();
        let bindingTo: EdmEntityTypeReference | undefined;
        const list = operationElement.querySelectorAll("Parameter");
        for (var i = 0; i < list.length; i++){
            const e = list.item(i);
            const type = ApiMetadata.parseType(e, namespaces);
            const name = getRequiredAttributeValue(e, "Name");
            if (getAttributeBoolValue(operationElement, "IsBound") && !bindingTo)
                bindingTo = EdmEntityTypeReference.fromTypeReference(type);
            else
                parameters.push({ name, type });
        }
        return new OperationMetadata(name, isAction, parameters, returnType, bindingTo);
    }

    private static parseType(element: Element, namespaces: Namespaces) {
        let typeName = getRequiredAttributeValue(element, "Type");
        let collection = startsWith(typeName, COLLECTION_TYPE_PREFIX);
        if (collection)
            typeName = typeName.substring(COLLECTION_TYPE_PREFIX.length, typeName.length - 1);
        const typeMetadata = ApiMetadata.getEdmTypeMetadata(typeName, namespaces);
        const res = new EdmTypeReference(typeMetadata, true, collection);
        res.nullable = getAttributeBoolValue(element, "Nullable") != false;
        return res;
    }
}

function getRequiredAttributeValue(element: Element, attrName: string): string {
    return getAttributeValue(element, attrName) || (() => {
        throw new Error(`Metadata: Attribute '${attrName}' in element '${element.tagName}' not found `)
    })();
}
function getAttributeBoolValue(element: Element, attrName: string): boolean | undefined {
    var r = getAttributeValue(element, attrName);
    if (r != undefined) return r.toLowerCase() == "true";
}
function getAttributeValue(element: Element, attrName: string): string | undefined {
    var attr = element.attributes.getNamedItem(attrName);
    if (attr)
        return attr.value;
}