<?php

/**
 * @package KantPHP
 * @author  Zhenqiang Zhang <zhenqiang.zhang@hotmail.com>
 * @original-author Laravel/Symfony
 * @copyright (c) KantPHP Studio, All rights reserved.
 * @license http://www.opensource.org/licenses/BSD-3-Clause  The BSD 3-Clause License
 */
namespace Kant\Session;

use Kant\Support\Arr;
use Kant\Support\Str;
use SessionHandlerInterface;
use InvalidArgumentException;
use Kant\Http\Request;
use Kant\Session\Session;
use Kant\Session\Storage\MetadataBag;

class Store implements Session
{

    /**
     * The session ID.
     *
     * @var string
     */
    protected $id;

    /**
     * The session name.
     *
     * @var string
     */
    protected $name;

    /**
     * The session attributes.
     *
     * @var array
     */
    protected $attributes = [];

    /**
     * The session bags.
     *
     * @var array
     */
    protected $bags = [];

    /**
     * The meta-data bag instance.
     *
     * @var Kant\Session\Storage\MetadataBag
     */
    protected $metaBag;

    /**
     * Local copies of the session bag data.
     *
     * @var array
     */
    protected $bagData = [];

    /**
     * The session handler implementation.
     *
     * @var \SessionHandlerInterface
     */
    protected $handler;

    /**
     * Session store started status.
     *
     * @var bool
     */
    protected $started = false;

    /**
     * Create a new session instance.
     *
     * @param string $name            
     * @param \SessionHandlerInterface $handler            
     * @param string|null $id            
     * @return void
     */
    public function __construct($name, SessionHandlerInterface $handler, $id = null)
    {
        $this->setId($id);
        $this->name = $name;
        $this->handler = $handler;
        $this->metaBag = new MetadataBag();
    }

    /**
     *
     * {@inheritdoc}
     *
     */
    public function start()
    {
        $this->loadSession();
        
        if (!$this->has('_token')) {
            $this->regenerateToken();
        }
        
        return $this->started = true;
    }

    /**
     * Load the session data from the handler.
     *
     * @return void
     */
    protected function loadSession()
    {
        $this->attributes = array_merge($this->attributes, $this->readFromHandler());
        
        foreach (array_merge($this->bags, [
            $this->metaBag
        ]) as $bag) {
            $this->initializeLocalBag($bag);
            
            $bag->initialize($this->bagData[$bag->getStorageKey()]);
        }
    }

    /**
     * Read the session data from the handler.
     *
     * @return array
     */
    protected function readFromHandler()
    {
        $data = $this->handler->read($this->getId());
        
        if ($data) {
            $data = @unserialize($this->prepareForUnserialize($data));
            
            if ($data !== false && $data !== null && is_array($data)) {
                return $data;
            }
        }
        
        return [];
    }

    /**
     * Prepare the raw string data from the session for unserialization.
     *
     * @param string $data            
     * @return string
     */
    protected function prepareForUnserialize($data)
    {
        return $data;
    }

    /**
     * Initialize a bag in storage if it doesn't exist.
     *
     * @param \Symfony\Component\HttpFoundation\Session\SessionBagInterface $bag            
     * @return void
     */
    protected function initializeLocalBag($bag)
    {
        $this->bagData[$bag->getStorageKey()] = $this->pull($bag->getStorageKey(), []);
    }

    /**
     *
     * {@inheritdoc}
     *
     */
    public function getId()
    {
        return $this->id;
    }

    /**
     *
     * {@inheritdoc}
     *
     */
    public function setId($id)
    {
        if (!$this->isValidId($id)) {
            $id = $this->generateSessionId();
        }
        
        $this->id = $id;
    }

    /**
     * Determine if this is a valid session ID.
     *
     * @param string $id            
     * @return bool
     */
    public function isValidId($id)
    {
        return is_string($id) && preg_match('/^[a-f0-9]{40}$/', $id);
    }

    /**
     * Get a new, random session ID.
     *
     * @return string
     */
    protected function generateSessionId()
    {
        return sha1(uniqid('', true) . Str::quickRandom(25) . microtime(true));
    }

    /**
     *
     * {@inheritdoc}
     *
     */
    public function getName()
    {
        return $this->name;
    }

    /**
     *
     * {@inheritdoc}
     *
     */
    public function setName($name)
    {
        $this->name = $name;
    }

    /**
     *
     * {@inheritdoc}
     *
     */
    public function invalidate($lifetime = null)
    {
        $this->clear();
        
        return $this->migrate(true, $lifetime);
    }

    /**
     *
     * {@inheritdoc}
     *
     */
    public function migrate($destroy = false, $lifetime = null)
    {
        if ($destroy) {
            $this->handler->destroy($this->getId());
        }
        
        $this->setExists(false);
        
        $this->id = $this->generateSessionId();
        
        return true;
    }

    /**
     * Generate a new session identifier.
     *
     * @param bool $destroy            
     * @return bool
     */
    public function regenerate($destroy = false)
    {
        return $this->migrate($destroy);
    }

    /**
     *
     * {@inheritdoc}
     *
     */
    public function save()
    {
        $this->addBagDataToSession();
        
        $this->ageFlashData();
        
        $this->handler->write($this->getId(), $this->prepareForStorage(serialize($this->attributes)));
        
        $this->started = false;
    }

    /**
     * Prepare the serialized session data for storage.
     *
     * @param string $data            
     * @return string
     */
    protected function prepareForStorage($data)
    {
        return $data;
    }

    /**
     * Merge all of the bag data into the session.
     *
     * @return void
     */
    protected function addBagDataToSession()
    {
        foreach (array_merge($this->bags, [
            $this->metaBag
        ]) as $bag) {
            $key = $bag->getStorageKey();
            
            if (isset($this->bagData[$key])) {
                $this->put($key, $this->bagData[$key]);
            }
        }
    }

    /**
     * Age the flash data for the session.
     *
     * @return void
     */
    public function ageFlashData()
    {
        $this->forget($this->get('flash.old', []));
        
        $this->put('flash.old', $this->get('flash.new', []));
        
        $this->put('flash.new', []);
    }

    /**
     *
     * {@inheritdoc}
     *
     */
    public function has($name)
    {
        $keys = is_array($name) ? $name : func_get_args();
        
        foreach ($keys as $value) {
            if (is_null($this->get($value))) {
                return false;
            }
        }
        
        return true;
    }

    /**
     *
     * {@inheritdoc}
     *
     */
    public function get($name, $default = null)
    {
        return Arr::get($this->attributes, $name, $default);
    }

    /**
     * Get the value of a given key and then forget it.
     *
     * @param string $key            
     * @param string $default            
     * @return mixed
     */
    public function pull($key, $default = null)
    {
        return Arr::pull($this->attributes, $key, $default);
    }

    /**
     * Determine if the session contains old input.
     *
     * @param string $key            
     * @return bool
     */
    public function hasOldInput($key = null)
    {
        $old = $this->getOldInput($key);
        
        return is_null($key) ? count($old) > 0 : ! is_null($old);
    }

    /**
     * Get the requested item from the flashed input array.
     *
     * @param string $key            
     * @param mixed $default            
     * @return mixed
     */
    public function getOldInput($key = null, $default = null)
    {
        $input = $this->get('_old_input', []);
        
        // Input that is flashed to the session can be easily retrieved by the
        // developer, making repopulating old forms and the like much more
        // convenient, since the request's previous input is available.
        return Arr::get($input, $key, $default);
    }

    /**
     *
     * {@inheritdoc}
     *
     */
    public function set($name, $value)
    {
        Arr::set($this->attributes, $name, $value);
    }

    /**
     * Put a key / value pair or array of key / value pairs in the session.
     *
     * @param string|array $key            
     * @param mixed $value            
     * @return void
     */
    public function put($key, $value = null)
    {
        if (!is_array($key)) {
            $key = [
                $key => $value
            ];
        }
        
        foreach ($key as $arrayKey => $arrayValue) {
            $this->set($arrayKey, $arrayValue);
        }
    }

    /**
     * Push a value onto a session array.
     *
     * @param string $key            
     * @param mixed $value            
     * @return void
     */
    public function push($key, $value)
    {
        $array = $this->get($key, []);
        
        $array[] = $value;
        
        $this->put($key, $array);
    }

    /**
     * Increment the value of an item in the session.
     *
     * @param string $key            
     * @param int $amount            
     * @return mixed
     */
    public function increment($key, $amount = 1)
    {
        $value = $this->get($key, 0) + $amount;
        
        $this->put($key, $value);
        
        return $value;
    }

    /**
     * Decrement the value of an item in the session.
     *
     * @param string $key            
     * @param int $amount            
     * @return int
     */
    public function decrement($key, $amount = 1)
    {
        return $this->increment($key, $amount * - 1);
    }

    /**
     * Flash a key / value pair to the session.
     *
     * @param string $key            
     * @param mixed $value            
     * @return void
     */
    public function flash($key, $value)
    {
        $this->put($key, $value);
        
        $this->push('flash.new', $key);
        
        $this->removeFromOldFlashData([
            $key
        ]);
    }

    /**
     * Flash a key / value pair to the session
     * for immediate use.
     *
     * @param string $key            
     * @param mixed $value            
     * @return void
     */
    public function now($key, $value)
    {
        $this->put($key, $value);
        
        $this->push('flash.old', $key);
    }

    /**
     * Flash an input array to the session.
     *
     * @param array $value            
     * @return void
     */
    public function flashInput(array $value)
    {
        $this->flash('_old_input', $value);
    }

    /**
     * Reflash all of the session flash data.
     *
     * @return void
     */
    public function reflash()
    {
        $this->mergeNewFlashes($this->get('flash.old', []));
        
        $this->put('flash.old', []);
    }

    /**
     * Reflash a subset of the current flash data.
     *
     * @param array|mixed $keys            
     * @return void
     */
    public function keep($keys = null)
    {
        $keys = is_array($keys) ? $keys : func_get_args();
        
        $this->mergeNewFlashes($keys);
        
        $this->removeFromOldFlashData($keys);
    }

    /**
     * Merge new flash keys into the new flash array.
     *
     * @param array $keys            
     * @return void
     */
    protected function mergeNewFlashes(array $keys)
    {
        $values = array_unique(array_merge($this->get('flash.new', []), $keys));
        
        $this->put('flash.new', $values);
    }

    /**
     * Remove the given keys from the old flash data.
     *
     * @param array $keys            
     * @return void
     */
    protected function removeFromOldFlashData(array $keys)
    {
        $this->put('flash.old', array_diff($this->get('flash.old', []), $keys));
    }

    /**
     *
     * {@inheritdoc}
     *
     */
    public function all()
    {
        return $this->attributes;
    }

    /**
     *
     * {@inheritdoc}
     *
     */
    public function replace(array $attributes)
    {
        $this->put($attributes);
    }

    /**
     *
     * {@inheritdoc}
     *
     */
    public function remove($name)
    {
        return Arr::pull($this->attributes, $name);
    }

    /**
     * Remove one or many items from the session.
     *
     * @param string|array $keys            
     * @return void
     */
    public function forget($keys)
    {
        Arr::forget($this->attributes, $keys);
    }

    /**
     *
     * {@inheritdoc}
     *
     */
    public function clear()
    {
        $this->attributes = [];
        
        foreach ($this->bags as $bag) {
            $bag->clear();
        }
    }

    /**
     * Remove all of the items from the session.
     *
     * @return void
     */
    public function flush()
    {
        $this->clear();
    }

    /**
     *
     * {@inheritdoc}
     *
     */
    public function isStarted()
    {
        return $this->started;
    }

    /**
     *
     * {@inheritdoc}
     *
     */
    public function registerBag(SessionBagInterface $bag)
    {
        $this->bags[$bag->getStorageKey()] = $bag;
    }

    /**
     *
     * {@inheritdoc}
     *
     */
    public function getBag($name)
    {
        return Arr::get($this->bags, $name, function () {
            throw new InvalidArgumentException('Bag not registered.');
        });
    }

    /**
     *
     * {@inheritdoc}
     *
     */
    public function getMetadataBag()
    {
        return $this->metaBag;
    }

    /**
     * Get the raw bag data array for a given bag.
     *
     * @param string $name            
     * @return array
     */
    public function getBagData($name)
    {
        return Arr::get($this->bagData, $name, []);
    }

    /**
     * Get the CSRF token value.
     *
     * @return string
     */
    public function token()
    {
        return $this->get('_token');
    }

    /**
     * Get the CSRF token value.
     *
     * @return string
     */
    public function getToken()
    {
        return $this->token();
    }

    /**
     * Regenerate the CSRF token value.
     *
     * @return void
     */
    public function regenerateToken()
    {
        $this->put('_token', Str::quickRandom(40));
    }

    /**
     * Get the previous URL from the session.
     *
     * @return string|null
     */
    public function previousUrl()
    {
        return $this->get('_previous.url');
    }

    /**
     * Set the "previous" URL in the session.
     *
     * @param string $url            
     * @return void
     */
    public function setPreviousUrl($url)
    {
        return $this->put('_previous.url', $url);
    }

    /**
     * Set the existence of the session on the handler if applicable.
     *
     * @param bool $value            
     * @return void
     */
    public function setExists($value)
    {
        if ($this->handler instanceof ExistenceAwareInterface) {
            $this->handler->setExists($value);
        }
    }

    /**
     * Get the underlying session handler implementation.
     *
     * @return \SessionHandlerInterface
     */
    public function getHandler()
    {
        return $this->handler;
    }

    /**
     * Determine if the session handler needs a request.
     *
     * @return bool
     */
    public function handlerNeedsRequest()
    {
        return $this->handler instanceof CookieSessionHandler;
    }

    /**
     * Set the request on the handler instance.
     *
     * @param \Symfony\Component\HttpFoundation\Request $request            
     * @return void
     */
    public function setRequestOnHandler(Request $request)
    {
        if ($this->handlerNeedsRequest()) {
            $this->handler->setRequest($request);
        }
    }
}
