<?php

namespace Laminas\Cache\Pattern;

use GlobIterator;
use Laminas\Cache\Exception;
use Laminas\Stdlib\ErrorHandler;

use function array_unshift;
use function basename;
use function chmod;
use function decoct;
use function dirname;
use function file_exists;
use function file_get_contents;
use function file_put_contents;
use function mkdir;
use function ob_implicit_flush;
use function ob_start;
use function rtrim;
use function str_replace;
use function substr;
use function umask;
use function unlink;

use const DIRECTORY_SEPARATOR;
use const LOCK_EX;

class CaptureCache extends AbstractPattern
{
    /**
     * Start the cache
     *
     * @param  string $pageId  Page identifier
     * @return void
     */
    public function start($pageId = null)
    {
        if ($pageId === null) {
            $pageId = $this->detectPageId();
        }

        ob_start(function ($content) use ($pageId) {
            $this->set($content, $pageId);

            // http://php.net/manual/function.ob-start.php
            // -> If output_callback  returns FALSE original input is sent to the browser.
            return false;
        });

        ob_implicit_flush(0);
    }

    /**
     * Write content to page identity
     *
     * @param string      $content
     * @param null|string $pageId
     * @throws Exception\LogicException
     */
    public function set($content, $pageId = null)
    {
        $publicDir = $this->getOptions()->getPublicDir();
        if ($publicDir === null) {
            throw new Exception\LogicException("Option 'public_dir' no set");
        }

        if ($pageId === null) {
            $pageId = $this->detectPageId();
        }

        $path = $this->pageId2Path($pageId);
        $file = $path . DIRECTORY_SEPARATOR . $this->pageId2Filename($pageId);

        $this->createDirectoryStructure($publicDir . DIRECTORY_SEPARATOR . $path);
        $this->putFileContent($publicDir . DIRECTORY_SEPARATOR . $file, $content);
    }

    /**
     * Get from cache
     *
     * @param  null|string $pageId
     * @return string|null
     * @throws Exception\LogicException
     * @throws Exception\RuntimeException
     */
    public function get($pageId = null)
    {
        $publicDir = $this->getOptions()->getPublicDir();
        if ($publicDir === null) {
            throw new Exception\LogicException("Option 'public_dir' no set");
        }

        if ($pageId === null) {
            $pageId = $this->detectPageId();
        }

        $file = $publicDir
            . DIRECTORY_SEPARATOR . $this->pageId2Path($pageId)
            . DIRECTORY_SEPARATOR . $this->pageId2Filename($pageId);

        if (file_exists($file)) {
            ErrorHandler::start();
            $content = file_get_contents($file);
            $error   = ErrorHandler::stop();
            if ($content === false) {
                throw new Exception\RuntimeException("Failed to read cached pageId '{$pageId}'", 0, $error);
            }
            return $content;
        }
    }

    /**
     * Checks if a cache with given id exists
     *
     * @param  null|string $pageId
     * @throws Exception\LogicException
     * @return bool
     */
    public function has($pageId = null)
    {
        $publicDir = $this->getOptions()->getPublicDir();
        if ($publicDir === null) {
            throw new Exception\LogicException("Option 'public_dir' no set");
        }

        if ($pageId === null) {
            $pageId = $this->detectPageId();
        }

        $file = $publicDir
            . DIRECTORY_SEPARATOR . $this->pageId2Path($pageId)
            . DIRECTORY_SEPARATOR . $this->pageId2Filename($pageId);

        return file_exists($file);
    }

    /**
     * Remove from cache
     *
     * @param  null|string $pageId
     * @throws Exception\LogicException
     * @throws Exception\RuntimeException
     * @return bool
     */
    public function remove($pageId = null)
    {
        $publicDir = $this->getOptions()->getPublicDir();
        if ($publicDir === null) {
            throw new Exception\LogicException("Option 'public_dir' no set");
        }

        if ($pageId === null) {
            $pageId = $this->detectPageId();
        }

        $file = $publicDir
            . DIRECTORY_SEPARATOR . $this->pageId2Path($pageId)
            . DIRECTORY_SEPARATOR . $this->pageId2Filename($pageId);

        if (file_exists($file)) {
            ErrorHandler::start();
            $res = unlink($file);
            $err = ErrorHandler::stop();
            if (! $res) {
                throw new Exception\RuntimeException("Failed to remove cached pageId '{$pageId}'", 0, $err);
            }
            return true;
        }

        return false;
    }

    /**
     * Clear cached pages matching glob pattern
     *
     * @param string $pattern
     * @throws Exception\LogicException
     */
    public function clearByGlob($pattern = '**')
    {
        $publicDir = $this->getOptions()->getPublicDir();
        if ($publicDir === null) {
            throw new Exception\LogicException("Option 'public_dir' no set");
        }

        $it = new GlobIterator(
            $publicDir . '/' . $pattern,
            GlobIterator::CURRENT_AS_SELF | GlobIterator::SKIP_DOTS | GlobIterator::UNIX_PATHS
        );
        foreach ($it as $pathname => $entry) {
            if ($entry->isFile()) {
                unlink($pathname);
            }
        }
    }

    /**
     * Determine the page to save from the request
     *
     * @throws Exception\RuntimeException
     * @return string
     */
    protected function detectPageId()
    {
        if (! isset($_SERVER['REQUEST_URI'])) {
            throw new Exception\RuntimeException("Can't auto-detect current page identity");
        }

        return $_SERVER['REQUEST_URI'];
    }

    /**
     * Get filename for page id
     *
     * @param string $pageId
     * @return string
     */
    protected function pageId2Filename($pageId)
    {
        if (substr($pageId, -1) === '/') {
            return $this->getOptions()->getIndexFilename();
        }

        return basename($pageId);
    }

    /**
     * Get path for page id
     *
     * @param string $pageId
     * @return string
     */
    protected function pageId2Path($pageId)
    {
        if (substr($pageId, -1) === '/') {
            $path = rtrim($pageId, '/');
        } else {
            $path = dirname($pageId);
        }

        // convert requested "/" to the valid local directory separator
        if (DIRECTORY_SEPARATOR !== '/') {
            $path = str_replace('/', DIRECTORY_SEPARATOR, $path);
        }

        return $path;
    }

    /**
     * Write content to a file
     *
     * @param  string  $file File complete path
     * @param  string  $data Data to write
     * @return void
     * @throws Exception\RuntimeException
     */
    protected function putFileContent($file, $data)
    {
        $options = $this->getOptions();
        $locking = $options->getFileLocking();
        $perm    = $options->getFilePermission();
        $umask   = $options->getUmask();
        if ($umask !== false && $perm !== false) {
            $perm &= ~$umask;
        }

        ErrorHandler::start();

        $umask = $umask !== false ? umask($umask) : false;
        $rs    = file_put_contents($file, $data, $locking ? LOCK_EX : 0);
        if ($umask) {
            umask($umask);
        }

        if ($rs === false) {
            $err = ErrorHandler::stop();
            throw new Exception\RuntimeException("Error writing file '{$file}'", 0, $err);
        }

        if ($perm !== false && ! chmod($file, $perm)) {
            $oct = decoct($perm);
            $err = ErrorHandler::stop();
            throw new Exception\RuntimeException("chmod('{$file}', 0{$oct}) failed", 0, $err);
        }

        ErrorHandler::stop();
    }

    /**
     * Creates directory if not already done.
     *
     * @param string $pathname
     * @return void
     * @throws Exception\RuntimeException
     */
    protected function createDirectoryStructure($pathname)
    {
        // Directory structure already exists
        if (file_exists($pathname)) {
            return;
        }

        $options = $this->getOptions();
        $perm    = $options->getDirPermission();
        $umask   = $options->getUmask();
        if ($umask !== false && $perm !== false) {
            $perm &= ~$umask;
        }

        ErrorHandler::start();

        if ($perm === false) {
            // built-in mkdir function is enough

            $umask = $umask !== false ? umask($umask) : false;
            $res   = mkdir($pathname, $perm !== false ? $perm : 0775, true);

            if ($umask !== false) {
                umask($umask);
            }

            if (! $res) {
                $oct = $perm === false ? '775' : decoct($perm);
                $err = ErrorHandler::stop();
                throw new Exception\RuntimeException("mkdir('{$pathname}', 0{$oct}, true) failed", 0, $err);
            }

            if ($perm !== false && ! chmod($pathname, $perm)) {
                $oct = decoct($perm);
                $err = ErrorHandler::stop();
                throw new Exception\RuntimeException("chmod('{$pathname}', 0{$oct}) failed", 0, $err);
            }
        } else {
            // built-in mkdir function sets permission together with current umask
            // which doesn't work well on multo threaded webservers
            // -> create directories one by one and set permissions

            // find existing path and missing path parts
            $parts = [];
            $path  = $pathname;
            while (! file_exists($path)) {
                array_unshift($parts, basename($path));
                $nextPath = dirname($path);
                if ($nextPath === $path) {
                    break;
                }
                $path = $nextPath;
            }

            // make all missing path parts
            foreach ($parts as $part) {
                $path .= DIRECTORY_SEPARATOR . $part;

                // create a single directory, set and reset umask immediately
                $umask = $umask !== false ? umask($umask) : false;
                $res   = mkdir($path, $perm === false ? 0775 : $perm, false);
                if ($umask !== false) {
                    umask($umask);
                }

                if (! $res) {
                    $oct = $perm === false ? '775' : decoct($perm);
                    ErrorHandler::stop();
                    throw new Exception\RuntimeException(
                        "mkdir('{$path}', 0{$oct}, false) failed"
                    );
                }

                if ($perm !== false && ! chmod($path, $perm)) {
                    $oct = decoct($perm);
                    ErrorHandler::stop();
                    throw new Exception\RuntimeException(
                        "chmod('{$path}', 0{$oct}) failed"
                    );
                }
            }
        }

        ErrorHandler::stop();
    }

    /**
     * Returns the generated file name.
     *
     * @param null|string $pageId
     * @return string
     */
    public function getFilename($pageId = null)
    {
        if ($pageId === null) {
            $pageId = $this->detectPageId();
        }

        $publicDir = $this->getOptions()->getPublicDir();
        $path      = $this->pageId2Path($pageId);
        $file      = $path . DIRECTORY_SEPARATOR . $this->pageId2Filename($pageId);

        return $publicDir . $file;
    }
}
