<?php

namespace Acms\Services\Template\Twig\Config;

use Acms\Services\Facades\Config;
use Acms\Traits\Utilities\FieldTrait;
use Field;
use Field_Validation;
use Twig\Markup;

/*
 * Field と Field_Validation はそれぞれ独立した static $aryField を持つ別シングルトンに
 * なっており、`Field::singleton('post')` と `Field_Validation::singleton('post')` は
 * **別インスタンス**を返す (Field.php:111 と Field.php:1149 をそれぞれ参照)。
 * ACMS の POST シングルトンは `Application::getPostParameter()` 内で
 * `Field_Validation::singleton('post')` に格納されているため、POST 値を読むときは
 * 必ず Field_Validation 側を参照する必要がある。
 */

/**
 * Twig 関数 acms_config() の戻り値となる値オブジェクト。
 *
 * 設定画面 Twig から「現在のスコープに対応する重ね合わせ Config Field」を扱う窓口。
 *
 * 設計方針:
 *  - Field オブジェクトは Twig に露出させない (どのメソッドも Field を返さない)
 *  - __toString / Stringable は実装しない (Twig の暗黙文字列化を避ける)
 *  - get / array / string / integer / float / boolean / selected / checked は Field からの直取得 (軽量経路)
 *  - field / group / all / invalid は FieldTrait::buildFieldTrait の戻り値を返す
 *  - Field と built は同インスタンス内でのみ Lazy + memoize
 *
 * 値の取得は **POST 値 overload あり** をデフォルトとする (Symfony Form / Rails / Django と
 * 同じ「フォーム描画時は submitted 優先」哲学)。永続化済みの値だけ取りたい場合は
 * `cfg.persisted` で POST overload を行わない別アクセサを取得する。
 */
final class ConfigAccessor
{
    use FieldTrait;

    private const ENCODING = 'UTF-8';

    /**
     * @var array{blogId?: int, ruleId?: int, moduleId?: int, configSetId?: int}
     */
    private array $options;

    /**
     * persisted モード (POST overload を行わないモード) かどうか。
     */
    private bool $persistedMode = false;

    /**
     * @var Field|null
     */
    private ?Field $field = null;

    /**
     * @var array<string, mixed>|null
     */
    private ?array $built = null;

    /**
     * @var ConfigAccessor|null persisted モードの遅延キャッシュ
     */
    private ?ConfigAccessor $persistedCache = null;

    /**
     * @param array{blogId?: int, ruleId?: int, moduleId?: int, configSetId?: int} $options
     */
    public function __construct(array $options = [])
    {
        $this->options = $options;
    }

    /**
     * 単一値を取得する (軽量経路)。
     *
     * @param string $key
     * @param mixed $default
     * @param int|null $index 配列値の特定要素を取りたい場合に指定
     * @return mixed
     */
    public function get(string $key, mixed $default = null, ?int $index = null): mixed
    {
        $field = $this->resolveField();
        if ($index !== null) {
            $arr = $field->getArray($key);
            return $arr[$index] ?? $default;
        }
        $value = $field->get($key, $default);
        return $value;
    }

    /**
     * 配列値を取得する (軽量経路)。
     *
     * @param string $key
     * @param bool $strict false の場合、末尾の空文字・null・フィールドグループを除いた配列を返す
     * @return array<int, mixed>
     */
    public function array(string $key, bool $strict = false): array
    {
        return $this->resolveField()->getArray($key, $strict);
    }

    /**
     * 単一値を文字列として取得する (軽量経路)。値が無ければ default。
     *
     * @param string $key
     * @param string|null $default
     * @param int|null $index 配列値の特定要素を取りたい場合に指定
     * @return string|null
     */
    public function string(string $key, ?string $default = null, ?int $index = null): ?string
    {
        return $this->resolveField()->string($key, $default, $index ?? 0);
    }

    /**
     * 単一値を整数に変換して取得する (軽量経路)。数値でなければ default。
     *
     * @param string $key
     * @param int|null $default
     * @param int|null $index 配列値の特定要素を取りたい場合に指定
     * @return int|null
     */
    public function integer(string $key, ?int $default = null, ?int $index = null): ?int
    {
        return $this->resolveField()->integer($key, $default, $index ?? 0);
    }

    /**
     * 単一値を浮動小数点数に変換して取得する (軽量経路)。数値でなければ default。
     *
     * @param string $key
     * @param float|null $default
     * @param int|null $index 配列値の特定要素を取りたい場合に指定
     * @return float|null
     */
    public function float(string $key, ?float $default = null, ?int $index = null): ?float
    {
        return $this->resolveField()->float($key, $default, $index ?? 0);
    }

    /**
     * 単一値を真偽値に変換して取得する (軽量経路, a-blog cms 慣用の on/off 等)。
     *
     * @param string $key
     * @param bool|null $default
     * @param int|null $index 配列値の特定要素を取りたい場合に指定
     * @return bool|null
     */
    public function boolean(string $key, ?bool $default = null, ?int $index = null): ?bool
    {
        return $this->resolveField()->boolean($key, $default, $index ?? 0);
    }

    /**
     * select 要素の selected 属性を出力する。
     *
     * 戻り値は Twig\Markup でラップしているため autoescape をバイパスする。
     *
     * @param string $key
     * @param string $value
     * @param int|null $index
     * @return Markup
     */
    public function selected(string $key, string $value, ?int $index = null): Markup
    {
        $current = (string) ($this->get($key, '', $index) ?? '');
        $html = $current === $value ? 'selected="selected"' : '';
        return new Markup($html, self::ENCODING);
    }

    /**
     * input[type=checkbox|radio] の checked 属性を出力する。
     *
     * @param string $key
     * @param string $value
     * @param int|null $index
     * @return Markup
     */
    public function checked(string $key, string $value, ?int $index = null): Markup
    {
        $current = (string) ($this->get($key, '', $index) ?? '');
        $html = $current === $value ? 'checked="checked"' : '';
        return new Markup($html, self::ENCODING);
    }

    /**
     * buildFieldTrait の 1 件ぶん (キー単位) を返す。
     *
     * 存在しないキーは ['value' => null, 'array' => []] を返す。
     *
     * @param string $key
     * @return array<string, mixed>
     */
    public function field(string $key): array
    {
        $built = $this->resolveBuilt();
        if (!isset($built[$key])) {
            return ['value' => null, 'array' => []];
        }
        $entry = $built[$key];
        return is_array($entry) ? $entry : ['value' => $entry, 'array' => [$entry]];
    }

    /**
     * @group 行ループの配列を返す。
     *
     * @param string $name
     * @return array<int, array<string, mixed>>
     */
    public function group(string $name): array
    {
        $built = $this->resolveBuilt();
        $loop = $built[$name] ?? [];
        return is_array($loop) ? $loop : [];
    }

    /**
     * buildFieldTrait の戻り値全体を返す。
     *
     * @return array<string, mixed>
     */
    public function all(): array
    {
        return $this->resolveBuilt();
    }

    /**
     * Field_Validation 経由のバリデーションエラーを問い合わせる。
     *
     * @param string $key
     * @param string|null $method 個別メソッドの失敗を見たい場合に指定 (例: 'required')
     * @param int|null $index グループ内 index
     * @return bool
     */
    public function invalid(string $key, ?string $method = null, ?int $index = null): bool
    {
        $built = $this->resolveBuilt();
        if (!isset($built[$key]) || !is_array($built[$key])) {
            return false;
        }
        $entry = $built[$key];
        if ($index !== null && isset($entry[$index]) && is_array($entry[$index])) {
            $entry = $entry[$index];
        }
        if ($method !== null) {
            return (bool) ($entry['v'][$method] ?? false);
        }
        return (bool) ($entry['invalid'] ?? false);
    }

    /**
     * POST から保存通知 (notice_mess) の生文字列を取得する。
     *
     * ACMS_POST_Config が保存後に redirect 経由で post field にセットする値で、
     * 保存後 GET の表示で利用する。値の例: 'saved', 'reset', '' (未保存) など。
     *
     * @return string
     */
    public function notice(): string
    {
        return Field_Validation::singleton('post')->get('notice_mess', '');
    }

    /**
     * 保存通知が表示対象かどうか (notice() が非空)。
     */
    public function saved(): bool
    {
        return $this->notice() !== '';
    }

    /**
     * 直近のリクエストが Config 保存往復だったか。
     *
     * POST シングルトンの 'config' サブフィールドが non-null なら true。
     */
    public function posted(): bool
    {
        $postConfig = Field_Validation::singleton('post')->getChild('config');
        return !$postConfig->isNull();
    }

    /**
     * Field 全体が valid か。
     *
     * Field_Validation の場合のみ実際にバリデーション結果を見る。通常 Field では常に true。
     */
    public function valid(): bool
    {
        $field = $this->resolveField();
        if ($field instanceof Field_Validation) {
            return $field->isValidAll();
        }
        return true;
    }

    /**
     * バリデーション失敗フィールド一覧 (フィールド名 → 失敗メソッド名のリスト)。
     *
     * 集計ロジックは Field_Validation::getInvalidMethods に閉じている (ドメイン側の
     * 責務)。Twig 側ではフィールド名だけ欲しい場合 `cfg.errors|keys` で取れる。
     *
     * @return array<string, list<string>>
     */
    public function errors(): array
    {
        $field = $this->resolveField();
        if (!$field instanceof Field_Validation) {
            return [];
        }
        return $field->errors();
    }

    /**
     * persisted モード版 ConfigAccessor (POST overload を行わない) を返す。
     *
     * 「ユーザー入力ではなく、純粋に保存済みの値だけを参照したい」escape hatch 用途。
     * 通常のフォーム描画では `acms_config()` のデフォルト (POST overload あり) を使う。
     *
     * persisted 版でさらに persisted を呼んでも自分自身を返す (終端)。
     */
    public function persisted(): self
    {
        if ($this->persistedMode) {
            return $this;
        }
        if ($this->persistedCache === null) {
            $accessor = new self($this->options);
            $accessor->persistedMode = true;
            $this->persistedCache = $accessor;
        }
        return $this->persistedCache;
    }

    /**
     * 重ね合わせ済み Field を Lazy 取得 (memoize)。
     */
    private function resolveField(): Field
    {
        if ($this->field === null) {
            $this->field = $this->buildOverlaidField($this->options);
        }
        return $this->field;
    }

    /**
     * buildFieldTrait の戻り値を Lazy 取得 (memoize)。
     *
     * @return array<string, mixed>
     */
    private function resolveBuilt(): array
    {
        if ($this->built === null) {
            $built = $this->buildFieldTrait($this->resolveField());
            $this->built = is_array($built) ? $built : [];
        }
        return $this->built;
    }

    /**
     * 重ね合わせ Field を返す。
     *
     * スコープ指定 (blogId / ruleId / moduleId / configSetId) がある場合は
     * Admin_Config::getConfig 相当に、そのスコープの Config をゼロから組み立てる。
     * blogId を指定すると、現在のリクエストブログではなく、その指定ブログの保存済み
     * Config を基底にする (サンプルプラグイン等が任意ブログの設定画面を描くための経路)。
     *
     * スコープ指定がいずれも無い場合は、メイン PHP (Application) が解決済みの
     * 「現在のコンテキストの重ね合わせ Config」(`Field::singleton('config')`) を
     * 基底に使う。これはブログ・カテゴリー・ルール・コンフィグセットまで反映済みで、
     * `default + loadBlogConfig(BID)` の手組みでは取りこぼす解決結果を含む。
     *
     * @param array{blogId?: int, ruleId?: int, moduleId?: int, configSetId?: int} $options
     * @return Field
     */
    private function buildOverlaidField(array $options): Field
    {
        $blogId = $options['blogId'] ?? null;
        $ruleId = $options['ruleId'] ?? null;
        $moduleId = $options['moduleId'] ?? null;
        $configSetId = $options['configSetId'] ?? null;
        if ($moduleId !== null && $moduleId > 0) {
            $configSetId = null;
        }

        $hasScope = ($blogId !== null && $blogId > 0)
            || ($ruleId !== null && $ruleId > 0)
            || ($moduleId !== null && $moduleId > 0)
            || ($configSetId !== null && $configSetId > 0);

        if ($hasScope) {
            $config = $this->buildScopedField($blogId, $ruleId, $moduleId, $configSetId);
        } else {
            $config = $this->currentContextField();
        }

        if ($this->persistedMode) {
            return $config;
        }
        return $this->applyPostOverload($config);
    }

    /**
     * スコープ指定 (blogId / ruleId / moduleId / configSetId) に応じた重ね合わせ Field を組み立てる。
     *
     * blogId 未指定時は現在のリクエストブログ (BID) を基底ブログとする。
     * configSetId を指定するとブログ基底はコンフィグセットで置き換わる (blogId より優先)。
     *
     * blogId が効くのはブログ基底層 (loadBlogConfig) のみ。rule / module の上書きは
     * Config ファサードが現在リクエストブログ基準で解決する (loadRuleConfig が BID 固定の
     * ため) ので、別ブログの rule / module 設定は blogId では取得できない。
     *
     * @param int|null $blogId
     * @param int|null $ruleId
     * @param int|null $moduleId
     * @param int|null $configSetId
     * @return Field
     */
    private function buildScopedField(?int $blogId, ?int $ruleId, ?int $moduleId, ?int $configSetId): Field
    {
        $config = Config::loadDefaultField();
        if ($configSetId !== null && $configSetId > 0) {
            $config->overload(Config::loadConfigSet($configSetId));
        } else {
            $config->overload(Config::loadBlogConfig($blogId !== null && $blogId > 0 ? $blogId : BID));
        }

        $extra = null;
        if ($ruleId !== null && $ruleId > 0 && ($moduleId === null || $moduleId <= 0)) {
            $extra = Config::loadRuleConfig($ruleId, $configSetId);
        } elseif ($moduleId !== null && $moduleId > 0) {
            $extra = Config::loadModuleConfig($moduleId, $ruleId);
        }
        if ($extra !== null) {
            $config->overload($extra);
        }

        return $config;
    }

    /**
     * メイン PHP が解決した現在コンテキストの Config を複製して返す。
     *
     * `Field::singleton('config')` はリクエスト全体で共有されるシングルトンで、
     * `Field::overload()` は in-place 破壊なので、そのまま返すと後段の
     * applyPostOverload や FieldTrait の処理がグローバル状態を汚染しうる。
     * 複製を返してシングルトンを保護する (applyPostOverload が書き換えるのは
     * トップレベルのフィールドのみなので、子フィールドは共有のままで安全)。
     *
     * @return Field
     */
    private function currentContextField(): Field
    {
        return new Field(Field::singleton('config'));
    }

    /**
     * DB 重ね合わせ済みの Config Field の上に POST.config を最後に overload する。
     *
     * Symfony Form / Rails と同じく「フォーム描画では submitted を最優先」する哲学。
     * バリデーションエラーで再描画されたときにユーザー入力を保持できる。
     *
     * POST が Field_Validation 由来なら、後段で valid()/errors() が動くように
     * Field_Validation 側を最終 Field として返す (config-side も逆 overload する)。
     */
    private function applyPostOverload(Field $config): Field
    {
        $postConfig = Field_Validation::singleton('post')->getChild('config');
        if ($postConfig->isNull()) {
            return $config;
        }
        $config->overload($postConfig);
        if ($postConfig instanceof Field_Validation) {
            $postConfig->overload($config);
            return $postConfig;
        }
        return $config;
    }
}
