<?php

/**
 * A simple library for working with geometric shapes on a Cartesian Coordinate plane.
 * As featured on https://tharba.kim
 * @author Tharbakim
 * /

/**
 * Basic interface for a simple geometric shape plottable onto a plane described by the cartesian coordinate system.
 * @property Coordinate $pointOfOrigin
 */
interface Plottable
{
    /**
     * Changes the scale of the object from the pointOfOrigin.
     * @param float $scale % of the current size to scale to. 1.00 is the current shape.
     * @return void 
     */
    public function scale(float $scale): void;

    /**
     * Moves the shape based on the vector represented by the Coordinate object.
     * @param Coordinate $translate vector to be applied to the x and y positions of the shape.
     * @return void 
     */
    public function translate(Coordinate $translate): void;

    /**
     * Returns the point of origin of the shape.
     * 
     *  Allows for positioning a shape on the same position as an existing Point() object.
     *  This is used when performing a scale operation on a shape, to calculate the offset after the new size has been determined.
     * @return Coordinate 
     */
    public function getOrigin(): Coordinate;

    /**
     * String representation of the object.
     * 
     * Used for debugging during tests and to see the current state of the object.
     * @return string 
     */
    public function __toString(): string;
}

/**
 * Class to represent points on the cartesian coordinate plane.
 * 
 * Since points on a cartesian coordinate system also double as a vector from the origin of the plane,
 * this class can also be used to describe a vector used in translations.
 * @package 
 */
class Coordinate
{
    private $xPosition;
    private $yPosition;

    public function __construct(float $x, float $y)
    {
        $this->xPosition = $x;
        $this->yPosition = $y;
    }

    /**
     * Setter for the xPosition of the object.
     * @param float $x The value the x position is to be set to on the plane.
     * @return void 
     */
    public function setX(float $x): void
    {
        $this->xPosition = $x;
    }

    /**
     * Setter for the yPosition of the object.
     * @param float $y the value the y position is to be set to on the plane.
     * @return void
     */
    public function setY(float $y): void
    {
        $this->yPosition = $y;
    }

    /**
     * Getter for the x position of the object.
     * @return float x position.
     */
    public function getX(): float
    {
        return $this->xPosition;
    }

    /**
     * Getter for the y position of the object.
     * @return float y position.
     */
    public function getY(): float
    {
        return $this->yPosition;
    }

    /**
     * Apply a translation to the point based on a Coordinate representing a vector.
     * @param Coordinate $translate the vector to apply to the object.
     * @return void 
     */
    public function translate(Coordinate $translate): void
    {
        $this->setX($this->xPosition + $translate->getX());
        $this->setY($this->yPosition + $translate->getY());
    }

    /**
     * Apply a multiplicative scale to the object based on the $pointOfOrigin position.
     * 
     * Technically, could also be used in mirroring/reversing the position of the shape by supplying a negative value.
     * @param Coordinate $pointOfOrigin The "anchor" for the object to be scaled upon.
     * @param float $scale The % scale to apply to the point. 1.00 is the current scale.
     * @return void 
     */
    public function scale(Coordinate $pointOfOrigin, float $scale): void
    {
        $this->setX((($this->xPosition - $pointOfOrigin->getX()) * $scale) + $pointOfOrigin->getX());
        $this->setY((($this->yPosition - $pointOfOrigin->getY()) * $scale) + $pointOfOrigin->getY());
    }

    /**
     * String representation of the object for testing.
     * @return string 
     */
    public function __toString(): string
    {
        return json_encode(array("x" => $this->getX(), "y" => $this->getY()));
    }
}

class Point implements Plottable
{
    /**
     * The position of the Point object on the plane.
     * @var Coordinate
     */
    protected Coordinate $pointOfOrigin;

    public function __construct(Coordinate $point)
    {
        $this->pointOfOrigin = $point;
    }

    public function getOrigin(): Coordinate
    {
        return $this->pointOfOrigin;
    }

    /**
     * This function will never do anything useful, since the point being "Scaled' is always the
     * point of origin for the Point class.
     * 
     * The function is implemented to maintain compatibility with
     * the Plottable interface An alternative solution is to throw an exception here.
     * @param float $scale The % to scale by, as a decimal.
     * @return void 
     */
    public function scale(float $scale): void
    {
        return;
    }

    public function translate(Coordinate $translate): void
    {
        $this->pointOfOrigin->translate($translate);
    }

    public function __toString(): string
    {
        return json_encode(array(
            "pointOfOrigin" => $this->pointOfOrigin->__toString()
        ));
    }
}

class Circle extends Point
{
    private float $radius;
    public function __construct(Coordinate $point, float $radius)
    {
        $this->pointOfOrigin = $point;
        $this->radius = $radius;
    }

    public function scale(float $scale): void
    {
        $this->radius = $this->radius * $scale;
    }

     public function __toString(): string
    {
        return json_encode(array(
            "pointOfOrigin" => $this->pointOfOrigin->__toString(),
            "radius" => $this->radius
        ));
    }
}

class Polygon implements Plottable
{
    private Coordinate $pointOfOrigin;
    private array $coordinates;


    /**
     * A polygon must be an enclosed shape, if the first and last coordinates are not the same the shape is not enclosed.
     * An alternative solution would be to append the coordinates of the first point to the end of the array if they do not match, closing the polygon.
     * @param array $coordinates 
     * @return void 
     * @throws Exception If the polygon is not an enclosed shape.
     */
    private function isClosedShape(array $coordinates): void
    {
        if ($coordinates[0]->__toString() !== $coordinates[count($coordinates) - 1]->__toString()) {
            throw new \Exception('Polygon must be an enclosed shape, but the first and last point are not equal.');
        }
    }

    /**
     * Since PHP doesn't allow type hinting for the contents of an array,
     * check that all the contents fo the array are valid instances of Coordinate
     * @param array $coordinates 
     * @return void
     * @throws Exception If one of the values of $coordinates is not a valid Coordinate object.
     */
    private function isValidCoordinates(array $coordinates): void
    {
        foreach ($coordinates as $key => $point) {
            if (!$point instanceof Coordinate) {
                throw new \Exception("Polygon must be made up of an array of Coordinates, non-coordinate found at position {$key}");
            }
        }
    }

    public function __construct(array $coordinates, ?Coordinate $pointOfOrigin = null)
    {
        $this->isValidCoordinates($coordinates);
        $this->isClosedShape($coordinates);

        $this->coordinates = $coordinates;

        // Because $this->coordinates[0] is a Coordinate object, it is passed by reference.
        // Therefore we do not need to update $this->pointOfOrigin with each translate operation.
        $this->pointOfOrigin = $this->coordinates[0];

        if (!is_null($pointOfOrigin)) {
            $this->translate($pointOfOrigin);
        }
    }

    public function getOrigin(): Coordinate
    {
        return $this->pointOfOrigin;
    }
    public function scale(float $scale): void
    {
        foreach ($this->coordinates as $coordinate) {
            $coordinate->scale($this->pointOfOrigin, $scale);
        }
    }

    public function translate(Coordinate $translate): void
    {
        foreach ($this->coordinates as $coordinate) {
            $coordinate->translate($translate);
        }
        // Point of origin is updated automatically as it its value is set to &$this->coordinates[0]
    }

    public function __toString(): string
    {
        $output = array(
            "pointOfOrigin" => $this->pointOfOrigin->__toString()
        );
        $output['points'] = array();
        foreach ($this->coordinates as $key => $coordinate) {
            $output['points'][$key] = $coordinate->__toString();
        }

        return json_encode($output);
    }
}
