/*
 * Copyright (c) 2015-2018, IGN France.
 * Copyright (c) 2018-2026, Giro3D team.
 * SPDX-License-Identifier: MIT
 */

import { register } from 'ol/proj/proj4.js';
import proj4 from 'proj4';
// @ts-expect-error no types
import parseCode from 'proj4/lib/parseCode';
// @ts-expect-error no types
import wktParser from 'wkt-parser';

import SRID from './SRID';
import { LinearUnit, AngularUnit, parseUnit, type Unit } from './Unit';

type ID = Record<string, number>;

interface WktUnit {
    name: string;
    convert: number;
    AUTHORITY?: object;
}

interface ProjCS {
    type: 'PROJCS';
    name: string;
    UNIT: WktUnit;
    AUTHORITY?: object;
}
interface VertCS {
    UNIT: WktUnit;
}

interface ProjCRS {
    ID: ID;
}

interface CompoundCS {
    type: 'COMPD_CS';
    PROJCS: ProjCS;
    VERT_CS: VertCS;
}

function parseLinearUnit(unit: WktUnit): LinearUnit {
    return new LinearUnit(unit.name, unit.convert);
}

function parseSRID(authority: object): SRID {
    const [name, code] = Object.entries(authority)[0];
    return new SRID(name, Number.parseInt(code));
}

function getNicename(obj: object): string {
    if ('name' in obj && typeof obj.name === 'string') {
        return obj.name;
    }
    return '<unknown>';
}

interface ProjCSInfos {
    name: string;
    srid?: SRID;
    unit: LinearUnit;
}
function getProjCsInfos(projCs: ProjCS): ProjCSInfos {
    const name = getNicename(projCs);
    const unit = parseLinearUnit(projCs.UNIT);

    if (projCs.AUTHORITY) {
        const authority = parseSRID(projCs.AUTHORITY);
        return { name, srid: authority, unit };
    }
    return { name, unit };
}

/**
 * Contains information about coordinate systems, as well as methods to register new coordinate systems.
 */
export class CoordinateSystem {
    /**
     * The EPSG:3857 / pseudo-mercator coordinate systems.
     */
    public static readonly epsg3857 = new CoordinateSystem({
        name: 'WGS 84 / Pseudo-Mercator',
        srid: new SRID('EPSG', 3857),
        horizontal: { unit: LinearUnit.meters },
        vertical: { unit: LinearUnit.meters },
    });
    public static readonly epsg4326 = new CoordinateSystem({
        name: 'WGS 84',
        srid: new SRID('EPSG', 4326),
        horizontal: { unit: AngularUnit.degrees },
        vertical: { unit: LinearUnit.meters },
    });
    public static readonly epsg4978 = new CoordinateSystem({
        name: 'WGS 84',
        srid: new SRID('EPSG', 4978),
        horizontal: { unit: LinearUnit.meters },
        vertical: { unit: LinearUnit.meters },
    });
    public static readonly epsg4979 = new CoordinateSystem({
        name: 'WGS 84',
        srid: new SRID('EPSG', 4979),
        horizontal: { unit: AngularUnit.degrees },
        vertical: { unit: LinearUnit.meters },
    });
    /**
     * A special coordinate system used for spherical projections.
     */
    public static readonly equirectangular = new CoordinateSystem({
        name: 'equirectangular',
        horizontal: { unit: AngularUnit.degrees },
    });

    public static readonly unknown = new CoordinateSystem({ name: 'unknown' });

    private static readonly _registry: Map<string, CoordinateSystem> = new Map([
        ['EPSG:3857', CoordinateSystem.epsg3857],
        ['EPSG:4326', CoordinateSystem.epsg4326],
        ['EPSG:4978', CoordinateSystem.epsg4978],
        ['EPSG:4979', CoordinateSystem.epsg4979],
        ['equirectangular', CoordinateSystem.equirectangular],
        ['unknown', CoordinateSystem.unknown],
    ]);

    /**
     * Registers a coordinate system with the underlying proj and OpenLayers libraries.
     *
     * Note: it is recommended to provide WKT definitions instead of proj strings, since
     * they provide more metadata about the CRS (such as name, SRID, etc).
     *
     * Note 2: some coordinate systems definitions (such as WKT 2's `COMPOUNDCRS`) are
     * not supported by the underlying proj library. However, if you are not planning
     * to use any feature of Giro3D that requires the proj library, you may ignore
     * failures and warnings.
     *
     * @param id - The id of the coordinate system.
     * @param definition - The WKT or proj definition.
     * @param options - Registration options.
     * @example
     * const wkt = \`
     * PROJCS["RGF93 v1 / Lambert-93",
     *     GEOGCS["RGF93 v1",
     *         DATUM["Reseau_Geodesique_Francais_1993_v1",
     *             SPHEROID["GRS 1980",6378137,298.257222101],
     *             TOWGS84[0,0,0,0,0,0,0]],
     *         PRIMEM["Greenwich",0,
     *             AUTHORITY["EPSG","8901"]],
     *         UNIT["degree",0.0174532925199433,
     *             AUTHORITY["EPSG","9122"]],
     *         AUTHORITY["EPSG","4171"]],
     *     PROJECTION["Lambert_Conformal_Conic_2SP"],
     *     PARAMETER["latitude_of_origin",46.5],
     *     PARAMETER["central_meridian",3],
     *     PARAMETER["standard_parallel_1",49],
     *     PARAMETER["standard_parallel_2",44],
     *     PARAMETER["false_easting",700000],
     *     PARAMETER["false_northing",6600000],
     *     UNIT["metre",1,
     *         AUTHORITY["EPSG","9001"]],
     *     AXIS["Easting",EAST],
     *     AXIS["Northing",NORTH],
     *     AUTHORITY["EPSG","2154"]]
     * \`;
     *
     * const crs = CoordinateSystem.register('EPSG:2154', wkt);
     * console.log(crs.name);
     * @returns A {@link CoordinateSystem} instance.
     */
    public static register(
        /**
         * The ID of the coordinate system.
         */
        id: string,
        /**
         * The WKT or proj definition.
         */
        definition: string,
        options?: {
            /**
             * If true, any error that occurs when registering the
             * coordinate system definition with proj4.js is re-thrown.
             * Otherwise, a simple warning is logged instead.
             */
            throwIfFailedToRegisterWithProj?: boolean;
        },
    ): CoordinateSystem {
        if (this._registry.has(id)) {
            return this._registry.get(id) as CoordinateSystem;
        }
        try {
            this.registerCRSWithProjAndOpenLayers(id, definition);
        } catch (error) {
            // proj4.js is not able to parse all WKT definitions, especially compound CRSes.
            // this does not mean that the coordinate system cannot be used at all, just that
            // it cannot be used by proj4.js or OpenLayers.
            // In other words, if the Giro3D scene is purely 3D without any mapping component
            // that will use proj4.js, then it should be fine.
            if (options?.throwIfFailedToRegisterWithProj === true) {
                throw error;
            } else {
                console.warn(error);
            }
        }
        const crs = CoordinateSystem.fromWkt(definition, { id });
        this._registry.set(id, crs);
        return crs;
    }

    /**
     * Mostly used for unit testing.
     * @internal
     */
    public static clearRegistry(): void {
        this._registry.clear();

        this._registry.set('EPSG:3857', CoordinateSystem.epsg3857);
        this._registry.set('EPSG:4326', CoordinateSystem.epsg4326);
        this._registry.set('EPSG:4978', CoordinateSystem.epsg4978);
        this._registry.set('EPSG:4979', CoordinateSystem.epsg4979);
        this._registry.set('equirectangular', CoordinateSystem.equirectangular);
        this._registry.set('unknown', CoordinateSystem.unknown);
    }

    /**
     * @param name - the short name, or EPSG code to identify this CRS.
     * @param value - the CRS definition, either in proj syntax, or in WKT syntax.
     */
    private static registerCRSWithProjAndOpenLayers(name: string, value: string): void {
        if (!name || name === '') {
            throw new Error('missing CRS name');
        }
        if (!value || value === '') {
            throw new Error('missing CRS PROJ string');
        }

        try {
            // define the CRS with PROJ
            proj4.defs(name, value);
        } catch (e) {
            let message = '';
            if (e instanceof Error) {
                message = ': ' + e.message;
            }
            throw new Error(`failed to register PROJ definition for ${name}${message}`);
        }
        try {
            // register this CRS with OpenLayers
            register(proj4);
        } catch (e) {
            let message = '';
            if (e instanceof Error) {
                message = ': ' + e.message;
            }
            throw new Error(`failed to register PROJ definitions in OpenLayers${message}`);
        }
    }

    public static get(srid: string): CoordinateSystem {
        const crs = this._registry.get(srid);

        if (crs) {
            return crs;
        }

        throw new Error(`coordinate system not found: ${srid}`);
    }

    /**
     * Creates a {@link CoordinateSystem} from its WKT definition.
     *
     * Note: this does not register the coordinate system with proj4.js. Use {@link register} instead.
     * @param wkt - The WKT 1 or WKT 2 definition.
     * @returns The created coordinate system, or throws an error if the definition could not be parsed.
     */
    public static fromWkt(wkt: string, overrides?: { id?: string }): CoordinateSystem {
        try {
            let parsed: ProjCRS | ProjCS | CompoundCS | object;

            try {
                // We use the wkt-parser package directly because it provides better
                // information, especially correct SRID, but only works for WKT.
                // For a proj string, we have to fallback to parseCode()
                parsed = wktParser(wkt);
            } catch {
                parsed = parseCode(wkt);
            }

            if ('ID' in parsed) {
                // WKT 2 / PROJCRS
                return new CoordinateSystem({
                    id: overrides?.id,
                    name: getNicename(parsed),
                    srid: parseSRID(parsed.ID),
                    definition: wkt,
                });
            } else if ('PROJCS' in parsed) {
                // WKT 1 / COMPD_CS
                const projCsInfos = getProjCsInfos(parsed['PROJCS']);
                const parameters: ConstructorParameters<typeof CoordinateSystem>[0] = {
                    id: overrides?.id,
                    name: projCsInfos.name,
                    srid: projCsInfos.srid,
                    definition: wkt,
                    horizontal: { unit: projCsInfos.unit },
                };
                if ('VERT_CS' in parsed) {
                    parameters.vertical = { unit: parseLinearUnit(parsed.VERT_CS.UNIT) };
                }
                return new CoordinateSystem(parameters);
            } else if ('type' in parsed && parsed.type === 'PROJCS') {
                // WKT 1 / PROJCS
                const projCsInfos = getProjCsInfos(parsed);
                return new CoordinateSystem({
                    id: overrides?.id,
                    name: projCsInfos.name,
                    srid: projCsInfos.srid,
                    definition: wkt,
                    horizontal: { unit: projCsInfos.unit },
                });
            } else {
                let srid: SRID | undefined = undefined;
                let unit: Unit | undefined = undefined;

                if (
                    'AUTHORITY' in parsed &&
                    typeof parsed.AUTHORITY === 'object' &&
                    parsed.AUTHORITY
                ) {
                    srid = parseSRID(parsed.AUTHORITY);
                }

                if ('title' in parsed && typeof parsed.title === 'string') {
                    srid = SRID.parse(parsed.title);
                }

                if ('units' in parsed && typeof parsed.units === 'string') {
                    unit = parseUnit(parsed.units);
                }

                return new CoordinateSystem({
                    id: overrides?.id,
                    name: getNicename(parsed),
                    srid: srid,
                    horizontal: unit != null ? { unit } : undefined,
                });
            }
        } catch (error: unknown) {
            console.error(`Failed to parse wkt "${wkt}".`);
            throw error;
        }
    }

    private readonly _customId?: string;

    /**
     * The readable name of this coordinate system.
     */
    public readonly name: string;
    /**
     * The SRID of this coordinate system.
     */
    public readonly srid?: SRID;
    /**
     * Contains metadata about the horizontal component of this coordinate system.
     */
    public readonly horizontal?: { readonly unit: Unit };
    /**
     * Contains metadata about the vertical component of this coordinate system.
     */
    public readonly vertical?: { readonly unit: LinearUnit };
    /**
     * The WKT definition of this coordinate system.
     */
    public readonly definition?: string;

    /**
     * The internal identifier of this coordinate system. Used as a key in the coordinate system registry.
     * By order of priority, will return: the custom identifier, the SRID, then the name.
     */
    public get id(): string {
        if (typeof this._customId !== 'undefined') {
            return this._customId;
        }

        if (typeof this.srid !== 'undefined') {
            return this.srid.toString();
        }
        return this.name;
    }

    public constructor(params: {
        /**
         * The name of the coordinate system.
         */
        name: string;
        /**
         * The optional SRID of this coordinate system.
         */
        srid?: SRID;
        /**
         * The id of this coordinate system. If unspecified, will use the SRID or name, if available.
         */
        id?: string;
        /**
         * The horizontal component of the coordinate system.
         */
        horizontal?: { unit: Unit };
        /**
         * The vertical component of the coordinate system.
         */
        vertical?: { unit: LinearUnit };
        /**
         * The WKT definition of the coordinate system.
         */
        definition?: string;
    }) {
        this.name = params.name;
        this.srid = params.srid;
        this._customId = params.id;

        if (typeof params.horizontal !== 'undefined') {
            this.horizontal = params.horizontal;
        }
        if (typeof params.vertical !== 'undefined') {
            this.vertical = params.vertical;
        }
        if (typeof params.definition !== 'undefined') {
            this.definition = params.definition;
        }
    }

    /**
     * Returns true if this coordinate system has angular units.
     */
    public isGeographic(): boolean {
        const unit = this.horizontal?.unit;
        if (AngularUnit.isAngularUnit(unit)) {
            return true;
        }

        return false;
    }

    /**
     * Returns the conversion factor between horizontal units and meters.
     */
    public get metersPerHorizontalUnit(): number {
        const unit = this.horizontal?.unit;
        if (LinearUnit.isLinearUnit(unit)) {
            return unit.metersPerUnit;
        }
        return 1;
    }

    /**
     * Returns the conversion factor between vertical units and meters.
     */
    public get metersPerVerticalUnit(): number {
        const unit = this.vertical?.unit;
        if (LinearUnit.isLinearUnit(unit)) {
            return unit.metersPerUnit;
        }
        return this.metersPerHorizontalUnit;
    }

    public isEpsg(code: number): boolean {
        if (typeof this.srid !== 'undefined') {
            return this.srid.isEpsg(code);
        }
        return false;
    }

    /**
     * Returns `true` if this coordinate system is the special equirectangular coordinate system (used for spherical mapping).
     */
    public isEquirectangular(): boolean {
        return this.name === 'equirectangular';
    }

    /**
     * Returns `true` if this coordinate system is the special unknown coordinate system (used for non-georeferenced scenes).
     */
    public isUnknown(): boolean {
        return this.name === 'unknown' && typeof this.definition === 'undefined';
    }

    /**
     * Returns `true` if the two coordinate systems are equal.
     */
    public equals(other: CoordinateSystem): boolean {
        return this.id === other.id;
    }
}

export default CoordinateSystem;
