<?php

namespace EasyRdf;

/**
 * EasyRdf
 *
 * LICENSE
 *
 * Copyright (c) 2013-2014 Nicholas J Humfrey.  All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are met:
 * 1. Redistributions of source code must retain the above copyright
 *    notice, this list of conditions and the following disclaimer.
 * 2. Redistributions in binary form must reproduce the above copyright notice,
 *    this list of conditions and the following disclaimer in the documentation
 *    and/or other materials provided with the distribution.
 * 3. The name of the author 'Nicholas J Humfrey" may be used to endorse or
 *    promote products derived from this software without specific prior
 *    written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
 * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
 * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
 * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
 * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
 * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
 * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
 * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
 * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
 * POSSIBILITY OF SUCH DAMAGE.
 *
 * @copyright  Copyright (c) 2013-2014 Nicholas J Humfrey
 * @license    https://www.opensource.org/licenses/bsd-license.php
 */

/**
 * Sub-class of EasyRdf\Resource that represents an RDF collection (rdf:List)
 *
 * This class can be used to iterate through a collection of items.
 *
 * Note that items are numbered from 1 (not 0) for consistency with RDF Containers.
 *
 * @see       http://www.w3.org/TR/xmlschema-2/#date
 *
 * @copyright  Copyright (c) 2013-2014 Nicholas J Humfrey
 * @license    https://www.opensource.org/licenses/bsd-license.php
 */
class Collection extends Resource implements \ArrayAccess, \Countable, \SeekableIterator
{
    private $position;
    private $current;

    /** Create a new collection - do not use this directly
     *
     * @ignore
     */
    public function __construct($uri, $graph)
    {
        $this->position = 1;
        $this->current = null;
        parent::__construct($uri, $graph);
    }

    /** Seek to a specific position in the container
     *
     * The first item is postion 1
     *
     * @param int $position The position in the container to seek to
     *
     * @throws \OutOfBoundsException
     * @throws \InvalidArgumentException
     */
    #[\ReturnTypeWillChange]
    public function seek($position)
    {
        if (\is_int($position) && $position > 0) {
            list($node, $actual) = $this->getCollectionNode($position);
            if ($actual === $position) {
                $this->position = $actual;
                $this->current = $node;
            } else {
                throw new \OutOfBoundsException("Unable to seek to position $position in the collection");
            }
        } else {
            throw new \InvalidArgumentException('Collection position must be a positive integer');
        }
    }

    /** Rewind the iterator back to the start of the collection
     *
     */
    #[\ReturnTypeWillChange]
    public function rewind()
    {
        $this->position = 1;
        $this->current = null;
    }

    /** Return the current item in the collection
     *
     * @return mixed The current item
     */
    #[\ReturnTypeWillChange]
    public function current()
    {
        if (1 === $this->position) {
            return $this->get('rdf:first');
        } elseif ($this->current) {
            return $this->current->get('rdf:first');
        }
    }

    /** Return the key / current position in the collection
     *
     * Note: the first item is number 1
     *
     * @return int The current position
     */
    #[\ReturnTypeWillChange]
    public function key()
    {
        return $this->position;
    }

    /** Move forward to next item in the collection
     *
     */
    #[\ReturnTypeWillChange]
    public function next()
    {
        if (1 === $this->position) {
            $this->current = $this->get('rdf:rest');
        } elseif ($this->current) {
            $this->current = $this->current->get('rdf:rest');
        }
        ++$this->position;
    }

    /** Checks if current position is valid
     *
     * @return bool True if the current position is valid
     */
    #[\ReturnTypeWillChange]
    public function valid()
    {
        if (1 === $this->position && $this->hasProperty('rdf:first')) {
            return true;
        } elseif (null !== $this->current && $this->current->hasProperty('rdf:first')) {
            return true;
        } else {
            return false;
        }
    }

    /** Get a node for a particular offset into the collection
     *
     * This function may not return the item you requested, if
     * it does not exist. Please check the $postion parameter
     * returned.
     *
     * If the offset is null, then the last node in the
     * collection (before rdf:nil) will be returned.
     *
     * @param int|null $offset The offset into the collection (or null)
     *
     * @return array $node, $postion  The node object and postion of the node
     */
    public function getCollectionNode($offset)
    {
        $position = 1;
        $node = $this;
        $nil = $this->graph->resource('rdf:nil');
        while (
            ($rest = $node->get('rdf:rest'))
            && $rest !== $nil
            && (null === $offset || ($position < $offset))
        ) {
            $node = $rest;
            ++$position;
        }

        return [$node, $position];
    }

    /** Counts the number of items in the collection
     *
     * Note that this is an slow method - it is more efficient to use
     * the iterator interface, if you can.
     *
     * @return int The number of items in the collection
     */
    #[\ReturnTypeWillChange]
    public function count()
    {
        // Find the end of the collection
        list($node, $position) = $this->getCollectionNode(null);
        if (!$node->hasProperty('rdf:first')) {
            return 0;
        } else {
            return $position;
        }
    }

    /** Append an item to the end of the collection
     *
     * @param mixed $value The value to append
     *
     * @return int The number of values appended (1 or 0)
     */
    public function append($value)
    {
        // Find the end of the collection
        list($node) = $this->getCollectionNode(null);
        $rest = $node->get('rdf:rest');

        if ($node === $this && null === $rest) {
            $node->set('rdf:first', $value);
            $node->addResource('rdf:rest', 'rdf:nil');
        } else {
            $new = $this->graph->newBnode();
            $node->set('rdf:rest', $new);
            $new->add('rdf:first', $value);
            $new->addResource('rdf:rest', 'rdf:nil');
        }

        return 1;
    }

    /** Array Access: check if a position exists in collection using array syntax
     *
     * Example: isset($list[2])
     */
    #[\ReturnTypeWillChange]
    public function offsetExists($offset)
    {
        if (\is_int($offset) && $offset > 0) {
            list($node, $position) = $this->getCollectionNode($offset);

            return $node && $position === $offset && $node->hasProperty('rdf:first');
        } else {
            throw new \InvalidArgumentException('Collection offset must be a positive integer');
        }
    }

    /** Array Access: get an item at a specified position in collection using array syntax
     *
     * Example: $item = $list[2];
     */
    #[\ReturnTypeWillChange]
    public function offsetGet($offset)
    {
        if (\is_int($offset) && $offset > 0) {
            list($node, $position) = $this->getCollectionNode($offset);
            if ($node && $position === $offset) {
                return $node->get('rdf:first');
            }
        } else {
            throw new \InvalidArgumentException('Collection offset must be a positive integer');
        }
    }

    /**
     * Array Access: set an item at a positon in collection using array syntax
     *
     * Example: $list[2] = $item;
     *
     * @todo Change return type to void, because this violates offsetSet:void
     *       see: https://php.net/manual/en/arrayaccess.offsetset.php
     */
    #[\ReturnTypeWillChange]
    public function offsetSet($offset, $value)
    {
        if (null === $offset) {
            // No offset - append to end of collection
            $this->append($value);
        } elseif (\is_int($offset) && $offset > 0) {
            list($node, $position) = $this->getCollectionNode($offset);

            // Create nodes, if they are missing
            while ($position < $offset) {
                $new = $this->graph->newBnode();
                $node->set('rdf:rest', $new);
                $new->addResource('rdf:rest', 'rdf:nil');
                $node = $new;
                ++$position;
            }

            // Terminate the list
            if (!$node->hasProperty('rdf:rest')) {
                $node->addResource('rdf:rest', 'rdf:nil');
            }

            // currently ignored by phpstan (see phpstan.neon)
            return $node->set('rdf:first', $value);
        } else {
            throw new \InvalidArgumentException('Collection offset must be a positive integer');
        }
    }

    /**
     * Array Access: delete an item at a specific postion using array syntax
     *
     * Example: unset($seq[2]);
     */
    #[\ReturnTypeWillChange]
    public function offsetUnset($offset)
    {
        if (\is_int($offset) && $offset > 0) {
            list($node, $position) = $this->getCollectionNode($offset);
        } else {
            throw new \InvalidArgumentException('Collection offset must be a positive integer');
        }

        // Does the item exist?
        if ($node && $position === $offset) {
            $nil = $this->graph->resource('rdf:nil');
            if (1 === $position) {
                $rest = $node->get('rdf:rest');
                if ($rest && $rest !== $nil) {
                    // Move second value, so we can keep the head of list
                    $node->set('rdf:first', $rest->get('rdf:first'));
                    $node->set('rdf:rest', $rest->get('rdf:rest'));
                    $rest->delete('rdf:first');
                    $rest->delete('rdf:rest');
                } else {
                    // Just remove the value
                    $node->delete('rdf:first');
                    $node->delete('rdf:rest');
                }
            } else {
                // Remove the value and re-link the list
                $node->delete('rdf:first');
                $rest = $node->get('rdf:rest');
                $previous = $node->get('^rdf:rest');
                if (null === $rest) {
                    $rest = $nil;
                }
                if ($previous) {
                    $previous->set('rdf:rest', $rest);
                }
            }
        }
    }
}
