import {LngLat} from './lng_lat.ts';
import type {LngLatLike} from './lng_lat.ts';
import {wrap} from '../util/util.ts';

/**
 * A {@link LngLatBounds} object, an array of {@link LngLatLike} objects in `[sw, ne]` order,
 * or an array of numbers in `[west, south, east, north]` order.
 *
 * @group Geography and Geometry
 *
 * @example
 * ```ts
 * let v1 = new LngLatBounds(
 *   new LngLat(-73.9876, 40.7661),
 *   new LngLat(-73.9397, 40.8002)
 * );
 * let v2 = new LngLatBounds([-73.9876, 40.7661], [-73.9397, 40.8002])
 * let v3 = [[-73.9876, 40.7661], [-73.9397, 40.8002]];
 * ```
 */
export type LngLatBoundsLike = LngLatBounds | [LngLatLike, LngLatLike] | [number, number, number, number];

/**
 * A `LngLatBounds` object represents a geographical bounding box,
 * defined by its southwest and northeast points in longitude and latitude.
 *
 * If no arguments are provided to the constructor, a `null` bounding box is created.
 *
 * Note that any MapLibre GL method that accepts a `LngLatBounds` object as an argument or option
 * can also accept an `Array` of two {@link LngLatLike} constructs and will perform an implicit conversion.
 * This flexible type is documented as {@link LngLatBoundsLike}.
 *
 * @group Geography and Geometry
 *
 * @example
 * ```ts
 * let sw = new LngLat(-73.9876, 40.7661);
 * let ne = new LngLat(-73.9397, 40.8002);
 * let llb = new LngLatBounds(sw, ne);
 * ```
 * @see [Fit to the bounds of a LineString](https://maplibre.org/maplibre-gl-js/docs/examples/fit-to-the-bounds-of-a-linestring/)
 */
export class LngLatBounds {
    _ne: LngLat;
    _sw: LngLat;

    /**
     * @param sw - The southwest corner of the bounding box.
     * OR array of 4 numbers in the order of  west, south, east, north
     * OR array of 2 LngLatLike: `[sw, ne]`
     * @param ne - The northeast corner of the bounding box.
     * @example
     * ```ts
     * let sw = new LngLat(-73.9876, 40.7661);
     * let ne = new LngLat(-73.9397, 40.8002);
     * let llb = new LngLatBounds(sw, ne);
     * ```
     * OR
     * ```ts
     * let llb = new LngLatBounds([-73.9876, 40.7661, -73.9397, 40.8002]);
     * ```
     * OR
     * ```ts
     * let llb = new LngLatBounds([sw, ne]);
     * ```
     */
    constructor(sw?: LngLatLike | [number, number, number, number] | [LngLatLike, LngLatLike], ne?: LngLatLike) {
        if (!sw) {
            // noop
        } else if (ne) {
            this.setSouthWest(<LngLatLike>sw).setNorthEast(ne);
        } else if (Array.isArray(sw)) {
            if (sw.length === 4) {
            // 4 element array: west, south, east, north
                this.setSouthWest([sw[0], sw[1]]).setNorthEast([sw[2], sw[3]]);
            } else {
                this.setSouthWest(sw[0] as LngLatLike).setNorthEast(sw[1] as LngLatLike);
            }
        }
    }

    /**
     * Set the northeast corner of the bounding box
     *
     * @param ne - a {@link LngLatLike} object describing the northeast corner of the bounding box.
     */
    setNorthEast(ne: LngLatLike): this {
        this._ne = ne instanceof LngLat ? new LngLat(ne.lng, ne.lat) : LngLat.convert(ne);
        return this;
    }

    /**
     * Set the southwest corner of the bounding box
     *
     * @param sw - a {@link LngLatLike} object describing the southwest corner of the bounding box.
     */
    setSouthWest(sw: LngLatLike): this {
        this._sw = sw instanceof LngLat ? new LngLat(sw.lng, sw.lat) : LngLat.convert(sw);
        return this;
    }

    /**
     * Extend the bounds to include a given LngLatLike or LngLatBoundsLike.
     *
     * @param obj - object to extend to
     */
    extend(obj: LngLatLike | LngLatBoundsLike): this {
        const sw = this._sw,
            ne = this._ne;
        let sw2, ne2;

        if (obj instanceof LngLat) {
            sw2 = obj;
            ne2 = obj;

        } else if (obj instanceof LngLatBounds) {
            sw2 = obj._sw;
            ne2 = obj._ne;

            if (!sw2 || !ne2) return this;

        } else {
            if (Array.isArray(obj)) {
                if (obj.length === 4 || (obj as any[]).every(Array.isArray)) {
                    const lngLatBoundsObj = (obj as any as LngLatBoundsLike);
                    return this.extend(LngLatBounds.convert(lngLatBoundsObj));
                } else {
                    const lngLatObj = (obj as any as LngLatLike);
                    return this.extend(LngLat.convert(lngLatObj));
                }

            } else if (obj && ('lng' in obj || 'lon' in obj) && 'lat' in obj) {
                return this.extend(LngLat.convert(obj));
            }

            return this;
        }

        if (!sw && !ne) {
            this._sw = new LngLat(sw2.lng, sw2.lat);
            this._ne = new LngLat(ne2.lng, ne2.lat);

        } else {
            sw.lng = Math.min(sw2.lng, sw.lng);
            sw.lat = Math.min(sw2.lat, sw.lat);
            ne.lng = Math.max(ne2.lng, ne.lng);
            ne.lat = Math.max(ne2.lat, ne.lat);
        }

        return this;
    }

    /**
     * Returns the geographical coordinate equidistant from the bounding box's corners.
     *
     * @returns The bounding box's center.
     * @example
     * ```ts
     * let llb = new LngLatBounds([-73.9876, 40.7661], [-73.9397, 40.8002]);
     * llb.getCenter(); // = LngLat {lng: -73.96365, lat: 40.78315}
     * ```
     */
    getCenter(): LngLat {
        return new LngLat((this._sw.lng + this._ne.lng) / 2, (this._sw.lat + this._ne.lat) / 2);
    }

    /**
     * Returns the southwest corner of the bounding box.
     *
     * @returns The southwest corner of the bounding box.
     */
    getSouthWest(): LngLat { return this._sw; }

    /**
     * Returns the northeast corner of the bounding box.
     *
     * @returns The northeast corner of the bounding box.
     */
    getNorthEast(): LngLat { return this._ne; }

    /**
     * Returns the northwest corner of the bounding box.
     *
     * @returns The northwest corner of the bounding box.
     */
    getNorthWest(): LngLat { return new LngLat(this.getWest(), this.getNorth()); }

    /**
     * Returns the southeast corner of the bounding box.
     *
     * @returns The southeast corner of the bounding box.
     */
    getSouthEast(): LngLat { return new LngLat(this.getEast(), this.getSouth()); }

    /**
     * Returns the west edge of the bounding box.
     *
     * @returns The west edge of the bounding box.
     */
    getWest(): number { return this._sw.lng; }

    /**
     * Returns the south edge of the bounding box.
     *
     * @returns The south edge of the bounding box.
     */
    getSouth(): number { return this._sw.lat; }

    /**
     * Returns the east edge of the bounding box.
     *
     * @returns The east edge of the bounding box.
     */
    getEast(): number { return this._ne.lng; }

    /**
     * Returns the north edge of the bounding box.
     *
     * @returns The north edge of the bounding box.
     */
    getNorth(): number { return this._ne.lat; }

    /**
     * Returns the bounding box represented as an array.
     *
     * @returns The bounding box represented as an array, consisting of the
     * southwest and northeast coordinates of the bounding represented as arrays of numbers.
     * @example
     * ```ts
     * let llb = new LngLatBounds([-73.9876, 40.7661], [-73.9397, 40.8002]);
     * llb.toArray(); // = [[-73.9876, 40.7661], [-73.9397, 40.8002]]
     * ```
     */
    toArray(): [[number, number], [number, number]] {
        return [this._sw.toArray(), this._ne.toArray()];
    }

    /**
     * Return the bounding box represented as a string.
     *
     * @returns The bounding box represents as a string of the format
     * `'LngLatBounds(LngLat(lng, lat), LngLat(lng, lat))'`.
     * @example
     * ```ts
     * let llb = new LngLatBounds([-73.9876, 40.7661], [-73.9397, 40.8002]);
     * llb.toString(); // = "LngLatBounds(LngLat(-73.9876, 40.7661), LngLat(-73.9397, 40.8002))"
     * ```
     */
    toString() {
        return `LngLatBounds(${this._sw.toString()}, ${this._ne.toString()})`;
    }

    /**
     * Check if the bounding box is an empty/`null`-type box.
     *
     * @returns True if bounds have been defined, otherwise false.
     */
    isEmpty(): boolean {
        return !(this._sw && this._ne);
    }

    /**
     * Check if the point is within the bounding box.
     *
     * @param lnglat - geographic point to check against.
     * @returns `true` if the point is within the bounding box.
     * @example
     * ```ts
     * let llb = new LngLatBounds(
     *   new LngLat(-73.9876, 40.7661),
     *   new LngLat(-73.9397, 40.8002)
     * );
     *
     * let ll = new LngLat(-73.9567, 40.7789);
     *
     * console.log(llb.contains(ll)); // = true
     * ```
     */
    contains(lnglat: LngLatLike): boolean {
        const {lng, lat} = LngLat.convert(lnglat);

        const containsLatitude = this._sw.lat <= lat && lat <= this._ne.lat;
        let containsLongitude = this._sw.lng <= lng && lng <= this._ne.lng;
        if (this._sw.lng > this._ne.lng) { // wrapped coordinates
            containsLongitude = this._sw.lng >= lng && lng >= this._ne.lng;
        }

        return containsLatitude && containsLongitude;
    }

    /**
     * Checks if this bounding box intersects with another bounding box.
     *
     * Returns true if the bounding boxes share any area, including cases where
     * they only touch along an edge or at a corner.
     *
     * This method properly handles cases where either or both bounding boxes cross
     * the antimeridian (date line).
     */
    intersects(other: LngLatBoundsLike): boolean {
        other = LngLatBounds.convert(other);

        const latIntersects =
            other.getNorth() >= this.getSouth() &&
            other.getSouth() <= this.getNorth();

        if (!latIntersects) return false;

        // Check if either bound covers the full world (|span| >= 360°)
        // This must be done before wrapping to preserve the span information
        const thisSpan = Math.abs(this.getEast() - this.getWest());
        const otherSpan = Math.abs(other.getEast() - other.getWest());

        if (thisSpan >= 360 || otherSpan >= 360) {
            return true;
        }

        // Normalize longitudes to [-180, 180] range
        const thisWest = wrap(this.getWest(), -180, 180);
        const thisEast = wrap(this.getEast(), -180, 180);
        const otherWest = wrap(other.getWest(), -180, 180);
        const otherEast = wrap(other.getEast(), -180, 180);

        // Check if either bounds wraps around the antimeridian
        // Use strict inequality: equal values indicate zero-width bounds (e.g., a point), not wrapping
        const thisWraps = thisWest > thisEast;
        const otherWraps = otherWest > otherEast;

        // Both wrap: they always intersect
        if (thisWraps && otherWraps) {
            return true;
        }

        // Only this wraps: intersects if other is outside the gap
        if (thisWraps) {
            return otherEast >= thisWest || otherWest <= thisEast;
        }

        if (otherWraps) {
            // Only other wraps: intersects if this is outside the gap
            return thisEast >= otherWest || thisWest <= otherEast;
        }

        // Neither wraps: standard intersection check
        return otherWest <= thisEast && otherEast >= thisWest;
    }

    /**
     * Converts an array to a `LngLatBounds` object.
     *
     * If a `LngLatBounds` object is passed in, the function returns it unchanged.
     *
     * Internally, the function calls {@link LngLat.convert} to convert arrays to `LngLat` values.
     *
     * @param input - An array of two coordinates to convert, or a `LngLatBounds` object to return.
     * @returns A new `LngLatBounds` object, if a conversion occurred, or the original `LngLatBounds` object.
     * @example
     * ```ts
     * let arr = [[-73.9876, 40.7661], [-73.9397, 40.8002]];
     * let llb = LngLatBounds.convert(arr); // = LngLatBounds {_sw: LngLat {lng: -73.9876, lat: 40.7661}, _ne: LngLat {lng: -73.9397, lat: 40.8002}}
     * ```
     */
    static convert(input: LngLatBoundsLike | null): LngLatBounds {
        if (input instanceof LngLatBounds) return input;
        if (!input) return input as null;
        return new LngLatBounds(input);
    }

    /**
     * Returns a `LngLatBounds` from the coordinates extended by a given `radius`. The returned `LngLatBounds` completely contains the `radius`.
     *
     * @param center - center coordinates of the new bounds.
     * @param radius - Distance in meters from the coordinates to extend the bounds.
     * @returns A new `LngLatBounds` object representing the coordinates extended by the `radius`.
     * @example
     * ```ts
     * let center = new LngLat(-73.9749, 40.7736);
     * LngLatBounds.fromLngLat(100).toArray(); // = [[-73.97501862141328, 40.77351016847229], [-73.97478137858673, 40.77368983152771]]
     * ```
     */
    static fromLngLat(center: LngLat, radius:number = 0): LngLatBounds {
        const earthCircumferenceInMetersAtEquator = 40075017;
        const latAccuracy = 360 * radius / earthCircumferenceInMetersAtEquator,
            lngAccuracy = latAccuracy / Math.cos((Math.PI / 180) * center.lat);

        return new LngLatBounds(new LngLat(center.lng - lngAccuracy, center.lat - latAccuracy),
            new LngLat(center.lng + lngAccuracy, center.lat + latAccuracy));
    }

    /**
     * Adjusts the given bounds to handle the case where the bounds cross the 180th meridian (antimeridian).
     *
     * @returns The adjusted LngLatBounds
     * @example
     * ```ts
     * let bounds = new LngLatBounds([175.813127, -20.157768], [-178. 340903, -15.449124]);
     * let adjustedBounds = bounds.adjustAntiMeridian();
     * // adjustedBounds will be: [[175.813127, -20.157768], [181.659097, -15.449124]]
     * ```
     */
    adjustAntiMeridian(): LngLatBounds {
        const sw = new LngLat(this._sw.lng, this._sw.lat);
        const ne = new LngLat(this._ne.lng, this._ne.lat);

        if (sw.lng > ne.lng) {
            return new LngLatBounds(
                sw,
                new LngLat(ne.lng + 360, ne.lat)
            );
        }

        return new LngLatBounds(sw, ne);
    }

}
