<?php
/**
 * Perforce Swarm
 *
 * @copyright   2013 Perforce Software. All rights reserved.
 * @license     Please see LICENSE.txt in top-level folder of this distribution.
 * @version     <release>/<patch>
 */
namespace Record\Cache;

/**
 * Array serializer that streams elements into a file.
 * This allows large arrays to be serialized with minimal memory usage.
 * Optionally builds an index file for efficient lookups (see ArrayReader).
 */
class ArrayWriter
{
    const INDEX_SUFFIX  = '.index';

    protected $file     = null;
    protected $handle   = null;
    protected $count    = 0;
    protected $index    = false;
    protected $writing  = false;

    /**
     * Setup a new streaming array writer/serializer
     *
     * @param string    $file   the file to write the array to
     * @param bool      $index  optional - build a separate index file of all array entries
     *                          the index provides byte offset information to lookup specific elements
     *                          in the main file without unserializing everything - the index itself is
     *                          a serialized array in the form of: [ key => [byte-offset, length], ... ]
     */
    public function __construct($file, $index = false)
    {
        $this->file = $file;

        if ($index) {
            $this->index = new static($file . static::INDEX_SUFFIX, false);
        }
    }

    /**
     * If the file doesn't exist (or appears to be corrupt), it will be (re)created.
     * If the file already exists and has valid content, the file will not be modified
     * and a runtime exception will be thrown.
     *
     * @return  $this   provides fluent interface
     * @throws  \RuntimeException   if a valid looking file already exists
     */
    public function createFile()
    {
        if (!is_string($this->file) || !strlen($this->file)) {
            throw new \RuntimeException("Cannot create file. Filename must be set to a non-empty string.");
        }

        Cache::ensureWritable(dirname($this->file));

        // open with 'c+' (to create if missing, but not truncate if existing)
        $this->handle = @fopen($this->file, 'c+');
        if ($this->handle === false) {
            throw new \RuntimeException("Unable to create file ('" . $this->file . "').");
        }

        // if anything goes wrong past this point, make sure we close/unlock our file
        try {
            // write lock to ensure no one else reads/writes until we are done
            $locked = flock($this->handle, LOCK_EX);
            if ($locked === false) {
                throw new \RuntimeException("Unable to lock file ('" . $file . "') for writing.");
            }

            // now that we have acquired a write lock, we want to verify that the
            // file is empty or invalid - if we're indexing, we only validate the
            // index file (handled by our index instance), if we're not indexing
            // (or we are ourselves the index instance) we validate $this->file
            if ($this->index) {
                $this->index->createFile();
            } elseif (unserialize(file_get_contents($this->file)) !== false) {
                throw new \RuntimeException("Unable to create file ('" . $this->file . "'). File has valid content.");
            }

            // prime file for array entries - we don't know how many entries there are
            // so we write a zero-padded length for now and update it on close.
            // we ftrunctate in case the file already exists, but has bad content
            ftruncate($this->handle, 0);
            fwrite($this->handle, 'a:0000000000:{');
        } catch (\Exception $e) {
            $this->closeFile();
            throw $e;
        }

        // if we get this far we are committed to writing the file
        // we need a flag for this so we can safely touch-up in close
        $this->writing = true;

        return $this;
    }

    public function writeElement($key, $value)
    {
        if (!is_resource($this->handle)) {
            throw new \RuntimeException("Cannot write element. File is not open.");
        }

        // offset is needed if we're indexing
        $offset = $this->index ? ftell($this->handle) : null;

        fwrite($this->handle, serialize($key));
        fwrite($this->handle, serialize($value));
        $this->count++;

        // if we're indexing, record the position and length of this element
        if ($this->index) {
            $this->index->writeElement($key, array($offset, ftell($this->handle) - $offset));
        }

        return $this;
    }

    public function closeFile()
    {
        if (!$this->isOpen()) {
            throw new \RuntimeException("Cannot close file. File is not open.");
        }

        // close the array, rewind and touch up length (but only if we are writing)
        if ($this->writing) {
            fwrite($this->handle, '}');
            fseek($this->handle, 2);
            fwrite($this->handle, str_pad($this->count, 10, '0', STR_PAD_LEFT));
            $this->writing = false;
        }

        // if we're indexing, need to close index file now
        if ($this->index && $this->index->isOpen()) {
            $this->index->closeFile();
        }

        // readers don't bother locking the index file (just the main file), so we wait until
        // the index is fully closed to unlock the main file - this ensures a complete index
        flock($this->handle, LOCK_UN);
        fclose($this->handle);

        return $this;
    }

    public function isOpen()
    {
        return is_resource($this->handle);
    }
}