<?php

declare(strict_types=1);

/**
 * This file is part of CodeIgniter 4 framework.
 *
 * (c) CodeIgniter Foundation <admin@codeigniter.com>
 *
 * For the full copyright and license information, please view
 * the LICENSE file that was distributed with this source code.
 */

namespace CodeIgniter\HTTP;

use CodeIgniter\Exceptions\DownloadException;
use CodeIgniter\Files\File;
use Config\App;
use Config\Mimes;

/**
 * HTTP response when a download is requested.
 *
 * @see \CodeIgniter\HTTP\DownloadResponseTest
 */
class DownloadResponse extends Response
{
    /**
     * Download file name
     */
    private string $filename;

    /**
     * Download for file
     */
    private ?File $file = null;

    /**
     * mime set flag
     */
    private readonly bool $setMime;

    /**
     * Download for binary
     */
    private ?string $binary = null;

    /**
     * Download charset
     */
    private string $charset = 'UTF-8';

    /**
     * Download reason
     *
     * @var string
     */
    protected $reason = 'OK';

    /**
     * The current status code for this response.
     *
     * @var int
     */
    protected $statusCode = 200;

    /**
     * Constructor.
     */
    public function __construct(string $filename, bool $setMime)
    {
        parent::__construct(config(App::class));

        $this->filename = $filename;
        $this->setMime  = $setMime;

        // Make sure the content type is either specified or detected
        $this->removeHeader('Content-Type');
    }

    /**
     * set download for binary string.
     *
     * @return void
     */
    public function setBinary(string $binary)
    {
        if ($this->file instanceof File) {
            throw DownloadException::forCannotSetBinary();
        }

        $this->binary = $binary;
    }

    /**
     * set download for file.
     *
     * @return void
     */
    public function setFilePath(string $filepath)
    {
        if ($this->binary !== null) {
            throw DownloadException::forCannotSetFilePath($filepath);
        }

        $this->file = new File($filepath, true);
    }

    /**
     * set name for the download.
     *
     * @return $this
     */
    public function setFileName(string $filename)
    {
        $this->filename = $filename;

        return $this;
    }

    /**
     * get content length.
     */
    public function getContentLength(): int
    {
        if (is_string($this->binary)) {
            return strlen($this->binary);
        }

        if ($this->file instanceof File) {
            return $this->file->getSize();
        }

        return 0;
    }

    /**
     * Set content type by guessing mime type from file extension
     */
    private function setContentTypeByMimeType(): void
    {
        $mime    = null;
        $charset = '';

        if ($this->setMime && ($lastDotPosition = strrpos($this->filename, '.')) !== false) {
            $mime    = Mimes::guessTypeFromExtension(substr($this->filename, $lastDotPosition + 1));
            $charset = $this->charset;
        }

        if (! is_string($mime)) {
            // Set the default MIME type to send
            $mime    = 'application/octet-stream';
            $charset = '';
        }

        $this->setContentType($mime, $charset);
    }

    /**
     * get download filename.
     */
    private function getDownloadFileName(): string
    {
        $filename  = $this->filename;
        $x         = explode('.', $this->filename);
        $extension = end($x);

        /* It was reported that browsers on Android 2.1 (and possibly older as well)
         * need to have the filename extension upper-cased in order to be able to
         * download it.
         *
         * Reference: http://digiblog.de/2011/04/19/android-and-the-download-file-headers/
         */
        $userAgent = service('superglobals')->server('HTTP_USER_AGENT');
        if (count($x) !== 1 && $userAgent !== null
                && preg_match('/Android\s(1|2\.[01])/', $userAgent)) {
            $x[count($x) - 1] = strtoupper($extension);
            $filename         = implode('.', $x);
        }

        return $filename;
    }

    /**
     * Get Content-Disposition Header string.
     */
    private function getContentDisposition(bool $inline = false): string
    {
        $downloadFilename = $utf8Filename = $this->getDownloadFileName();
        $disposition      = $inline ? 'inline' : 'attachment';

        if (strtoupper($this->charset) !== 'UTF-8') {
            $utf8Filename = mb_convert_encoding($downloadFilename, 'UTF-8', $this->charset);
        }

        $result = sprintf('%s; filename="%s"', $disposition, addslashes($downloadFilename));

        if ($utf8Filename !== '') {
            $result .= sprintf('; filename*=UTF-8\'\'%s', rawurlencode($utf8Filename));
        }

        return $result;
    }

    /**
     * Disallows status changing.
     *
     * @throws DownloadException
     */
    public function setStatusCode(int $code, string $reason = '')
    {
        throw DownloadException::forCannotSetStatusCode($code, $reason);
    }

    /**
     * Sets the Content Type header for this response with the mime type
     * and, optionally, the charset.
     *
     * @return ResponseInterface
     */
    public function setContentType(string $mime, string $charset = 'UTF-8')
    {
        parent::setContentType($mime, $charset);

        if ($charset !== '') {
            $this->charset = $charset;
        }

        return $this;
    }

    /**
     * Sets the appropriate headers to ensure this response
     * is not cached by the browsers.
     */
    public function noCache(): self
    {
        $this->removeHeader('Cache-Control');
        $this->setHeader('Cache-Control', ['private', 'no-transform', 'no-store', 'must-revalidate']);

        return $this;
    }

    /**
     * {@inheritDoc}
     *
     * @return $this
     *
     * @todo Do downloads need CSP or Cookies? Compare with ResponseTrait::send()
     */
    public function send()
    {
        // Turn off output buffering completely, even if php.ini output_buffering is not off
        if (ENVIRONMENT !== 'testing') {
            while (ob_get_level() > 0) {
                ob_end_clean();
            }
        }

        $this->buildHeaders();
        $this->sendHeaders();
        $this->sendBody();

        return $this;
    }

    /**
     * set header for file download.
     *
     * @return void
     */
    public function buildHeaders()
    {
        if (! $this->hasHeader('Content-Type')) {
            $this->setContentTypeByMimeType();
        }

        if (! $this->hasHeader('Content-Disposition')) {
            $this->setHeader('Content-Disposition', $this->getContentDisposition());
        }

        $this->setHeader('Content-Transfer-Encoding', 'binary');
        $this->setHeader('Content-Length', (string) $this->getContentLength());
    }

    /**
     * output download file text.
     *
     * @return DownloadResponse
     *
     * @throws DownloadException
     */
    public function sendBody()
    {
        if ($this->binary !== null) {
            return $this->sendBodyByBinary();
        }

        if ($this->file instanceof File) {
            return $this->sendBodyByFilePath();
        }

        throw DownloadException::forNotFoundDownloadSource();
    }

    /**
     * output download text by file.
     *
     * @return DownloadResponse
     */
    private function sendBodyByFilePath()
    {
        $splFileObject = $this->file->openFile('rb');

        // Flush 1MB chunks of data
        while (! $splFileObject->eof() && ($data = $splFileObject->fread(1_048_576)) !== false) {
            echo $data;
            unset($data);
        }

        return $this;
    }

    /**
     * output download text by binary
     *
     * @return DownloadResponse
     */
    private function sendBodyByBinary()
    {
        echo $this->binary;

        return $this;
    }

    /**
     * Sets the response header to display the file in the browser.
     *
     * @return DownloadResponse
     */
    public function inline()
    {
        $this->setHeader('Content-Disposition', $this->getContentDisposition(true));

        return $this;
    }
}
