<?php

declare(strict_types=1);

namespace Acms\Services\PageGeneration;

use Acms\Services\Process\ProcessPool;
use Acms\Services\Process\ProcessTask;
use Acms\Services\Process\ProcessResult;
use Acms\Services\Process\ProcOpenRunner;
use Acms\Services\PageGeneration\Entities\Page;
use Acms\Services\PageGeneration\Entities\EntryPage;
use Acms\Services\PageGeneration\Handler\PageGenerationResponseHandler;
use Acms\Services\PageGeneration\Handler\PageGenerationErrorHandler;
use Acms\Services\PageGeneration\Handler\PageGenerationTaskStartHandler;
use Acms\Services\PageGeneration\Contracts\PageGenerationListenerInterface;

class PageGenerationService implements PageGenerationListenerInterface
{
    /**
     * @var Page[]
     */
    private array $pages = [];

    /**
     * @var ProcessPool
     */
    private ProcessPool $pool;

    /**
     * ページを生成
     *
     * @param int $maxParallel
     * @param PageGenerationListenerInterface|null $listener
     * @param bool $withData
     * @return list<PageGenerationResult>
     */
    public function run(
        int $maxParallel,
        ?PageGenerationListenerInterface $listener = null,
        bool $withData = false
    ): array {
        $listener ??= $this;
        $runner = new ProcOpenRunner();
        $responseHandler = new PageGenerationResponseHandler($listener, $withData);
        $errorHandler = new PageGenerationErrorHandler($listener);
        $taskStartHandler = new PageGenerationTaskStartHandler($listener);

        $consoleScriptPath = $this->getConsoleScriptPath();
        $phpBinPath = $this->getPhpBinPath();

        $this->pool = new ProcessPool(
            maxParallel: $maxParallel,
            runner: $runner,
            responseHandler: $responseHandler,
            errorHandler: $errorHandler,
            taskStartHandler: $taskStartHandler
        );

        foreach ($this->pages as $page) {
            $command = [$phpBinPath, ...$this->buildSessionIniOptions($page), $consoleScriptPath, SCRIPT_DIR, $page->getUrl()];
            if ($page->getUserAgent() !== null) {
                $command[] = $page->getUserAgent();
            }
            $this->pool->addTask(new ProcessTask(
                command: $command,
                timeoutSeconds: 300.0,
                env: $this->buildEnv($page),
                context: ['page' => $page],
            ));
        }

        /** @var list<PageGenerationResult> $results */
        $results = $this->pool->run();
        return $results;
    }

    /**
     * ページを追加
     *
     * @param string $url
     * @param string $destinationPathname
     * @param string|null $userAgent
     * @param bool $withSession
     * @return void
     */
    public function addPage(string $url, string $destinationPathname, ?string $userAgent = null, bool $withSession = false): void
    {
        $this->pages[] = new Page($url, $destinationPathname, $userAgent, $withSession);
    }

    /**
     * エントリーページを追加
     *
     * @param string $url
     * @param string $destinationPathname
     * @param integer $entryId
     * @param string|null $userAgent
     * @return void
     */
    public function addEntryPage(string $url, string $destinationPathname, int $entryId, ?string $userAgent = null, bool $withSession = false): void
    {
        $this->pages[] = new EntryPage($url, $destinationPathname, $entryId, $userAgent, $withSession);
    }

    /**
     * 停止を要求する
     *
     * @return void
     */
    public function requestStop(): void
    {
        $this->pool->requestStop();
    }

    /**
     * 成功時の処理
     *
     * @param Page $page
     * @param string $stdout
     * @param int $statusCode
     * @param ProcessResult $result
     * @return void
     */
    public function onPageGenerationSuccess(Page $page, string $stdout, int $statusCode, ProcessResult $result): void
    {
        // デフォルトでは何もしない
    }

    /**
     * エラー時の処理
     *
     * @param Page $page
     * @param int $statusCode
     * @param ProcessResult $result
     * @return void
     */
    public function onPageGenerationError(Page $page, int $statusCode, ProcessResult $result): void
    {
        // デフォルトでは何もしない
    }

    /**
     * 開始時の処理
     *
     * @param Page $page
     * @return bool
     */
    public function onPageGenerationStart(Page $page): bool
    {
        return true;
    }

    /**
     * 子プロセスに渡す環境変数を組み立てる
     *
     * proc_open に環境変数を渡すと親プロセスの環境変数は引き継がれないため、現在の環境変数を
     * ベースにする。子プロセスには HTTP リクエストが無く $_SERVER['SCRIPT_NAME'] から
     * 設置ディレクトリを復元できないため、解決済みの DIR_OFFSET を引き渡す。
     *
     * @param Page $page
     * @return array<string, string>
     */
    private function buildEnv(Page $page): array
    {
        $env = getenv();
        $env['ACMS_DIR_OFFSET'] = DIR_OFFSET;

        if ($page->isWithSession() && SESSION_NAME && ACMS_SID) {
            $env['SESSION_NAME'] = SESSION_NAME;
            $env['SESSION_ID'] = (string)ACMS_SID;
        }
        return $env;
    }

    /**
     * セッションを引き継ぐ子プロセスへ渡す PHP ini オプションを組み立てる
     *
     * 共有ホスティングでは Web SAPI と CLI が異なる php.ini（＝異なる session.save_path）を
     * 読み込むことがあり、セッション ID を引き継いでも子プロセスが別ディレクトリを参照して
     * 匿名セッションに落ちる。親プロセスの実効値を -d で伝播し、同じ保存先を参照させる。
     *
     * @param Page $page
     * @return list<string>
     */
    protected function buildSessionIniOptions(Page $page): array
    {
        if (!$page->isWithSession()) {
            return [];
        }
        if ($this->useDatabaseSession()) {
            // DB セッションは save_path を参照しないため伝播不要
            return [];
        }
        $saveHandler = $this->getSessionSaveHandler();
        if ($saveHandler === '') {
            return [];
        }
        $options = ['-d', 'session.save_handler=' . $saveHandler];
        $savePath = $this->getSessionSavePath();
        if ($savePath !== '') {
            $options[] = '-d';
            $options[] = 'session.save_path=' . $savePath;
        }
        return $options;
    }

    /**
     * DB セッション（PHP_SESSION_USE_DB）を利用しているかどうか
     *
     * @return bool
     */
    protected function useDatabaseSession(): bool
    {
        return defined('PHP_SESSION_USE_DB') && PHP_SESSION_USE_DB;
    }

    /**
     * 親プロセスの実効セッション保存ハンドラを取得
     *
     * @return string
     */
    protected function getSessionSaveHandler(): string
    {
        $saveHandler = ini_get('session.save_handler');
        return is_string($saveHandler) ? $saveHandler : '';
    }

    /**
     * 親プロセスの実効セッション保存先パスを取得
     *
     * ini_get ではなく session_save_path で取得する。Hook や Session\Engine（SESSION_SAVE_PATH）が
     * 実行時に変更した保存先も含めた「親が実際に使っている値」を伝播するため。
     *
     * @return string
     */
    protected function getSessionSavePath(): string
    {
        $savePath = session_save_path();
        return is_string($savePath) ? $savePath : '';
    }

    /**
     * phpの実行パスを取得
     *
     * @return string
     */
    private function getPhpBinPath(): string
    {
        return env('PHP_BIN_PATH', 'php');
    }

    /**
     * console.phpスクリプトのパスを取得
     *
     * @return string
     */
    private function getConsoleScriptPath(): string
    {
        $scriptPath = __DIR__ . '/console.php';
        if (!file_exists($scriptPath)) {
            throw new \RuntimeException('console.php not found: ' . $scriptPath);
        }
        return $scriptPath;
    }
}
