<?php

declare(strict_types=1);

namespace Brick\Geo;

use Brick\Geo\Attribute\NoProxy;
use Brick\Geo\Exception\CoordinateSystemException;
use Brick\Geo\Exception\GeometryIoException;
use Brick\Geo\Exception\InvalidGeometryException;
use Brick\Geo\Exception\UnexpectedGeometryException;
use Brick\Geo\Io\WkbReader;
use Brick\Geo\Io\WkbWriter;
use Brick\Geo\Io\WktReader;
use Brick\Geo\Io\WktWriter;
use Brick\Geo\Projector\Projector;
use Brick\Geo\Projector\RemoveZmProjector;
use Brick\Geo\Projector\RoundCoordinatesProjector;
use Brick\Geo\Projector\SridProjector;
use Brick\Geo\Projector\SwapXyProjector;
use Override;
use Stringable;

/**
 * Geometry is the root class of the hierarchy.
 */
abstract class Geometry implements Stringable
{
    final public const GEOMETRY = 0;
    final public const POINT = 1;
    final public const LINESTRING = 2;
    final public const POLYGON = 3;
    final public const MULTIPOINT = 4;
    final public const MULTILINESTRING = 5;
    final public const MULTIPOLYGON = 6;
    final public const GEOMETRYCOLLECTION = 7;
    final public const CIRCULARSTRING = 8;
    final public const COMPOUNDCURVE = 9;
    final public const CURVEPOLYGON = 10;
    final public const MULTICURVE = 11;
    final public const MULTISURFACE = 12;
    final public const CURVE = 13;
    final public const SURFACE = 14;
    final public const POLYHEDRALSURFACE = 15;
    final public const TIN = 16;
    final public const TRIANGLE = 17;

    /**
     * The coordinate system of this geometry.
     */
    protected CoordinateSystem $coordinateSystem;

    /**
     * Whether this geometry is empty.
     */
    protected bool $isEmpty;

    /**
     * @param CoordinateSystem $coordinateSystem The coordinate system of this geometry.
     * @param bool             $isEmpty          Whether this geometry is empty.
     */
    protected function __construct(CoordinateSystem $coordinateSystem, bool $isEmpty)
    {
        $this->coordinateSystem = $coordinateSystem;
        $this->isEmpty = $isEmpty;
    }

    /**
     * Builds a Geometry from a WKT representation.
     *
     * If the resulting geometry is valid but is not an instance of the class this method is called on,
     * for example passing a Polygon WKT to Point::fromText(), an exception is thrown.
     *
     * @param string $wkt  The Well-Known Text representation.
     * @param int    $srid The optional SRID to use.
     *
     * @return static
     *
     * @throws GeometryIoException         If the given string is not a valid WKT representation.
     * @throws CoordinateSystemException   If the WKT contains mixed coordinate systems.
     * @throws InvalidGeometryException    If the WKT represents an invalid geometry.
     * @throws UnexpectedGeometryException If the resulting geometry is not an instance of the current class.
     */
    public static function fromText(string $wkt, int $srid = 0): Geometry
    {
        /** @var WktReader|null $wktReader */
        static $wktReader;

        if ($wktReader === null) {
            $wktReader = new WktReader();
        }

        $geometry = $wktReader->read($wkt, $srid);

        if ($geometry instanceof static) {
            return $geometry;
        }

        throw UnexpectedGeometryException::unexpectedGeometryType(static::class, $geometry);
    }

    /**
     * Builds a Geometry from a WKB representation.
     *
     * If the resulting geometry is valid but is not an instance of the class this method is called on,
     * for example passing a Polygon WKB to Point::fromBinary(), an exception is thrown.
     *
     * @param string $wkb  The Well-Known Binary representation.
     * @param int    $srid The optional SRID to use.
     *
     * @return static
     *
     * @throws GeometryIoException         If the given string is not a valid WKB representation.
     * @throws CoordinateSystemException   If the WKB contains mixed coordinate systems.
     * @throws InvalidGeometryException    If the WKB represents an invalid geometry.
     * @throws UnexpectedGeometryException If the resulting geometry is not an instance of the current class.
     */
    public static function fromBinary(string $wkb, int $srid = 0): Geometry
    {
        /** @var WkbReader|null $wkbReader */
        static $wkbReader;

        if ($wkbReader === null) {
            $wkbReader = new WkbReader();
        }

        $geometry = $wkbReader->read($wkb, $srid);

        if ($geometry instanceof static) {
            return $geometry;
        }

        throw UnexpectedGeometryException::unexpectedGeometryType(static::class, $geometry);
    }

    /**
     * Returns the inherent dimension of this geometry.
     *
     * This dimension must be less than or equal to the coordinate dimension.
     * In non-homogeneous collections, this will return the largest topological dimension of the contained objects.
     */
    abstract public function dimension(): int;

    /**
     * Returns the coordinate dimension of this geometry.
     *
     * The coordinate dimension is the total number of coordinates in the coordinate system.
     *
     * The coordinate dimension can be 2 (for x and y), 3 (with z or m added), or 4 (with both z and m added).
     * The ordinates x, y and z are spatial, and the ordinate m is a measure.
     *
     * @return int<2, 4>
     */
    public function coordinateDimension(): int
    {
        return $this->coordinateSystem->coordinateDimension();
    }

    /**
     * Returns the spatial dimension of this geometry.
     *
     * The spatial dimension is the number of measurements or axes needed to describe the
     * spatial position of this geometry in a coordinate system.
     *
     * The spatial dimension is 3 if the coordinate system has a Z coordinate, 2 otherwise.
     *
     * @return int<2, 3>
     */
    public function spatialDimension(): int
    {
        return $this->coordinateSystem->spatialDimension();
    }

    /**
     * Returns the name of the instantiable subtype of Geometry of which this Geometry is an instantiable member.
     */
    abstract public function geometryType(): string;

    abstract public function geometryTypeBinary(): int;

    /**
     * Returns the Spatial Reference System ID for this geometry.
     *
     * @return int The SRID, zero if not set.
     */
    #[NoProxy]
    public function srid(): int
    {
        return $this->coordinateSystem->srid();
    }

    /**
     * Returns the WKT representation of this geometry.
     */
    #[NoProxy]
    public function asText(): string
    {
        /** @var WktWriter|null $wktWriter */
        static $wktWriter;

        if ($wktWriter === null) {
            $wktWriter = new WktWriter();
        }

        return $wktWriter->write($this);
    }

    /**
     * Returns the WKB representation of this geometry.
     */
    #[NoProxy]
    public function asBinary(): string
    {
        /** @var WkbWriter|null $wkbWriter */
        static $wkbWriter;

        if ($wkbWriter === null) {
            $wkbWriter = new WkbWriter();
        }

        return $wkbWriter->write($this);
    }

    /**
     * Returns whether this geometry is the empty Geometry.
     *
     * If true, then this geometry represents the empty point set for the coordinate space.
     */
    public function isEmpty(): bool
    {
        return $this->isEmpty;
    }

    /**
     * Returns whether this geometry has z coordinate values.
     */
    public function is3D(): bool
    {
        return $this->coordinateSystem->hasZ();
    }

    /**
     * Returns whether this geometry has m coordinate values.
     */
    public function isMeasured(): bool
    {
        return $this->coordinateSystem->hasM();
    }

    /**
     * Returns the coordinate system of this geometry.
     */
    public function coordinateSystem(): CoordinateSystem
    {
        return $this->coordinateSystem;
    }

    /**
     * Returns a copy of this Geometry, with the SRID altered.
     *
     * Note that only the SRID value is changed, the coordinates are not reprojected.
     * Use GeometryEngine::transform() to reproject the Geometry to another SRID.
     *
     * @return static
     */
    public function withSrid(int $srid): Geometry
    {
        if ($srid === $this->srid()) {
            return $this;
        }

        return $this->project(new SridProjector($srid));
    }

    /**
     * Returns a copy of this Geometry, with Z and M coordinates removed.
     *
     * @return static
     */
    public function toXy(): Geometry
    {
        if ($this->coordinateDimension() === 2) {
            return $this;
        }

        return $this->project(new RemoveZmProjector(removeZ: true, removeM: true));
    }

    /**
     * Returns a copy of this Geometry, with the Z coordinate removed.
     *
     * @return static
     */
    public function withoutZ(): Geometry
    {
        if (! $this->coordinateSystem->hasZ()) {
            return $this;
        }

        return $this->project(new RemoveZmProjector(removeZ: true));
    }

    /**
     * Returns a copy of this Geometry, with the M coordinate removed.
     *
     * @return static
     */
    public function withoutM(): Geometry
    {
        if (! $this->coordinateSystem->hasM()) {
            return $this;
        }

        return $this->project(new RemoveZmProjector(removeM: true));
    }

    /**
     * Returns a copy of this Geometry, with coordinates rounded to the given precision.
     *
     * @return static
     */
    public function withRoundedCoordinates(int $precision): Geometry
    {
        return $this->project(new RoundCoordinatesProjector($precision));
    }

    /**
     * Returns the bounding box of the Geometry.
     */
    abstract public function getBoundingBox(): BoundingBox;

    /**
     * Returns the raw coordinates of this geometry as an array.
     *
     * This returns potentially nested lists of floats.
     *
     * Examples:
     * - a Point will return list<float>
     * - a LineString will return list<list<float>>
     * - a Polygon will return list<list<list<float>>>
     *
     * Subclasses will narrow down the return type as appropriate.
     *
     * @return list<mixed>
     */
    abstract public function toArray(): array;

    /**
     * Returns a copy of this Geometry, with the X and Y coordinates swapped.
     *
     * @return static
     */
    public function swapXy(): Geometry
    {
        return $this->project(new SwapXyProjector());
    }

    /**
     * Projects this geometry to a different coordinate system.
     */
    abstract public function project(Projector $projector): Geometry;

    /**
     * Returns whether this Geometry is identical to another Geometry.
     *
     * This method will only return true if the geometries are of the same type, with the exact same coordinates,
     * in the same order, and with the same SRID.
     *
     * This is different from the concept of spatially equal; if you need to check for spatial equality,
     * please see `GeometryEngine::equals()` instead.
     */
    public function isIdenticalTo(Geometry $that): bool
    {
        return $this->srid() === $that->srid() && $this->asText() === $that->asText();
    }

    /**
     * Returns a text representation of this geometry.
     */
    #[NoProxy, Override]
    final public function __toString(): string
    {
        return $this->asText();
    }
}
