<?php

/**
 * Created by PhpStorm.
 * User: ondra
 * Date: 18.12.17
 * Time: 8:25.
 */

namespace KupShop\KupShopBundle\Util;

use KupShop\KupShopBundle\Util\System\PathFinder;
use Symfony\Component\HttpFoundation\StreamedResponse;

class CachingStreamedResponse extends StreamedResponse
{
    protected $ttl = 86400;

    protected $cacheName;

    protected $skipCache = false;

    protected $isExpensive = false;

    protected $isLimited = false;

    private $fileHandle;

    protected $chunkSize = 4096;

    /** @var int */
    protected $action;

    // load cached file
    public const ACTION_USE_CACHE = 1;
    // generate content and create cache file
    public const ACTION_REFRESH = 2;
    // generate content without creating cache file
    public const ACTION_SKIP_CACHE = 3;

    /** @var int */
    protected $timestamp;

    public function setTtl(int $seconds): static
    {
        $this->ttl = $seconds;

        return $this;
    }

    public function setCacheName($cacheName)
    {
        $this->cacheName = $cacheName;

        return $this;
    }

    public function setSkipCache($skipCache)
    {
        $this->skipCache = $skipCache;

        return $this;
    }

    public function setExpensive(bool $isExpensive = true)
    {
        $this->isExpensive = $isExpensive;
    }

    public function setIsLimited(bool $isLimited): void
    {
        $this->isLimited = $isLimited;
    }

    /**
     * @return CachingStreamedResponse
     */
    public function setChunkSize(int $chunkSize)
    {
        $this->chunkSize = $chunkSize;

        return $this;
    }

    public function initialize()
    {
        if (!$this->cacheName) {
            throw new \LogicException('CachingStreamedResponse::$cacheName must be set!');
        }

        $this->action = static::ACTION_REFRESH;
        if ($this->skipCache) {
            if ($this->isExpensive && !$this->isLimited) {
                $this->action = static::ACTION_REFRESH;
            } else {
                $this->action = static::ACTION_SKIP_CACHE;
            }
        } elseif ($this->isExpensive && file_exists($this->getCacheName())) {
            $this->action = static::ACTION_USE_CACHE;
        } else {
            if (file_exists($this->getCacheName()) && (time() - filemtime($this->getCacheName())) < $this->ttl) {
                $this->action = static::ACTION_USE_CACHE;
            } elseif (file_exists($this->getLockFileName()) && !flock(fopen($this->getLockFileName(), 'r'), LOCK_SH | LOCK_NB)) {
                // Fixes bug with stale flock on lock file
                // If lock is stale, delete it and refresh
                if ((time() - filemtime($this->getLockFileName())) < 3600 * 24) {
                    $this->action = static::ACTION_USE_CACHE; // if lock is not stale, set ACTION_USE_CACHE no matter the existence of the cache file
                } else {
                    unlink($this->getLockFileName());
                }
            }
        }

        if ($this->action === static::ACTION_USE_CACHE) {
            if (file_exists($this->getCacheName())) {
                // use cached file creation timestamp for Last-Modified header
                $timestamp = filemtime($this->getCacheName());
                $this->headers->set('Content-Length', filesize($this->getCacheName()));
            } else {
                // trying to use cache without cache file => probably subsequent request because it couldn't flock lock file
                $this->setStatusCode(429);
                $this->sendHeaders();
                exit;
            }
        } else {
            $this->timestamp = $timestamp = time();
        }
        $this->headers->set('Last-Modified', gmdate('D, d M Y H:i:s ', $timestamp).'GMT');
    }

    /**
     * @throws \Throwable
     */
    public function sendContent(): static
    {
        if (!$this->action) {
            throw new \LogicException('Call CachingStreamedResponse::initialize() before sendContent()!');
        }

        if ($this->streamed) {
            return $this;
        }

        if ($this->action === static::ACTION_USE_CACHE) {
            $this->readCacheFile();

            return $this;
        } elseif ($this->action === static::ACTION_SKIP_CACHE) {
            return parent::sendContent();
        }

        $flock_file = fopen($this->getLockFileName(), 'wb');
        flock($flock_file, LOCK_EX);

        ob_start([$this, 'write'], $this->chunkSize);

        try {
            parent::sendContent();
        } catch (\Throwable $exception) {
            ob_end_clean();
            $raven = getRaven();
            $raven->captureException($exception);

            if (!isDevelopment() && file_exists($this->getCacheName())) {
                $this->readCacheFile();

                return $this;
            } else {
                throw $exception;
            }
        }

        ob_end_flush();

        rename($this->getNewCacheName(), $this->getCacheName());

        // adjust modification timestamp
        // so Last-Modified has the same value as when the cached file is used
        touch($this->getCacheName(), $this->timestamp);

        flock($flock_file, LOCK_UN);
        fclose($flock_file);
        @unlink($this->getLockFileName());

        return $this;
    }

    public function write($buffer, $phase)
    {
        if ($phase & PHP_OUTPUT_HANDLER_START) {
            $this->fileHandle = fopen($this->getNewCacheName(), 'wb');
        }

        if ($this->fileHandle) {
            fwrite($this->fileHandle, $buffer);
        }

        if ($phase & PHP_OUTPUT_HANDLER_END) {
            fflush($this->fileHandle);
            fclose($this->fileHandle);
            $this->fileHandle = null;
        }

        return $buffer;
    }

    public function getCacheName()
    {
        $pathFinder = PathFinder::getService();

        return $pathFinder->getTmpDir().$this->cacheName;
    }

    private function getNewCacheName()
    {
        return $this->getCacheName().'_new';
    }

    private function getLockFileName()
    {
        return $this->getCacheName().'.lock';
    }

    private function readCacheFile()
    {
        $this->streamed = true;

        if (!file_exists($this->getCacheName())) {
            $this->streamed = false;
            $this->action = CachingStreamedResponse::ACTION_SKIP_CACHE;

            return $this->sendContent(); // fallback to ACTION_SKIP_CACHE in case final cache file is not present
        }

        return readfile($this->getCacheName());
    }
}
