<?php

namespace Acms\Services\Common;

use Acms\Services\Facades\Storage;

class Download
{
    /**
     * ファイルをダウンロードまたはインライン配信する。
     *
     * $extension を指定した場合は Content-Disposition: inline で返し、
     * HTTP Range リクエストに対応した部分配信を行う。
     *
     * @param string $path
     * @param string $fileName
     * @param string|false $extension 指定時は inline 配信
     * @param bool $remove 配信後にファイルを削除するか
     * @return never
     */
    public function handle(string $path, string $fileName, $extension = false, bool $remove = false)
    {
        if (!is_file($path) || !is_readable($path)) {
            $this->sendNotFound();
        }
        $fileSize = filesize($path);
        if ($fileSize === false) {
            $this->sendNotFound();
        }

        if ($extension !== false) {
            $this->sendInlineFile($path, $fileName, $extension, $fileSize);
        } else {
            $this->sendAttachmentFile($path, $fileName, $fileSize);
        }
        if ($remove) {
            Storage::remove($path);
        }
        exit(0);
    }

    /**
     * 404 Not Found を送信して終了する。
     *
     * @return never
     */
    private function sendNotFound()
    {
        header('HTTP/1.1 404 Not Found');
        exit(1);
    }

    /**
     * インライン配信を行う。
     *
     * @param string $path
     * @param string $fileName
     * @param string $extension
     * @param int $fileSize
     */
    private function sendInlineFile(string $path, string $fileName, string $extension, int $fileSize): void
    {
        $mime = $this->resolveInlineMime($extension);
        $fp = fopen($path, 'rb');
        if (!is_resource($fp)) {
            $this->sendNotFound();
        }

        header('Content-Disposition: ' . $this->buildContentDisposition($fileName, 'inline'));
        header('Content-Type: ' . ($mime !== false && $mime !== '' ? $mime : 'application/octet-stream'));
        header('Accept-Ranges: bytes');

        $range = $this->parseSingleRange($_SERVER['HTTP_RANGE'] ?? '', $fileSize);
        if ($range === false) {
            fclose($fp);
            $this->respondRangeNotSatisfiable($fileSize);
        }

        $start = 0;
        $end = $fileSize - 1;
        if (is_array($range)) {
            [$start, $end] = $range;
        }

        $contentLength = $end - $start + 1;
        if (is_array($range)) {
            header('HTTP/1.1 206 Partial Content');
            header("Content-Range: bytes {$start}-{$end}/{$fileSize}");
        } else {
            header('HTTP/1.1 200 OK');
        }
        header('Content-Length: ' . $contentLength);

        $this->clearOutputBuffers();
        fseek($fp, $start);
        $this->streamFile($fp, $contentLength);
        fclose($fp);
    }

    /**
     * 添付ファイルとして配信する。
     *
     * @param string $path
     * @param string $fileName
     * @param int $fileSize
     */
    private function sendAttachmentFile(string $path, string $fileName, int $fileSize): void
    {
        header('Content-Disposition: ' . $this->buildContentDisposition($fileName, 'attachment'));

        $ua = defined('UA') ? UA : '';
        header(strpos($ua, 'MSIE') !== false ? 'Content-Type: text/download' : 'Content-Type: application/octet-stream');
        header('Content-Length: ' . $fileSize);

        $this->clearOutputBuffers();
        readfile($path);
    }

    /**
     * Content-Disposition ヘッダの値を組み立てる。
     *
     * @param string $fileName
     * @param string $disposition inline または attachment
     * @return string
     */
    private function buildContentDisposition(string $fileName, string $disposition): string
    {
        $safeFileName = str_replace(['\\', '"'], ['\\\\', '\"'], $fileName);
        $encoded = rawurlencode($fileName);
        return "{$disposition}; filename=\"{$safeFileName}\"; filename*=UTF-8''{$encoded}";
    }

    /**
     * 出力バッファをすべて破棄する。
     */
    private function clearOutputBuffers(): void
    {
        while (ob_get_level() > 0) {
            ob_end_clean();
        }
    }

    /**
     * inline 配信用 MIME type を取得する。
     *
     * @param string $extension
     * @return string|false
     */
    private function resolveInlineMime(string $extension)
    {
        $inlineExtensions = configArray('media_inline_download_extension');

        foreach ($inlineExtensions as $i => $value) {
            if ($extension === $value) {
                return config('media_inline_download_mime', null, $i);
            }
        }

        return false;
    }

    /**
     * 単一の HTTP Range ヘッダーを解析する。
     *
     * 戻り値:
     * - null: Range 指定なし
     * - array{int,int}: [start, end]
     * - false: 不正な Range
     *
     * @param string $rangeHeader
     * @param int $fileSize
     * @return array<int,int>|null|false
     */
    private function parseSingleRange(string $rangeHeader, int $fileSize)
    {
        $rangeHeader = trim($rangeHeader);
        if ($rangeHeader === '') {
            return null;
        }

        if (!preg_match('/^bytes=(\d*)-(\d*)$/', $rangeHeader, $matches)) {
            return false;
        }

        $startStr = $matches[1];
        $endStr = $matches[2];

        // bytes=-
        if ($startStr === '' && $endStr === '') {
            return false;
        }

        // bytes=-500（末尾から N バイト）
        if ($startStr === '') {
            $suffixLength = (int) $endStr;
            if ($suffixLength <= 0) {
                return false;
            }

            if ($suffixLength > $fileSize) {
                $suffixLength = $fileSize;
            }

            return [$fileSize - $suffixLength, $fileSize - 1];
        }

        // bytes=500-
        if ($endStr === '') {
            $start = (int) $startStr;
            if ($start >= $fileSize) {
                return false;
            }

            return [$start, $fileSize - 1];
        }

        // bytes=500-999
        $start = (int) $startStr;
        $end = (int) $endStr;

        if ($start > $end || $start >= $fileSize) {
            return false;
        }

        if ($end >= $fileSize) {
            $end = $fileSize - 1;
        }

        return [$start, $end];
    }

    /**
     * 416 Range Not Satisfiable を返す。
     *
     * @param int $fileSize
     * @return never
     */
    private function respondRangeNotSatisfiable(int $fileSize)
    {
        header('HTTP/1.1 416 Range Not Satisfiable');
        header("Content-Range: bytes */{$fileSize}");
        exit(1);
    }

    /**
     * ファイルポインタから指定バイト数を出力する。
     *
     * @param resource $fp
     * @param int $length
     */
    private function streamFile($fp, int $length): void
    {
        $chunkSize = 8192;
        $remaining = $length;

        while ($remaining > 0 && !feof($fp)) {
            $readSize = min($chunkSize, $remaining);
            $buffer = fread($fp, $readSize);

            if (!$buffer) {
                break;
            }
            echo $buffer;
            flush();
            $remaining -= strlen($buffer);

            if (connection_status() !== CONNECTION_NORMAL) {
                break;
            }
        }
    }
}
