import { Discipline } from '../discipline';
import { BimIfcClass } from '../bim-ifc-class';
import { BimChangeType, isIfc, isDwg, isBlob, IsLayer } from '../bim-api-client';
import { Permissions } from '../Permission';
import { BimLayerApiClient } from './BimLayerApiClient';
import { Http, HttpResponseType } from '../../http';
import { BimMessageApiClient } from './BimMessageApiClient';
import { BimUploadApiClient } from './BimUploadApiClient';
import { asWriteable } from '../../Types';
import { PredefinedBimChangeMetadataQuery } from '../PredefinedBimChangeMetadataQuery';
import { OwnerSystem } from '../OwnerSystem';
import { BimSettingsApiClient } from './BimSettingsApiClient';
import { LayerDefinitions } from '../LayerDefinitions';
import { IfcSiteInformationImplementation } from '../IfcSiteInformation';
import { TwinfinityMapBoxApiClient } from './MapBoxApiClient';
import { telemetry } from '../../Telemetry';
import { TwinClient } from '@twinfinity/twin';
import { wrapAuthorizationHeaderProvider, createTwinfinityHttpClient } from './AuthHelpers';
function assignCalculatedBimIfcChangeProperties(ifcChange) {
    const ifc = ifcChange.metadata.ifc;
    ifcChange.resourceUrl = {
        ...(ifc?.url?.idx && { idx: new URL(ifc.url.idx) }),
        ...(ifc?.url?.geom && { geom: new URL(ifc.url.geom) }),
        ...(ifc?.url?.t8gd && { t8gd: new URL(ifc.url.t8gd) }),
        ...(ifc?.url?.prop && { prop: new URL(ifc.url.prop) }),
        ...(ifc?.url && ifc?.url['geom-feat'] && { 'geom-feat': new URL(ifc.url['geom-feat']) })
    };
    ifcChange.floors = ifc?.floors ?? [];
    ifcChange.classes = (ifc?.classes ?? []).map((c) => BimIfcClass.getOrAdd(c));
    ifcChange.productCount = ifc?.statistics?.productCount ?? -1;
}
function assignDiscipline(change, defaultDisciplineShort) {
    // Server sends us discipline as string (short id). We convert it to
    // a strongly typed Discipline class instance.
    const disciplineShort = change.discipline ?? defaultDisciplineShort;
    if (disciplineShort) {
        change.discipline = Discipline.getOrAdd(disciplineShort);
    }
}
function assignParseError(parserMetadata) {
    if (parserMetadata) {
        // server will not send hasParseError false but if it does not exist we set it to false
        parserMetadata.hasParseError = parserMetadata.hasParseError ?? false;
    }
}
function assignIfcSiteInformation(ifcChange) {
    const ifc = ifcChange.metadata.ifc;
    ifc.sites = (ifc?.sites ?? []).map((site) => new IfcSiteInformationImplementation(site));
}
function parseDate(date) {
    if (date instanceof Date)
        return date;
    return new Date(date);
}
/**
 * Enhances a {@link BimChange} dto with calculated properties.
 * When a {@link BimChange} dto is received from the backend, it may not contain all the properties that we need
 * so we calculate them here.
 *
 * @param dto - The {@link BimChange} object to enhance.
 * @hidden
 * @internal
 * @returns The enhanced {@link BimChange} object.
 */
export function enhanceChangeDtoWithCalculatedProperties(dto) {
    if (!IsLayer(dto)) {
        // change.availableLayerDefinitions may actually be undefined  when transferred over the wire
        // but we do not want to expose that on the type because we always replace it with a LayerDefinitions collection
        // (that may be empty).
        LayerDefinitions.copyToRef({ json: dto.availableLayerDefinitions }, dto);
    }
    asWriteable(dto).path = new URL(dto.apiUrl.replace(/\/_ps\/api\/$/, '')).pathname;
    Permissions.copyToRef({ json: dto.permissionsString }, dto);
    if (isIfc(dto)) {
        assignDiscipline(dto, Discipline.NotAvailableShortId);
        assignParseError(dto.metadata?.ifc);
        assignCalculatedBimIfcChangeProperties(dto);
        assignIfcSiteInformation(dto);
    }
    else if (isDwg(dto)) {
        assignDiscipline(dto);
        assignParseError(dto.metadata?.dwg);
    }
    else if (isBlob(dto)) {
        assignDiscipline(dto);
    }
    return dto;
}
/**
 * Converts URL (can be a content URL) to a resource URL.
 * @example
 * ```typescript
 *    const url = contentUrlToResourceUrl('https://bim.demo.projektstruktur.se/sites/portal/projects/projectA/_layer.assets/_ps/api/file/content/abcdefg');
 *    console.log(url); // 'https://bim.demo.projektstruktur.se/sites/portal/projects/projectA/_layer.assets'
 * ```
 * @param o URL or ContentUrl object.
 * @returns Resource url or undefined if the input is not a valid URL.
 */
function contentUrlToResourceUrl(o) {
    let resourceUrl = ('url' in o ? new URL(o.url) : o).toString();
    resourceUrl = resourceUrl.split(/(\/+)_ps(\/)+api((\/)+|$)/, 1)[0];
    if (!resourceUrl)
        return undefined;
    return new URL(resourceUrl);
}
/**
 * Enhances a {@link BimContainer} object with calculated properties.
 * When a {@link BimContainer} dto is received from the backend, it may not contain all the properties that we need
 * so we calculate them here.
 * @param dto - The {@link BimContainer} object to enhance.
 * @hidden
 * @internal
 * @returns The enhanced {@link BimContainer} object.
 */
export function enhancedContainerDtoWithCalculatedProperties(dto) {
    dto.path = new URL(dto.url).pathname;
    // Parse and set the modifiedUtc property. DTO has it as string.
    dto.modifiedUtc = parseDate(dto.modifiedUtc);
    Permissions.copyToRef({ json: dto.permissionsString }, dto);
    // container.availableLayerDefinitions may actually be undefined when transferred over the wire
    // but we do not want to expose that on the type because we always replace it with a LayerDefinitions collection
    // (that may be empty).
    LayerDefinitions.copyToRef({ json: dto.availableLayerDefinitions }, dto);
    return dto;
}
/**
 * Loads container and IFC data from the Twinfinity backend.
 */
export class BimTwinfinityApiClient {
    static _predefinedRootUrls = Object.freeze([
        '/sites/portal/',
        '/sites/archive/',
        '/workspaces/'
    ]);
    baseUrl;
    _rootWebUrls;
    /**
     * Access to layer operations.
     */
    layers;
    /**
     * Access to message operations.
     */
    messages;
    /**
     * Access to upload operations.
     */
    upload;
    /**
     * Access to settings operations.
     */
    settings;
    /**
     * Access to MapBox operations.
     */
    mapBox;
    /**
     * Access to twin operations.
     */
    twins;
    /**
     * Constructor
     * @param baseUrl Url to twinfinity. Example: https://bim.demo.projektstruktur.se or https://bim.demo.projektstruktur.se/sites/portal/projects/projectA
     */
    constructor(baseUrl, options) {
        if (options?.session) {
            Http.setAuthorizationHeaderProvider(wrapAuthorizationHeaderProvider(options.session));
        }
        if (!Array.isArray(baseUrl)) {
            const { protocol, host } = baseUrl;
            baseUrl = BimTwinfinityApiClient._predefinedRootUrls.map((rootUrl) => {
                return new URL(`${protocol}//${host}${rootUrl}`);
            });
        }
        if (baseUrl.length === 0)
            throw new Error('At least one base url must be provided.');
        // Verify that all urls have the same scheme and server
        const isValid = baseUrl.every((u) => u.protocol === baseUrl[0].protocol && u.host === baseUrl[0].host);
        if (!isValid) {
            throw new Error('All urls must have the same scheme and server.');
        }
        const schemeAndServer = `${baseUrl[0].protocol}//${baseUrl[0].host}`;
        this.baseUrl = new URL(schemeAndServer);
        this._rootWebUrls = baseUrl;
        this.layers = new BimLayerApiClient(this);
        // Previously we always used sites/portal but that url may not be available in all installations (especially if we dont use PS).
        // If sites/portal exists it will (like before) it will be used.
        const defaultRootWebUrl = this._rootWebUrls.find((url) => url.pathname.match(/^\/sites\/portal\//i)) ?? this._rootWebUrls.at(0);
        this.messages = new BimMessageApiClient(defaultRootWebUrl);
        this.upload = new BimUploadApiClient(this);
        this.settings = new BimSettingsApiClient(defaultRootWebUrl);
        this.mapBox = new TwinfinityMapBoxApiClient(this.baseUrl);
        // If sites/archive exists it will it will be used.
        const defaultArchiveRootWebUrl = this._rootWebUrls.find((url) => url.pathname.match(/^\/sites\/archive\//i)) ?? this._rootWebUrls.at(0);
        const httpClient = this.getHttpClient(options);
        this.twins = new TwinClient(defaultArchiveRootWebUrl, httpClient);
    }
    getHttpClient(options) {
        if (options?.httpClient) {
            return options.httpClient;
        }
        return createTwinfinityHttpClient(options);
    }
    /**
     * Creates a new instance of `BimTwinfinityApiClient` by detecting available root URLs for the current user.
     * @example
     * ```typescript
     * const client = await BimTwinfinityApiClient.create('demo.projektstruktur.se');
     * if (client == null) {
     *   console.log('User does not have access to any root URLs.');
     *   return;
     * }
     * const containers = await client.getContainers(); // Fetch containers from all available root URLs.
     * console.log(containers);
     * ```
     * @param hostnameOrUrl Hostname of the Twinfinity installation. If the hostname does not start with `bim.` it will be added automatically.
     * Its ok to pass a full URL too, but only the hostname is used. Scheme is always set to `https`.
     * @param options Optional session used to get authorization header for requests.
     * @returns A promise that resolves to a new instance of `BimTwinfinityApiClient`. If no root URLs are detected, the promise resolves to `undefined`.
     * `undefined` indicates that the user is not authorized or does not have access to any Twinfinity resources.
     */
    static async create(hostnameOrUrl, options) {
        // Try to parse hostname. Take possibility into account that users actually passes a url.
        const parsedHost = (URL.parse(hostnameOrUrl) ?? URL.parse(`https://${hostnameOrUrl}`))?.host;
        if (!parsedHost)
            throw new Error(`Invalid hostname: ${hostnameOrUrl}`);
        // Ensure that hostname starts with bim. If not we add it. Always use https and normalize to lowercase.
        const baseUrl = new URL(`https://bim.${parsedHost.replace(/^bim\./i, '')}`.toLowerCase());
        const rootUrls = await this.detectRootUrls(baseUrl, options);
        if (rootUrls.length === 0)
            return undefined;
        return new BimTwinfinityApiClient(rootUrls, options);
    }
    /**
     * Detects the root URLs available for the current user given a base URL. The result depends on type of Twinfinity installation, how its configured and
     * which resources the user has access to. The method will test the following root URLs:
     * - {protocol}://{host}/sites/portal/
     * - {protocol}://{host}/sites/archive/
     * - {protocol}://{host}/workspaces/
     * Where {protocol} and {host} is taken from the provided `baseUrl`.
     * @param baseUrl The base URL to detect root URLs for.
     * @param options Optional session used to get authorization header for requests.
     * @returns A promise that resolves to an array of detected root URLs.
     * @example
     * ```typescript
     * const baseUrl = new URL('https://bim.demo.projektstruktur.se');
     * const rootUrls = await BimTwinfinityApiClient.detectRootUrls(baseUrl);
     * console.log(rootUrls); // [ 'https://bim.demo.projektstruktur.se/sites/portal/', 'https://bim.demo.projektstruktur.se/sites/archive/' ]
     * ```
     */
    static async detectRootUrls(baseUrl, options) {
        // Test which baseurls that are available for use. May be zero, one or more.
        const possibleBaseUrls = this._predefinedRootUrls.map((rootUrl) => {
            return new URL(`${baseUrl.protocol}//${baseUrl.host}${rootUrl}`);
        });
        const session = options?.session;
        if (session != null) {
            Http.setAuthorizationHeaderProvider(wrapAuthorizationHeaderProvider(session));
        }
        return (await Promise.all(possibleBaseUrls.map(async (url) => {
            let validUrl = undefined;
            try {
                const ret = await this.containerInfo(url);
                // We do not care about the response, just that we get a 200 back. If so then
                // user has access to this url.
                if (ret.ok) {
                    validUrl = url;
                }
            }
            catch (err) {
                telemetry.trackException({ id: 'BimTwinfinityApiClient.create', exception: err });
            }
            return validUrl;
        }))).filter(Boolean);
    }
    /** Hostname of {@link baseUrl}. */
    get id() {
        return this.baseUrl.hostname;
    }
    /** @inheritDoc */
    canDelete({ permissions, ownerSystem, type }) {
        return permissions.hasAll('delete') && OwnerSystem.isDeletable(ownerSystem);
    }
    /** @inheritDoc */
    async deleteChanges(changesToDelete) {
        if (changesToDelete.some((change) => !OwnerSystem.isDeletable(change)))
            throw new Error('Only changes originally created in Twinfinity can be deleted.');
        if (changesToDelete.some((change) => !change.permissions.hasAny('delete')))
            throw new Error('Missing delete permission on one or more changes.');
        const changesGroupedByContainer = this.toLookup(changesToDelete, (change) => change.containerId);
        const results = Array.from(changesGroupedByContainer, async ([, changes]) => {
            const baseUrl = this.getLongestCommonUrlPath(changes.map((change) => change.apiUrl));
            const entitiesToDelete = changes.map((change) => {
                return { id: change.id, etag: change.etag };
            });
            const result = await Http.delete(`${baseUrl}/_ps/api/file`, HttpResponseType.json, {
                body: JSON.stringify(entitiesToDelete),
                headers: { 'Content-Type': 'application/json' }
            });
            return result;
        });
        return Promise.all(results);
    }
    toLookup(values, getKey) {
        const map = new Map();
        values.forEach((value) => {
            map.getOrAdd(getKey(value), (key) => []).push(value);
        });
        return map;
    }
    getLongestCommonUrlPath(urls) {
        const segmentedUrls = urls
            .map((url) => url
            .match('(.*)/_ps/api')[1]
            .split('/')
            .filter((str) => !!str))
            .sort((urlSegments) => urlSegments.length);
        const shortestUrl = segmentedUrls.pop();
        const longestCommonUrl = [];
        for (let segmentIndex = 0; segmentIndex < shortestUrl.length; segmentIndex++) {
            const currentUrlSegment = shortestUrl[segmentIndex];
            if (segmentedUrls.every((url) => url[segmentIndex] === currentUrlSegment))
                longestCommonUrl.push(currentUrlSegment);
            else
                break;
        }
        return longestCommonUrl.join('/');
    }
    /** @inheritDoc */
    async getInfo() {
        const systemInfoResults = await Promise.all(this._rootWebUrls.map((url) => {
            return this.getOrUndefined(`${url.href}_ps/api/system`, HttpResponseType.json);
        }));
        const systemInfo = systemInfoResults.filter(Boolean).shift();
        if (!systemInfo)
            throw new Error('Failed to retrieve system info');
        return systemInfo;
    }
    /** @inheritDoc */
    async getContainerInfo(containerOrUrl) {
        if (containerOrUrl) {
            if ('url' in containerOrUrl) {
                containerOrUrl = new URL(containerOrUrl.url);
            }
            return await BimTwinfinityApiClient.containerInfo(containerOrUrl);
        }
        // Either no url or container was specified
        // Therefore we have to fetch containerInfo for all root web urls in order to
        // find out what the default container info is (portal wins over archive).
        const responses = await Promise.all(this._rootWebUrls.map((url) => {
            return BimTwinfinityApiClient.containerInfo(url);
        }));
        // Return first ok response. If no such response then return first non ok response.
        return responses.filter((r) => r.ok)[0] ?? responses[0];
    }
    /** @inheritDoc */
    async getContainers(id) {
        const containerPromises = Promise.all(this._rootWebUrls.map(async (url) => {
            return Http.get(`${url.href}_ps/api/project/containers`, HttpResponseType.json);
        }));
        const result = await containerPromises;
        const containers = [];
        for (const r of result) {
            // TODO Log errors and stop querying backed pnRootWebUrl or archiveRootWebUrl if it does not
            // have that product
            if (r.status === 200) {
                const tmpContainers = await r.value;
                for (const container of tmpContainers.map(enhancedContainerDtoWithCalculatedProperties)) {
                    containers.push(container);
                }
            }
        }
        return containers;
    }
    /** @inheritDoc */
    async getChanges(parentOrUrl, options) {
        let getUrl = contentUrlToResourceUrl(parentOrUrl)?.toString();
        if (!getUrl)
            throw new Error('parentOrUrl represents a empty url.');
        getUrl += '/_ps/api/file';
        if ('id' in options) {
            getUrl += `/${options.id}`;
        }
        else {
            if (options.query !== 'all') {
                getUrl += `?$filter=${options.query}`;
            }
        }
        const changesResponse = await this.get(getUrl, HttpResponseType.json);
        if (changesResponse.status === 200) {
            (await changesResponse.value).forEach(enhanceChangeDtoWithCalculatedProperties);
        }
        return changesResponse;
    }
    /** @inheritDoc */
    async getIfcChanges(parentOrUrl) {
        const ifcChangesResponse = await this.getChanges(parentOrUrl, PredefinedBimChangeMetadataQuery.ifc());
        if (ifcChangesResponse.status !== 200) {
            throw new Error(`Failed getting IFC changes for ${parentOrUrl}. Status: ${ifcChangesResponse.status}`);
        }
        const ifcChanges = await ifcChangesResponse.value;
        return ifcChanges.filter((c) => c.type === BimChangeType.Ifc);
    }
    /** @inheritDoc */
    async get(absoluteUrl, converter, init) {
        return Http.get(absoluteUrl, converter, init);
    }
    async getOrUndefined(absoluteUrl, converter, init) {
        const resp = await this.get(absoluteUrl, converter, init);
        if (resp.status !== 200)
            return undefined;
        return await resp.value;
    }
    static async containerInfo(url) {
        return await Http.get(`${url.href.replace(/\/+$/, '')}/_ps/api/project/info`, HttpResponseType.json);
    }
}
//# sourceMappingURL=BimTwinfinityApiClient.js.map