<?php

/**
 * PHP Shapefile - PHP library to read and write ESRI Shapefiles, compatible with WKT and GeoJSON
 *
 * @package Shapefile
 * @author  Gaspare Sganga
 * @version 4.0.0dev
 * @license MIT
 * @link    https://gasparesganga.com/labs/php-shapefile/
 */

namespace Shapefile\Geometry;

use Shapefile\Shapefile;
use Shapefile\ShapefileException;

/**
 * Abstract base class for all geometries.
 * It defines some common public methods and some helper protected functions.
 */
abstract class Geometry
{
    /**
     * @var array|null      Custom bounding box set with setCustomBoundingBox() method.
     */
    private $custom_bounding_box = null;
    
    /**
     * @var array   Data of the Geometry.
     */
    private $data = [];
    
    /**
     * @var bool    Flag representing whether the Geometry is empty.
     */
    private $flag_empty = true;
    
    /**
     * @var bool    Flag representing whether the Geometry has Z dimension.
     */
    private $flag_z = false;
    
    /**
     * @var bool    Flag representing whether the Geometry has M dimension.
     */
    private $flag_m = false;
    
    /**
     * @var bool    Flag representing whether the DBF record is deleted.
     */
    private $flag_deleted = false;
    
    
    
    /////////////////////////////// ABSTRACT ///////////////////////////////
    /**
     * Initialize the Geometry with a structured array.
     *
     * @param   array   $array      Array structured according to Geometry type.
     *
     * @return  self    Returns $this to provide a fluent interface.
     */
    abstract public function initFromArray($array);
    
    /**
     * Initialize the Geometry with WKT.
     *
     * @param   string  $wkt        WKT string.
     *
     * @return  self    Returns $this to provide a fluent interface.
     */
    abstract public function initFromWKT($wkt);
    
    /**
     * Initialize the Geometry with GeoJSON.
     *
     * @param   string  $geojson    GeoJSON string.
     *
     * @return  self    Returns $this to provide a fluent interface.
     */
    abstract public function initFromGeoJSON($geojson);
    
    
    /**
     * Gets the Geometry as a structured array.
     *
     * @return  array
     */
    abstract public function getArray();
    
    /**
     * Gets the Geometry as WKT.
     *
     * @return  string
     */
    abstract public function getWKT();
    
    /**
     * Gets the Geometry as GeoJSON.
     *
     * @param   bool    $flag_bbox      If true include the bounding box in the GeoJSON output.
     * @param   bool    $flag_feature   If true output a GeoJSON Feature with all the data.
     *
     * @return  string
     */
    abstract public function getGeoJSON($flag_bbox = true, $flag_feature = false);
    
    /**
     * Gets Geometry bounding box.
     * If a custom one is defined, it will be returned instead of a computed one.
     *
     * @return  array   Associative array with the xmin, xmax, ymin, ymax and optional zmin, zmax, mmin, mmax values.
     */
    abstract public function getBoundingBox();
    
    /**
     * Gets the Shape base type of the Geometry.
     * This is not intended for users, but Shapefile requires it for internal mechanisms.
     *
     * @internal
     *
     * @return  int
     */
    abstract public function getSHPBasetype();
    
    
    /**
     * Gets the WKT base type of the Geometry.
     *
     * @return  string
     */
    abstract protected function getWKTBasetype();
    
    /**
     * Gets the GeoJSON base type of the Geometry.
     *
     * @return  string
     */
    abstract protected function getGeoJSONBasetype();
    
    
    
    /////////////////////////////// PUBLIC ///////////////////////////////
    /**
     * Gets the state of the Empty flag.
     *
     * @return  bool
     */
    public function isEmpty()
    {
        return $this->flag_empty;
    }
    
    /**
     * Gets the state of the Z flag.
     *
     * @return  bool
     */
    public function isZ()
    {
        return $this->flag_z;
    }
    
    /**
     * Gets the state of the M flag.
     *
     * @return  bool
     */
    public function isM()
    {
        return $this->flag_m;
    }
    
    /**
     * Gets the state of the Deleted flag.
     *
     * @return  bool
     */
    public function isDeleted()
    {
        return $this->flag_deleted;
    }
    
    /**
     * Sets the state of the Deleted flag.
     *
     * @param   bool    $value
     *
     * @return  self    Returns $this to provide a fluent interface.
     */
    public function setFlagDeleted($value)
    {
        $this->flag_deleted = $value;
        return $this;
    }
    
    
    /**
     * Sets a custom bounding box for the Geometry.
     * No check is carried out except a formal compliance of dimensions.
     *
     * @param   array   $bounding_box   Associative array with the xmin, xmax, ymin, ymax and optional zmin, zmax, mmin, mmax values.
     *
     * @return  self    Returns $this to provide a fluent interface.
     */
    public function setCustomBoundingBox($bounding_box)
    {
        $bounding_box = array_intersect_key($bounding_box, array_flip(['xmin', 'xmax', 'ymin', 'ymax', 'zmin', 'zmax', 'mmin', 'mmax']));
        if (
                $this->isEmpty()
            ||  !isset($bounding_box['xmin'], $bounding_box['xmax'], $bounding_box['ymin'], $bounding_box['ymax'])
            ||  (($this->isZ() && !isset($bounding_box['zmin'], $bounding_box['zmax'])) || (!$this->isZ() && (isset($bounding_box['zmin']) || isset($bounding_box['zmax']))))
            ||  (($this->isM() && !isset($bounding_box['mmin'], $bounding_box['mmax'])) || (!$this->isM() && (isset($bounding_box['mmin']) || isset($bounding_box['mmax']))))
        ) {
            throw new ShapefileException(Shapefile::ERR_GEOM_MISMATCHED_BBOX);
        }
        $this->custom_bounding_box = $bounding_box;
        return $this;
    }
    
    /**
     * Resets custom bounding box for the Geometry.
     * It will cause getBoundingBox() method to return a normally computed bbox instead of a custom one.
     *
     * @return  self    Returns $this to provide a fluent interface.
     */
    public function resetCustomBoundingBox()
    {
        $this->custom_bounding_box = null;
        return $this;
    }
    
    
    /**
     * Gets data value for speficied field name.
     *
     * @param   string  $fieldname  Name of the field.
     *
     * @return  mixed
     */
    public function getData($fieldname)
    {
        if (!isset($this->data[$fieldname])) {
            throw new ShapefileException(Shapefile::ERR_INPUT_FIELD_NOT_FOUND, $fieldname);
        }
        return $this->data[$fieldname];
    }
    
    /**
     * Sets data value for speficied field name.
     *
     * @param   string  $fieldname  Name of the field.
     * @param   mixed   $value      Value to assign to the field.
     *
     * @return  self    Returns $this to provide a fluent interface.
     */
    public function setData($fieldname, $value)
    {
        $this->data[$fieldname] = $value;
        return $this;
    }
    
    /**
     * Gets an array of defined data.
     *
     * @return  array
     */
    public function getDataArray()
    {
        return $this->data;
    }
    
    /**
     * Sets an array of data.
     *
     * @param   array   $data       Associative array of values.
     *
     * @return  self    Returns $this to provide a fluent interface.
     */
    public function setDataArray($data)
    {
        foreach ($data as $fieldname => $value) {
            $this->data[$fieldname] = $value;
        }
        return $this;
    }
    
    
    
    /////////////////////////////// PROTECTED ///////////////////////////////
    /**
     * Sets the state of the Empty flag.
     *
     * @param   bool    $value
     *
     * @return  self    Returns $this to provide a fluent interface.
     */
    protected function setFlagEmpty($value)
    {
        $this->flag_empty = $value;
        return $this;
    }
    
    /**
     * Sets the state of the Z flag.
     *
     * @param   bool    $value
     *
     * @return  self    Returns $this to provide a fluent interface.
     */
    protected function setFlagZ($value)
    {
        $this->flag_z = $value;
        return $this;
    }
    
    /**
     * Sets the state of the M flag.
     *
     * @param   bool    $value
     *
     * @return  self    Returns $this to provide a fluent interface.
     */
    protected function setFlagM($value)
    {
        $this->flag_m = $value;
        return $this;
    }
    
    
    /**
     * Checks if the Geometry has been initialized (it is not empty) and if YES throws an exception.
     *
     * @return  self    Returns $this to provide a fluent interface.
     */
    protected function checkInit()
    {
        if (!$this->isEmpty()) {
            throw new ShapefileException(Shapefile::ERR_GEOM_NOT_EMPTY);
        }
        return $this;
    }
    
    
    /**
     * Gets the custom bounding box.
     *
     * @return  array
     *
     * @return  self    Returns $this to provide a fluent interface.
     */
    protected function getCustomBoundingBox()
    {
        return $this->custom_bounding_box;
        return $this;
    }
    
    
    /**
     * Sanitize WKT.
     * It attempts to sanitize user-provided WKT and throws an exception if it appears to be invalid.
     *
     * @param   string  $wkt    The WKT to sanitize.
     *
     * @return  string  Sanitized WKT.
     */
    protected function wktSanitize($wkt)
    {
        // Normalize whitespaces
        $wkt = strtoupper(preg_replace('/\s+/', ' ', trim($wkt)));
        // Normalize commas
        $wkt = str_replace(array(', ', ' ,'), ',', $wkt);
        // Check basetype
        if (substr($wkt, 0, strlen($this->getWKTBasetype())) != strtoupper($this->getWKTBasetype())) {
            throw new ShapefileException(Shapefile::ERR_INPUT_WKT_NOT_VALID);
        }
        return $wkt;
    }
    
    /**
     * Checks if WKT represents an empty Geometry.
     *
     * @param   string  $wkt
     *
     * @return  bool
     */
    protected function wktIsEmpty($wkt)
    {
        return substr($wkt, -5) == 'EMPTY';
    }
    
    /**
     * Checks if WKT represents a Geometry that has a Z dimension.
     *
     * @param   string  $wkt    The whole sanitized WKT.
     *
     * @return  bool
     */
    protected function wktIsZ($wkt)
    {
        return strpos(trim(substr($wkt, strlen($this->getWKTBasetype()), 3)), 'Z') !== false;
    }
    
    /**
     * Checks if WKT represents a Geometry that has a M dimension.
     *
     * @param   string  $wkt    The whole sanitized WKT.
     *
     * @return  bool
     */
    protected function wktIsM($wkt)
    {
        return strpos(trim(substr($wkt, strlen($this->getWKTBasetype()), 3)), 'M') !== false;
    }
    
    /**
     * Extracts data from WKT.
     *
     * @param   string  $wkt    The whole sanitized WKT.
     *
     * @return  string
     */
    protected function wktExtractData($wkt)
    {
        if ($this->wktIsEmpty($wkt)) {
            return null;
        }
        $begin = strpos($wkt, '(');
        if ($begin === false) {
            throw new ShapefileException(ERR_INPUT_WKT_NOT_VALID);
        }
        $end = strrpos($wkt, ')');
        if ($end === false) {
            throw new ShapefileException(ERR_INPUT_WKT_NOT_VALID);
        }
        return trim(substr($wkt, $begin + 1, $end - $begin - 1));
    }
    
    /**
     * Parse a group of WKT coordinates into an associative array.
     * Refer to parseCoordinatesArray() method for output details.
     *
     * @param   string  $coordinates_string The WKT coordinates group.
     * @param   bool    $force_z            Flag to enforce the presence of Z dimension.
     * @param   bool    $force_m            Flag to enforce the presence of M dimension.
     *
     * @return  array
     */
    protected function wktParseCoordinates($coordinates_string, $force_z, $force_m)
    {
        return $this->parseCoordinatesArray(explode(' ', trim($coordinates_string)), $force_z, $force_m, Shapefile::ERR_INPUT_WKT_NOT_VALID);
    }
    
    /**
     * Returns an initialized WKT according to the Geometry properties.
     *
     * @return  string
     */
    protected function wktInitializeOutput()
    {
        $ret = $this->getWKTBasetype();
        if ($this->isEmpty()) {
            $ret .= ' EMPTY';
        } else {
            $ret .= ($this->isZ() ? 'Z' : '') . ($this->isM() ? 'M' : '');
        }
        return $ret;
    }
    
    
    /**
     * Return sanitized GeoJSON, keeping just the geometry part.
     * It attempts to sanitize user-provided GeoJSON and throws an exception if it appears to be invalid.
     *
     * If a GeoJSON Feature is provided, properties data will be stored within the Geometry.
     *
     * @param   string  $geojson    The GeoJSON to sanitize.
     *
     * @return  array   [
     *                      "coordinates"   => []
     *                      "flag_m"        => bool
     *                  ]
     */
    protected function geojsonSanitize($geojson)
    {
        $geojson = json_decode($geojson, true);
        if (json_last_error() !== JSON_ERROR_NONE) {
            throw new ShapefileException(Shapefile::ERR_INPUT_GEOJSON_NOT_VALID, 'Cannot parse JSON input');
        }
        
        // Treat any value other than a GeoJSON object as null (empty Geometry)
        if (!is_array($geojson)) {
            return null;
        }
        
        // Handle Feature
        $geojson = array_change_key_case($geojson, CASE_LOWER);
        if (isset($geojson['type']) && strtolower(trim($geojson['type'])) === 'feature') {
            if (!isset($geojson['properties']) || !is_array($geojson['properties'])) {
                throw new ShapefileException(Shapefile::ERR_INPUT_GEOJSON_NOT_VALID, 'Feature "properties" not defined');
            }
            $this->setDataArray($geojson['properties']);
            $geometry = !empty($geojson['geometry']) ?  array_change_key_case($geojson['geometry'], CASE_LOWER) : null;
        } else {
            $geometry = $geojson;
        }
        
        // If geometry is null it means "an empty Geometry"
        if ($geometry === null) {
            return null;
        }
        // Check if "type" and "coordinates" are defined and in correct format
        if (!isset($geometry['type'], $geometry['coordinates']) || !is_string($geometry['type']) || !is_array($geometry['coordinates'])) {
            throw new ShapefileException(Shapefile::ERR_INPUT_GEOJSON_NOT_VALID, 'Geometry "type" or "coordinates" not correctly defined');
        }
        // Check if "type" is consistent with current Geometry
        $type = strtoupper(trim($geometry['type']));
        if (substr($type, 0, strlen($this->getGeoJSONBasetype())) != strtoupper($this->getGeoJSONBasetype())) {
            throw new ShapefileException(Shapefile::ERR_INPUT_GEOJSON_NOT_VALID, 'Wrong Geometry type - ' . $geometry['type']);
        }
        
        // Empty "coordinates" array means empty Geometry
        return empty($geometry['coordinates']) ? null : [
            'coordinates'   => $geometry['coordinates'],
            'flag_m'        => substr($type, -1) == 'M',
        ];
    }
    
    /**
     * Parse an array of GeoJSON coordinates into an associative array.
     * Refer to parseCoordinatesArray() method for output details.
     *
     * @param   string  $coordinates_array  GeoJSON coordinates array.
     * @param   bool    $force_m            Flag to enforce the presence of M dimension.
     *
     * @return  array
     */
    protected function geojsonParseCoordinates($coordinates_array, $force_m)
    {
        return $this->parseCoordinatesArray($coordinates_array, false, $force_m, Shapefile::ERR_INPUT_GEOJSON_NOT_VALID);
    }
    
    /**
     * Builds valid GeoJSON starting from raw coordinates.
     *
     * @param   array   $coordinates    GeoJSON coordinates array.
     * @param   bool    $flag_bbox      If true include the bounding box in the GeoJSON output.
     * @param   bool    $flag_feature   If true output a GeoJSON Feature with all the data.
     *
     * @return  array
     */
    protected function geojsonPackOutput($coordinates, $flag_bbox, $flag_feature)
    {
        $ret = [];
        // Type
        $ret['type'] = $this->getGeoJSONBasetype() . ($this->isM() ? 'M' : '');
        // Bounding box
        if ($flag_bbox) {
            $ret['bbox'] = [];
            $bbox = $this->getBoundingBox();
            $ret['bbox'][] = $bbox['xmin'];
            $ret['bbox'][] = $bbox['ymin'];
            if ($this->isZ()) {
                $ret['bbox'][] = $bbox['zmin'];
            }
            if ($this->isM()) {
                $ret['bbox'][] = $bbox['mmin'];
            }
            $ret['bbox'][] = $bbox['xmax'];
            $ret['bbox'][] = $bbox['ymax'];
            if ($this->isZ()) {
                $ret['bbox'][] = $bbox['zmax'];
            }
            if ($this->isM()) {
                $ret['bbox'][] = $bbox['mmax'];
            }
        }
        // Coordinates
        $ret['coordinates'] = $coordinates;
        // Feature
        if ($flag_feature) {
            $ret = [
                'type'          => 'Feature',
                'geometry'      => $ret,
                'properties'    => $this->data,
            ];
        }
        
        return json_encode($ret);
    }
    
    
    /////////////////////////////// PRIVATE ///////////////////////////////
     /**
     * Parses an indexed array of coordinates and returns an associative one in the form of:
     *  [
     *      "x" => float
     *      "y" => float
     *      "z" => float|null
     *      "m" => float|null
     *  ]
     *
     * @param   float[] $coordinates    The indexed array of coordinates to parse.
     * @param   bool    $force_z        Flag to enforce the presence of Z dimension.
     * @param   bool    $force_m        Flag to enforce the presence of M dimension.
     * @param   int     $err_code       Error code to throw an exception in case of invalid input.
     *
     * @return  array
     */
    private function parseCoordinatesArray($coordinates, $force_z, $force_m, $err_code)
    {
        $count = count($coordinates);
        if (
            $count < 2                              ||
            (($force_z || $force_m) && $count < 3)  ||
            ($force_z && $force_m && $count < 4)    ||
            $count > 4
        ) {
            throw new ShapefileException($err_code, 'Wrong coordinates format');
        }
        
        $ret = [
            'x' => $coordinates[0],
            'y' => $coordinates[1],
            'z' => null,
            'm' => null,
        ];
        if ($count == 3) {
            if ($force_m) {
                $ret['m'] = $coordinates[2];
            } else {
                $ret['z'] = $coordinates[2];
            }
        }
        if ($count == 4) {
            $ret['z'] = $coordinates[2];
            $ret['m'] = $coordinates[3];
        }
        return $ret;
    }
}