<?php

namespace Acms\Services\ConfigServer;

use Acms\Services\Facades\LocalStorage;
use InvalidArgumentException;
use RuntimeException;
use Throwable;

/**
 * ablogcms/config.server.php の `define()` を **特定キーだけ差分更新** するクラス
 *
 * Web インストーラ: `setup/install.php::writeConfig` から呼ばれる。
 *
 * 全文生成は {@see ConfigServerBuilder}、`define` 行の組み立てルールは
 * {@see DefineLineFormatter} に分離。本クラスは I/O と差分更新ロジックのみ担う。
 *
 * I/O は原則 `LocalStorage` ファサード経由 (将来の Storage 差し替えと chmod 自動適用のため)。
 * ただし **読み込みだけは生の `file_get_contents` を直接使う**: `LocalStorage::get` は
 * `config.server.php` を `secret_file_name` (DB パス漏洩防止) としてブロックするため。
 * 書き込みと存在チェックは `secret_file_name` ガードの対象外なので Facade 経由で OK。
 *
 * 呼び出しタイミングは CMS bootstrap 後 (`Application::init()` 済み) の前提:
 * `setup/install.php` は setup/application.php で init() 済み。
 */
final class ConfigServerWriter
{
    public function __construct(private readonly string $configPath)
    {
        if ($this->configPath === '') {
            throw new InvalidArgumentException('configPath is required');
        }
    }

    /**
     * 指定した define() の値を書き換える。キーは文字列、値は文字列化できるもの。
     *
     * @param array<string, string|int|float|bool|null> $values define する key => value
     * @return list<string> 実際に書き換えが行われたキー
     */
    public function update(array $values): array
    {
        if (!LocalStorage::exists($this->configPath) || !LocalStorage::isFile($this->configPath)) {
            throw new RuntimeException(
                sprintf('Config file not found: %s', $this->configPath)
            );
        }
        // @codeCoverageIgnoreStart
        // isReadable / isWritable の false パスは root 実行環境では再現不能 (権限が無視される) のため除外。
        if (!LocalStorage::isReadable($this->configPath)) {
            throw new RuntimeException(
                sprintf('Config file is not readable: %s', $this->configPath)
            );
        }
        if (!LocalStorage::isWritable($this->configPath)) {
            throw new RuntimeException(
                sprintf('Config file is not writable: %s', $this->configPath)
            );
        }
        // @codeCoverageIgnoreEnd

        // LocalStorage::get は `config.server.php` を secret_file_name としてブロックする
        // (Web 経由のディレクトリトラバーサル防御)。書き換えのために自前で読む必要があるので、
        // ここだけは生の file_get_contents を使う。書き込み側は LocalStorage::put 経由。
        $content = @file_get_contents($this->configPath);
        // @codeCoverageIgnoreStart
        // isReadable() を通った直後で file_get_contents が false になるのは
        // ファイルが他プロセスに削除/ロックされた等の競合状態のみ。実テストで再現困難。
        if ($content === false) {
            throw new RuntimeException(
                sprintf('Failed to read config file: %s', $this->configPath)
            );
        }
        // @codeCoverageIgnoreEnd

        $updatedKeys = [];
        foreach ($values as $key => $value) {
            $replacementLine = DefineLineFormatter::format($key, $value);
            $pattern = '/define\(\'' . preg_quote($key, '/') . '\'\s*,\s*.*?\s*\);/';

            // preg_replace の置換文字列では $0, $1, ... が backreference として解釈されるため、
            // $ を含む値 ('price is $100' 等) が壊れる。
            // preg_replace_callback でクロージャ内から直接文字列を返すことで回避する。
            $count = 0;
            $newContent = preg_replace_callback(
                $pattern,
                function () use ($replacementLine, &$count): string {
                    $count++;
                    return $replacementLine;
                },
                $content,
                1,
            );
            // @codeCoverageIgnoreStart
            // preg_quote 済みのパターンで preg_replace_callback が null を返すのは
            // PCRE 内部エラー (バックトラッキング上限超過等) のみ。実テストで再現困難。
            if ($newContent === null) {
                throw new RuntimeException(
                    sprintf('preg_replace_callback failed (key=%s)', $key)
                );
            }
            // @codeCoverageIgnoreEnd
            if ($count > 0) {
                $content = $newContent;
                $updatedKeys[] = $key;
            }
        }

        if (count($updatedKeys) === 0) {
            return [];
        }

        try {
            LocalStorage::put($this->configPath, $content);
        } catch (Throwable $e) {
            // @codeCoverageIgnoreStart
            // isWritable() を通った直後で put が失敗するのは、ディレクトリが
            // 他プロセスに削除された等の競合状態のみ。実テストで再現困難。
            throw new RuntimeException(
                sprintf('Failed to write config file: %s', $this->configPath),
                0,
                $e,
            );
            // @codeCoverageIgnoreEnd
        }

        return $updatedKeys;
    }
}
