<?php

/**
 * PHP8.2対応以前にシリアライズされたFieldオブジェクトをデシリアライズすると、
 * PHP8.2以上の環境でエラーが発生するため、
 * #[\AllowDynamicProperties] を付与している。
 */
#[\AllowDynamicProperties]
class Field
{
    /**
     * @var array<string, array>
     */
    public $_aryField = [];

    /**
     * @var array<string, Field>
     */
    public $_aryChild = [];

    /**
     * @var array<string, array>
     */
    public $_aryMeta = [];

    /**
     * constructor
     *
     * @param Field|array<string, mixed>|string|null $Field
     * @param bool $isDeep
     */
    final public function __construct($Field = null, $isDeep = false)
    {
        $this->overload($Field, $isDeep);
    }

    /**
     * フィールドクエリを解析する
     * @param string $query
     * @return void
     */
    public function parse($query)
    {
        foreach (preg_split('@/\s*and\s*/@i', $query, -1, PREG_SPLIT_NO_EMPTY) as $data) {
            $s = preg_split('@/@i', $data, -1, PREG_SPLIT_NO_EMPTY);
            if ($s === false) {
                continue;
            }
            $key = array_shift($s);
            while ($val = array_shift($s)) {
                $this->addField($key, $val);
            }
        }
    }

    /**
     * オブジェクトを上書きする
     *
     * @param Field|array<string, mixed>|string $Field
     * @param bool $isDeep
     * @return void
     */
    public function overload($Field, $isDeep = false)
    {
        if (is_object($Field) and 'FIELD' == substr(strtoupper(get_class($Field)), 0, 5)) {
            foreach ($Field->listFields() as $fd) {
                $this->setField($fd, $Field->getArray($fd, true));
            }
            if ($isDeep) {
                foreach ($Field->listChildren() as $child) {
                    $Child  =& $Field->getChild($child);
                    $class  = get_class($Child);
                    $Child  = new $class($Child, $isDeep);
                    $this->addChild($child, $Child);
                }
            }
        } elseif (is_array($Field)) {
            foreach ($Field as $key => $val) {
                if (is_object($val)) {
                    if ('FIELD' != substr(strtoupper(get_class($val)), 0, 5)) {
                        continue;
                    }
                    $this->addChild($key, $val);
                } else {
                    if (is_array($val)) {
                        reset($val);
                        if (0 !== key($val)) {
                            $f = new Field($val);
                            $this->addChild($key, $f);
                            continue;
                        } else {
                            reset($val);
                        }
                    }
                    $this->setField($key, $val);
                }
            }
        } elseif (is_string($Field) and '' !== $Field) {
            $this->parse($Field);
        }
    }

    /**
     * シングルトンパターンでオブジェクトを生成する
     *
     * @static
     * @param string $key
     * @param null|Field $Field
     * @return Field
     */
    public static function & singleton($key, $Field = null)
    {
        static $aryField  = [];

        if (!isset($aryField[$key]) || $Field !== null) {
            $aryField[$key] = new Field($Field);
        }

        return $aryField[$key];
    }

    /**
     * シリアライズされた文字列を返す
     * @return string
     */
    public function serialize()
    {
        $res    = '';

        foreach ($this->listFields() as $fd) {
            if ($vals = $this->getArray($fd)) {
                $res    .= '/and/' . $fd . '/' . join('/', $vals);
            }
        }
        return substr($res, 5);
    }

    /**
     * 指定したフィールド名のフィールドがnullかどうかを判定する
     * $fdにnullを指定した場合は、フィールドが一つも存在しない場合にtrueを返す
     *
     * @param string|null $fd
     * @param int $i
     * @return bool
     */
    public function isNull($fd = null, $i = 0)
    {
        return is_null($fd) ? !count($this->_aryField) : !isset($this->_aryField[$fd][$i]);
    }

    /**
     * 指定したフィールド名のフィールドがフィールドグループかどうかを判定する
     * @param string $fd
     * @return bool
     */
    public function isGroup($fd)
    {
        return false;
    }

    /**
     * 指定した名前のフィールドが存在するかどうかを判定する
     * @param string $fd
     * @param int|null $i
     * @return bool
     */
    public function isExists($fd, $i = null)
    {
        if (!array_key_exists($fd, $this->_aryField)) {
            return false;
        }
        if (!is_null($i) and !array_key_exists($i, $this->_aryField[$fd])) {
            return false;
        }
        return true;
    }

    /**
     * 指定したフィールド名の値を取得する
     * フィールド名に文字列以外を指定した場合はfalseを返す
     * @template T
     * @param T $fd
     * @param string|int|null $def
     * @param int $i
     * @return (T is string ? string : false)
     */
    public function get($fd, $def = null, $i = 0)
    {
        if (!is_string($fd)) {
            return false;
        }
        $fdvalue = (!empty($this->_aryField[$fd][$i]) or (isset($this->_aryField[$fd][$i]) and ('0' === $this->_aryField[$fd][$i])))
                ? $this->_aryField[$fd][$i]
                : (!is_null($def) ? $def : (isset($this->_aryField[$fd][$i]) ? $this->_aryField[$fd][$i] : $def));

        return is_array($fdvalue) ? '' : strval($fdvalue);
    }

    /**
     * 指定したフィールド名の値を配列で取得する
     * @param string $fd
     * @param bool $strict falseの場合、空文字、null、フィールドグループを削除した配列を返す。デフォルトはfalse
     * @return array
     */
    public function getArray($fd, $strict = false)
    {
        if (!is_string($fd)) {
            return [];
        }
        $fds = isset($this->_aryField[$fd]) ? $this->_aryField[$fd] : [];
        if (!$cnt = count($fds)) {
            return [];
        }
        if (1 === $cnt and !isset($fds[0])) {
            return [];
        }

        if (!$strict) {
            for ($i = $cnt - 1; 0 <= $i; $i--) {
                if (!is_null($fds[$i]) and '' !== $fds[$i]) {
                    break;
                }
                if ($this->isGroup($fd)) {
                    break;
                }
                unset($fds[$i]);
            }
        }

        return $fds;
    }

    /**
     * フィールド名を列挙する
     * @return string[]
     */
    public function listFields()
    {
        return array_keys($this->_aryField);
    }

    /**
     * フィールドの値を設定する
     * @param string $fd フィールド名
     * @param array|string|int|float|null $vals
     * @return bool
     */
    public function setField($fd, $vals = null)
    {
        if (!is_string($fd)) {
            return false;
        }
        if (empty($vals) and 0 !== $vals and '0' !== $vals) {
            $this->_aryField[$fd]   = [];
        } else {
            if (!is_array($vals)) {
                $vals   = [$vals];
            }
            $this->_aryField[$fd]   = [];
            $max = max(array_keys($vals));
            $max = intval($max);
            for ($i = 0; $i <= $max; $i++) {
                $this->_aryField[$fd][$i] = isset($vals[$i]) ? $vals[$i] : '';
            }
        }
        return true;
    }

    /**
     * フィールドの値を設定する
     * @param string $fd フィールド名
     * @param array|string|int|float|null $vals
     * @return bool
     */
    public function set($fd, $vals = null)
    {
        return $this->setField($fd, $vals);
    }

    /**
     * 指定したフィールド名のフィールドに値を追加する
     * @param string $fd フィールド名
     * @param array|string|int|float|null $vals
     * @return bool
     */
    public function addField($fd, $vals)
    {
        if (!is_array($vals)) {
            $vals = [$vals];
        }
        foreach ($vals as $val) {
            $this->_aryField[$fd][] = $val;
        }
        return true;
    }

    /**
     * alias for addField
     *
     * @param string $fd フィールド名
     * @param array|string|int|float|null $vals
     * @return bool
     */
    public function add($fd, $vals)
    {
        return $this->addField($fd, $vals);
    }

    /**
     * 指定したフィールド名のフィールドを削除する
     * @param string $fd
     * @return bool
     */
    public function deleteField($fd)
    {
        if (!is_string($fd)) {
            return false;
        }
        unset($this->_aryField[$fd]);
        unset($this->_aryMeta[$fd]);
        return true;
    }

    /**
     * alias for deleteField
     *
     * @param string $fd
     * @return bool
     */
    public function delete($fd)
    {
        return $this->deleteField($fd);
    }

    /**
     * 指定した名前の子フィールドを取得する
     * @param string $name
     * @return Field
     */
    public function & getChild($name)
    {
        if (!isset($this->_aryChild[$name])) {
            $class  = get_class($this);
            $obj = new $class();
            $this->addChild($name, $obj);
        }
        return $this->_aryChild[$name];
    }

    /**
     * 指定した名前の子フィールドを設定する
     * @param string $name
     * @param Field &$Field
     * @return true
     */
    public function addChild($name, &$Field)
    {
        $this->_aryChild[$name] =& $Field;
        return true;
    }

    /**
     * 指定した名前の子フィールドを削除する
     * @return true
     */
    public function removeChild($name)
    {
        unset($this->_aryChild[$name]);
        return true;
    }

    /**
     * 子フィールド名を列挙する
     * @return string[]
     */
    public function listChildren()
    {
        return array_keys($this->_aryChild);
    }

    /**
     * 指定した名前の子フィールドが存在するかどうかを判定する
     * 名前を指定しない場合は、子フィールドが一つも存在しない場合にtrueを返す
     *
     * @param string|null $name
     * @return bool
     */
    public function isChildExists($name = null)
    {
        return is_null($name) ? !!count($this->_aryChild) : !!isset($this->_aryChild[$name]);
    }

    /**
     * 指定したフィールド名のフィールドにメタ情報を設定する
     * @param string $fd フィールド名
     * @param string|null $key メタ情報のキー
     * @param mixed $val メタ情報の値
     * @return true
     */
    public function setMeta($fd, $key = null, $val = null)
    {
        if (empty($key)) {
            $this->_aryMeta[$fd] = [];
        } else {
            $this->_aryMeta[$fd][$key] = $val;
        }
        return true;
    }

    /**
     * 指定したフィールド名のフィールドに設定されたメタ情報を取得する
     * $keyを指定しない場合は、指定したフィールド名のメタ情報すべてを配列で返す
     *
     * @template T of string|null
     * @param string $fd フィールド名
     * @param T $key メタ情報のキー
     * @return (T is non-empty-string ? mixed : array)
     */
    public function getMeta($fd, $key = null)
    {
        if (empty($key)) {
            return isset($this->_aryMeta[$fd]) ? $this->_aryMeta[$fd] : [];
        } else {
            return isset($this->_aryMeta[$fd][$key]) ? $this->_aryMeta[$fd][$key] : null;
        }
    }

    /**
     * 指定されたスコープ名に基づいてフィールド構造を深掘りし、関連するフィールドデータを整理します。
     *
     * 指定されたスコープ名の子フィールドを生成または取得し、
     * そのスコープ内で定義されているフィールドの値を元フィールドから新しい子フィールドに追加します。
     * 返り値として、新しい子フィールドを返します。
     *
     * @param string $scp スコープ名
     * @return Field 新たに生成された子フィールドの参照を返す
     */
    public function &dig($scp = 'field')
    {
        $Field  = $this->getChild($scp);

        if ($aryFd = $this->getArray($scp, true)) {
            foreach ($aryFd as $fd) {
                if (!$this->isExists($fd)) {
                    continue;
                }
                $Field->setField($fd, $this->getArray($fd));
                $this->deleteField($fd);
            }
            $this->deleteField($scp);
        }

        //-----------
        // reference
        if ($aryFd = $Field->listFields()) {
            foreach ($aryFd as $fd) {
                if ('&' !== substr($Field->get($fd), 0, 1)) {
                    continue;
                }
                $_fd    = preg_replace('@^\s*&\s*|\s*;$@', '', $Field->get($fd));
                if ($Field->isNull($_fd)) {
                    continue;
                }
                $Field->setField($fd, $Field->get($_fd));
            }
        }

        $this->addChild($scp, $Field);
        $Field  =& $this->getChild($scp);

        return $Field;
    }

    /**
     * カスタムユニットのフィールド名と値を調整します。
     *
     * フィールド名と値のペアを走査し、フィールド名に指定されたIDが含まれている場合に、
     * そのIDを除去して新しいフィールド名として設定します。IDがフィールド値にも含まれている場合（フィールド名が'@'で始まる場合）、
     * 値に対しても同様の処理を行います。
     *
     * 処理の結果、フィールド名からIDが除去された新しい配列が`_aryField`プロパティに設定されます。
     * また、メタデータを格納する`_aryMeta`プロパティは空の配列にリセットされます。
     *
     * @param string $id ユニットID。デフォルトは空文字列です。
     * @return void
     */
    public function retouchCustomUnit($id = '')
    {
        $aryField = [];
        $aryMeta = [];
        foreach ($this->_aryField as $key => $val) {
            $key = preg_replace("/^(.*)$id([^\d]*)$/", '$1$2', $key);
            if (preg_match('/^@/', $key)) {
                $val = preg_replace("/^(.*)$id([^\d]*)$/", '$1$2', $val);
            }
            $aryField[$key] = $val;
        }
        foreach ($this->_aryMeta as $key => $val) {
            $key = preg_replace("/^(.*)$id([^\d]*)$/", '$1$2', $key);
            $aryMeta[$key] = $val;
        }
        $this->_aryField = $aryField;
        $this->_aryMeta = $aryMeta;
    }

    /**
     * @param bool $isDeep 非推奨の引数です。使用しないでください。
     * @return true
     */
    public function reset(bool $isDeep = false)
    {
        return true;
    }

    /**
     * 指定したフィールドをコピーして、新しいオブジェクトを生成します。
     * @param string[] $fieldNames
     * @return static
     */
    public function cloneWith(array $fieldNames)
    {
        $field = new static();
        foreach ($fieldNames as $fieldName) {
            $values = $this->_aryField[$fieldName] ?? [];
            $meta = $this->_aryMeta[$fieldName] ?? [];
            $field->setField($fieldName, $values);
            foreach ($meta as $key => $value) {
                $field->setMeta($fieldName, $key, $value);
            }
        }
        return $field;
    }

    // -----------------------------------------------------------------------
    // 型付き取得 API（型変換アクセサ）
    //
    // Field は値をすべて文字列で保持するため、Laravel Config の string()/integer() の
    // ような「型が違えば例外」ではなく、文字列を指定型に「変換して」返す。
    // 変換できない / 値が無い場合は第 2 引数のデフォルト（未指定なら null）を返す
    // （get() と同じ寛容な哲学）。index 指定は get() と揃える。
    //
    //   $price = $field->integer('price', 0);   // '1980' → 1980 / 未入力 → 0
    //   $rate  = $field->float('rate');          // '0.8'  → 0.8
    //   $flag  = $field->boolean('isPublic');    // 'on'   → true
    //   $title = $field->string('title', '無題'); // 文字列はそのまま
    // -----------------------------------------------------------------------

    /**
     * 指定フィールドの生の文字列値を返す。値が無い / 空文字 / 配列なら null。
     * 型付きアクセサ共通の「値あり判定」に使う（boolean だけは空文字を別扱いするため使わない）。
     */
    private function rawScalar(string $fd, int $i): ?string
    {
        if (!isset($this->_aryField[$fd][$i]) || is_array($this->_aryField[$fd][$i])) {
            return null;
        }
        $value = (string) $this->_aryField[$fd][$i];
        return $value === '' ? null : $value;
    }

    /**
     * 文字列として取得する。値が無ければデフォルト。
     */
    public function string(string $fd, ?string $default = null, int $i = 0): ?string
    {
        return $this->rawScalar($fd, $i) ?? $default;
    }

    /**
     * 整数に変換して取得する。数値文字列でなければデフォルト。
     * 先頭 0 や指数表記も int 化されるため、電話番号・郵便番号は string() を使うこと。
     */
    public function integer(string $fd, ?int $default = null, int $i = 0): ?int
    {
        $raw = $this->rawScalar($fd, $i);
        if ($raw === null || !is_numeric($raw)) {
            return $default;
        }
        return (int) $raw;
    }

    /**
     * 浮動小数点数に変換して取得する。数値文字列でなければデフォルト。
     */
    public function float(string $fd, ?float $default = null, int $i = 0): ?float
    {
        $raw = $this->rawScalar($fd, $i);
        if ($raw === null || !is_numeric($raw)) {
            return $default;
        }
        return (float) $raw;
    }

    /**
     * 真偽値に変換して取得する（a-blog cms 慣用）。
     *  - true 扱い : 'on' / '1' / 'true' / 'yes'（大小無視）
     *  - false 扱い: 'off' / '0' / 'false' / 'no' / ''（空文字）
     *  - キー不在・上記以外 : デフォルト
     */
    public function boolean(string $fd, ?bool $default = null, int $i = 0): ?bool
    {
        if (!isset($this->_aryField[$fd][$i]) || is_array($this->_aryField[$fd][$i])) {
            return $default;
        }
        $lower = strtolower((string) $this->_aryField[$fd][$i]);
        if (in_array($lower, ['on', '1', 'true', 'yes'], true)) {
            return true;
        }
        if (in_array($lower, ['off', '0', 'false', 'no', ''], true)) {
            return false;
        }
        return $default;
    }

    // -----------------------------------------------------------------------
    // フィールドグループ操作 API
    //
    // a-blog cms の暗黙のフィールドグループ慣習を明示的に扱うための API。
    //  - `@groupName` キーにグループに属するフィールド名の配列を入れる
    //  - 各フィールド名に対し、同じインデックスで縦に値を揃える（行配列として扱える）
    //
    // 例: addition グループに 2 行入った状態
    //   _aryField['@additionGroup'] = ['additionId', 'additionName', 'additionFee']
    //   _aryField['additionId']     = ['1', '2']
    //   _aryField['additionName']   = ['送料', '手数料']
    //   _aryField['additionFee']    = ['500', '300']
    // -----------------------------------------------------------------------

    /**
     * '@' で始まる「グループ宣言キー」かどうかを判定する。
     */
    public static function isGroupKey(string $key): bool
    {
        return $key !== '' && $key[0] === '@';
    }

    /**
     * 'image@path' のような `baseName@subKey` 形式のキーを分解する。
     * 形式に合致しない場合は null。グループ宣言キー（'@xxx'）も null を返す。
     *
     * @return array{0: string, 1: string}|null
     */
    public static function parseMultiFieldKey(string $key): ?array
    {
        if (!str_contains($key, '@')) {
            return null;
        }
        [$mainKey, $subKey] = explode('@', $key, 2);
        if ($mainKey === '' || $subKey === '') {
            return null;
        }
        return [$mainKey, $subKey];
    }

    /**
     * '@' で始まる単一値などをひとまとめにせず純粋な値だけを型変換する。
     *
     *  - 空文字 / null → null
     *  - 文字列 "true" / "false"（大小文字無視）→ bool
     *  - 数値文字列 → int / float（先頭 0 で 2 文字以上は電話番号・郵便番号扱いで文字列保持）
     *  - それ以外 → 元の値をそのまま返す
     *
     * @param mixed $value
     * @return mixed
     */
    public static function convertValueType($value)
    {
        if ($value === '' || $value === null) {
            return null;
        }
        if (is_string($value)) {
            $lower = strtolower($value);
            if ($lower === 'true' || $lower === 'false') {
                return $lower === 'true';
            }
        }
        if (is_numeric($value)) {
            $strVal = (string) $value;
            if (str_contains($strVal, '.')) {
                return (float) $value;
            }
            if (strlen($strVal) > 1 && $strVal[0] === '0') {
                return $value;
            }
            return (int) $value;
        }
        return $value;
    }

    /**
     * このフィールドに宣言されているグループ名の配列を返す。
     * '@xxx' キーの 'xxx' 部分を返す。
     *
     * @return string[]
     */
    public function listGroupNames(): array
    {
        $names = [];
        foreach ($this->listFields() as $key) {
            if (self::isGroupKey($key)) {
                $names[] = substr($key, 1);
            }
        }
        return $names;
    }

    /**
     * 指定したグループに属するフィールド名のリストを返す。
     * 未宣言の場合は空配列。
     *
     * @return string[]
     */
    public function listGroupFields(string $groupName): array
    {
        return array_values(array_filter(
            $this->getArray('@' . $groupName),
            'is_string'
        ));
    }

    /**
     * グループに属するフィールド名のリストを宣言する。
     * '@$groupName' キーに上書きする。既存のフィールドの値はそのまま残る。
     *
     * @param string[] $fieldNames
     */
    public function defineGroup(string $groupName, array $fieldNames): void
    {
        $this->setField('@' . $groupName, array_values($fieldNames));
    }

    /**
     * グループの全行を連想配列の配列として返す。
     *
     * フィールドごとに長さがズレている場合は最大長に合わせ、足りない値は空文字で埋める。
     * $autoConvert が true なら convertValueType() で型変換を適用する。
     *
     * @return list<array<string, mixed>>
     */
    public function getGroupRows(string $groupName, bool $autoConvert = false): array
    {
        $fieldNames = $this->listGroupFields($groupName);
        if ($fieldNames === []) {
            return [];
        }
        $cols = [];
        $max = 0;
        foreach ($fieldNames as $fd) {
            $vals = $this->getArray($fd);
            $cols[$fd] = $vals;
            $max = max($max, count($vals));
        }
        $rows = [];
        for ($i = 0; $i < $max; $i++) {
            $row = [];
            foreach ($fieldNames as $fd) {
                $value = $cols[$fd][$i] ?? '';
                $row[$fd] = $autoConvert ? self::convertValueType($value) : $value;
            }
            $rows[] = $row;
        }
        return $rows;
    }

    /**
     * グループの全行を一括差し替えする。
     *
     * グループ未宣言で $rows が空でない場合、先頭行のキー順で自動的に宣言する。
     * 各行に宣言外のキーが含まれていれば InvalidArgumentException。
     *
     * @param list<array<string, mixed>> $rows
     */
    public function setGroupRows(string $groupName, array $rows): void
    {
        $fieldNames = $this->listGroupFields($groupName);
        if ($fieldNames === [] && $rows !== []) {
            $firstRow = $rows[0];
            $fieldNames = array_map('strval', array_keys($firstRow));
            $this->defineGroup($groupName, $fieldNames);
        }
        foreach ($rows as $i => $row) {
            $unknown = array_diff(array_map('strval', array_keys($row)), $fieldNames);
            if ($unknown !== []) {
                throw new \InvalidArgumentException(sprintf(
                    'Unknown keys in row %d of group "%s": %s',
                    $i,
                    $groupName,
                    implode(', ', $unknown)
                ));
            }
        }
        foreach ($fieldNames as $fd) {
            $col = [];
            foreach ($rows as $row) {
                $col[] = $row[$fd] ?? '';
            }
            $this->setField($fd, $col);
        }
    }

    /**
     * グループに 1 行追加する。
     *
     * グループ未宣言なら $row のキー順で自動的に宣言する。
     * $row に宣言外のキーが含まれていれば InvalidArgumentException。
     *
     * @param array<string, mixed> $row
     */
    public function addGroupRow(string $groupName, array $row): void
    {
        $fieldNames = $this->listGroupFields($groupName);
        if ($fieldNames === []) {
            $fieldNames = array_map('strval', array_keys($row));
            $this->defineGroup($groupName, $fieldNames);
        }
        $unknown = array_diff(array_map('strval', array_keys($row)), $fieldNames);
        if ($unknown !== []) {
            throw new \InvalidArgumentException(sprintf(
                'Unknown keys for group "%s": %s',
                $groupName,
                implode(', ', $unknown)
            ));
        }
        foreach ($fieldNames as $fd) {
            $this->addField($fd, $row[$fd] ?? '');
        }
    }

    /**
     * グループの指定インデックスの行を $row で置き換える。
     *
     * インデックスは getGroupRows() のビュー（最大長に揃えた行配列）に対応する。
     * addGroupRow() と同じ全置換セマンティクスで、$row に含まれない宣言フィールドは
     * 空文字で更新される（部分更新ではない）。宣言外のキーが含まれていれば
     * InvalidArgumentException、範囲外のインデックスなら OutOfBoundsException。
     *
     * @param array<string, mixed> $row
     */
    public function updateGroupRow(string $groupName, int $index, array $row): void
    {
        $rows = $this->getGroupRows($groupName);
        if (!array_key_exists($index, $rows)) {
            throw new \OutOfBoundsException(sprintf(
                'Row %d does not exist in group "%s".',
                $index,
                $groupName
            ));
        }
        $fieldNames = $this->listGroupFields($groupName);
        $unknown = array_diff(array_map('strval', array_keys($row)), $fieldNames);
        if ($unknown !== []) {
            throw new \InvalidArgumentException(sprintf(
                'Unknown keys for group "%s": %s',
                $groupName,
                implode(', ', $unknown)
            ));
        }
        $newRow = [];
        foreach ($fieldNames as $fd) {
            $newRow[$fd] = $row[$fd] ?? '';
        }
        $rows[$index] = $newRow;
        $this->setGroupRows($groupName, $rows);
    }

    /**
     * グループの指定インデックスの行を削除し、後続行を前へ詰める。
     *
     * インデックスは getGroupRows() のビュー（最大長に揃えた行配列）に対応する。
     * 範囲外のインデックスなら OutOfBoundsException。グループ宣言（@$groupName）自体は
     * 残し、行を全削除した場合は各宣言フィールドが空になる。
     */
    public function deleteGroupRow(string $groupName, int $index): void
    {
        $rows = $this->getGroupRows($groupName);
        if (!array_key_exists($index, $rows)) {
            throw new \OutOfBoundsException(sprintf(
                'Row %d does not exist in group "%s".',
                $index,
                $groupName
            ));
        }
        unset($rows[$index]);
        $this->setGroupRows($groupName, array_values($rows));
    }

    /**
     * グループと、それに属する全フィールドを削除する。
     * 宣言外のフィールドには触らない。
     */
    public function deleteGroup(string $groupName): void
    {
        $fieldNames = $this->listGroupFields($groupName);
        foreach ($fieldNames as $fd) {
            $this->deleteField($fd);
        }
        $this->deleteField('@' . $groupName);
    }

    /**
     * Field の内部状態を、扱いやすい構造化配列に変換する。
     *
     * テンプレート描画・V2 API レスポンス・デバッグ表示など、人間が読み書きする用途で
     * 使う。永続化やラウンドトリップ復元には向かないため、その用途では toArray() /
     * fromArray() を使うこと。
     *
     *  - 単一値フィールド          : ['value' => x, 'array' => [...]]
     *  - マルチ項目フィールド      : ['value' => ['sub' => x, ...], 'array' => [['sub' => x, ...], ...]]
     *      （baseName@subKey 形式が同じ baseName でまとまる）
     *  - フィールドグループ        : ['groupName' => [0 => [...], 1 => [...]]]
     *      （@groupName 宣言の値で zip した行配列）
     *
     * メディア（@media）・@html・block-editor 等のテンプレート出力固有の前処理は
     * 行わない（FieldTrait 側に残す責務）。URL 解決や HTML 絶対化も同様。
     *
     * 出力形式は Acms\Traits\Utilities\FieldTrait::buildFieldTrait と互換性がある。
     *
     * @param bool $autoConvert 既定で有効。"true"/"false" → bool、数値 → int/float（先頭 0 は文字列保持）。false にすると生の文字列のまま返す
     * @param bool $withValidation Field_Validation のとき各エントリに 'invalid' / 'v' キーを上乗せ
     * @param bool $recursive 子フィールドを toStructured した結果を合流させる
     * @return array<string, mixed>
     */
    public function toStructured(
        bool $autoConvert = true,
        bool $withValidation = false,
        bool $recursive = true,
    ): array {
        $fieldKeys = ($this instanceof Field_Validation)
            ? $this->listFields(true)
            : $this->listFields();

        // グループ宣言キーごとに、属するフィールド（直接の単一値 + baseName@subKey の subKey 形式）を集める
        $groupAssignments = [];
        foreach ($fieldKeys as $key) {
            if (!self::isGroupKey($key)) {
                continue;
            }
            $groupName = substr($key, 1);
            $items = $this->listGroupFields($groupName);
            $belongs = $items;
            foreach ($fieldKeys as $candidate) {
                $parsed = self::parseMultiFieldKey($candidate);
                if ($parsed !== null && in_array($parsed[0], $items, true)) {
                    $belongs[] = $candidate;
                }
            }
            $groupAssignments[$groupName] = $belongs;
        }
        // どのグループにも属さないキーだけを残す
        $allBelong = [];
        foreach ($groupAssignments as $belongs) {
            $allBelong = array_merge($allBelong, $belongs);
        }
        $nonGroupKeys = [];
        foreach ($fieldKeys as $key) {
            if (self::isGroupKey($key) && isset($groupAssignments[substr($key, 1)])) {
                continue;
            }
            if (in_array($key, $allBelong, true)) {
                continue;
            }
            $nonGroupKeys[] = $key;
        }

        [$items, $multiGroups] = self::partitionFieldKeys($nonGroupKeys);

        $result = [];
        // 単一値フィールド
        foreach ($items as $key) {
            $result[$key] = $this->buildSingleEntry($key, $autoConvert, $withValidation);
        }
        // マルチ項目フィールド（baseName@subKey 形式）。同名の単一値があれば上書きする
        foreach ($multiGroups as $baseName => $subKeys) {
            unset($result[$baseName]);
            $result[$baseName] = $this->buildMultiEntry($baseName, $subKeys, $autoConvert, $withValidation);
        }
        // フィールドグループ
        foreach ($groupAssignments as $groupName => $belongs) {
            $result[$groupName] = $this->buildGroupRows($belongs, $autoConvert, $withValidation);
        }

        // 子フィールド再帰
        if ($recursive) {
            foreach ($this->listChildren() as $childName) {
                $child = $this->getChild($childName);
                $result += $child->toStructured($autoConvert, $withValidation, $recursive);
            }
        }

        return $result;
    }

    /**
     * 単一値フィールドのエントリを組み立てる。
     *
     * @return array<string, mixed>
     */
    private function buildSingleEntry(string $key, bool $autoConvert, bool $withValidation): array
    {
        $values = $this->getArray($key);
        $data = $autoConvert ? array_map([self::class, 'convertValueType'], $values) : $values;
        $entry = [
            'value' => $data[0] ?? null,
            'array' => $data,
        ];
        if ($withValidation && $this instanceof Field_Validation) {
            $entry['invalid'] = !$this->isValid($key);
            foreach ($this->getMethods($key) as $method) {
                $entry['v'][$method] = !$this->isValid($key, $method);
            }
        }
        return $entry;
    }

    /**
     * マルチ項目フィールド（baseName@subKey 形式）のエントリを組み立てる。
     *
     * @param string[] $subKeys
     * @return array<string, mixed>
     */
    private function buildMultiEntry(string $baseName, array $subKeys, bool $autoConvert, bool $withValidation): array
    {
        $value = [];
        $array = [];
        foreach ($subKeys as $subKey) {
            $values = $this->getArray($baseName . '@' . $subKey);
            $data = $autoConvert ? array_map([self::class, 'convertValueType'], $values) : $values;
            $value[$subKey] = $data[0] ?? null;
            if ($data === []) {
                $array[0][$subKey] = null;
            } else {
                foreach ($data as $i => $val) {
                    $array[$i][$subKey] = $val;
                }
            }
        }
        $entry = [
            'value' => $value,
            'array' => $array,
        ];
        if ($withValidation && $this instanceof Field_Validation) {
            $entry['invalid'] = !$this->isValid($baseName);
            foreach ($this->getMethods($baseName) as $method) {
                $entry['v'][$method] = !$this->isValid($baseName, $method);
            }
        }
        return $entry;
    }

    /**
     * グループに属するキー群から行配列を組み立てる。
     *
     * @param string[] $belongs
     * @return array<int, array<string, mixed>>
     */
    private function buildGroupRows(array $belongs, bool $autoConvert, bool $withValidation): array
    {
        [$groupItems, $groupMultiGroups] = self::partitionFieldKeys($belongs);
        $rows = [];
        foreach ($groupItems as $key) {
            $values = $this->getArray($key);
            $data = $autoConvert ? array_map([self::class, 'convertValueType'], $values) : $values;
            foreach ($data as $i => $val) {
                $entry = ['value' => $val];
                if ($withValidation && $this instanceof Field_Validation) {
                    $entry['invalid'] = !$this->isValid($key, null, $i);
                    foreach ($this->getMethods($key) as $method) {
                        $entry['v'][$method] = !$this->isValid($key, $method, $i);
                    }
                }
                $rows[$i][$key] = $entry;
            }
        }
        foreach ($groupMultiGroups as $baseName => $subKeys) {
            foreach ($subKeys as $subKey) {
                $values = $this->getArray($baseName . '@' . $subKey);
                $data = $autoConvert ? array_map([self::class, 'convertValueType'], $values) : $values;
                foreach ($data as $i => $val) {
                    $rows[$i][$baseName][$subKey] = $val;
                    if ($withValidation && $this instanceof Field_Validation) {
                        $rows[$i][$baseName]['invalid'] = !$this->isValid($baseName, null, $i);
                        foreach ($this->getMethods($subKey) as $method) {
                            $rows[$i][$baseName]['v'][$method] = !$this->isValid($baseName, $method, $i);
                        }
                    }
                }
            }
        }
        return $rows;
    }

    /**
     * フィールドキーのリストを「単一値」「マルチ項目（baseName 別）」に分類する。
     *
     * @param string[] $keys
     * @return array{0: string[], 1: array<string, string[]>}
     */
    private static function partitionFieldKeys(array $keys): array
    {
        $items = [];
        $multiGroups = [];
        foreach ($keys as $key) {
            $parsed = self::parseMultiFieldKey($key);
            if ($parsed !== null) {
                [$baseName, $subKey] = $parsed;
                $multiGroups[$baseName][] = $subKey;
            } else {
                $items[] = $key;
            }
        }
        return [$items, $multiGroups];
    }

    /**
     * Field の内部状態を「シリアライゼーション用」の構造化配列にダンプする。
     *
     * fromArray() でビット等価に復元できる形式。永続化・転送・JSON 化など、
     * Field をそのまま「保存して後から復元したい」用途で使う。人間可読な表示用には
     * toStructured() を使うこと。
     *
     *  - class    : ダンプ元のクラス名（fromArray() のディスパッチに使う）
     *  - fields   : _aryField の生の値（@groupName キーや baseName@subKey 形式の生キーを保持）
     *  - children : 子フィールドを再帰的にダンプ（class キーで型情報も保持）
     *  - meta     : _aryMeta の値
     *
     * @return array<string, mixed>
     */
    public function toArray(): array
    {
        $children = [];
        foreach ($this->_aryChild as $name => $child) {
            $children[$name] = $child->toArray();
        }
        return [
            'class' => static::class,
            'fields' => $this->_aryField,
            'children' => $children,
            'meta' => $this->_aryMeta,
        ];
    }

    /**
     * toArray() の逆変換。Field::fromArray() は class キーで復元先クラスを選ぶ。
     * サブクラスの fromArray() は呼び出されたクラスでインスタンスを生成し、内部状態を復元する。
     *
     * @param array<string, mixed> $data
     * @return static
     */
    public static function fromArray(array $data): static
    {
        if (static::class === self::class) {
            $class = self::resolveClassFromArray($data);
            if ($class !== self::class) {
                /** @var static $field */
                $field = $class::fromArray($data);
                return $field;
            }
        }
        return static::restoreFromArray($data);
    }

    /**
     * fromArray() の復元処理本体。class キーによるディスパッチは行わない。
     *
     * @param array<string, mixed> $data
     * @return static
     */
    protected static function restoreFromArray(array $data): static
    {
        $field = new static();
        $field->_aryField = is_array($data['fields'] ?? null) ? $data['fields'] : [];
        $field->_aryMeta = is_array($data['meta'] ?? null) ? $data['meta'] : [];
        $children = is_array($data['children'] ?? null) ? $data['children'] : [];
        foreach ($children as $name => $childData) {
            if (!is_string($name) || !is_array($childData)) {
                continue;
            }
            // addChild() が参照渡し（=&）で保持する仕様のため、ループ変数 $child を
            // 使い回すと前のイテレーションで保存した参照が次の値で上書きされてしまう。
            // 各イテレーションでスコープを切るため、変数を unset() してから再代入する。
            unset($child);
            $child = Field::fromArray($childData);
            $field->addChild($name, $child);
        }
        return $field;
    }

    /**
     * 配列内の 'class' キーから復元先の Field 系クラスを解決する。
     * class キー未指定なら Field として復元。
     *
     * @param array<string, mixed> $data
     * @return class-string<self>
     */
    private static function resolveClassFromArray(array $data): string
    {
        $class = $data['class'] ?? self::class;
        if (!is_string($class) || !class_exists($class) || !is_a($class, self::class, true)) {
            throw new \InvalidArgumentException(sprintf(
                'Invalid Field class in array data: %s',
                is_string($class) ? $class : gettype($class)
            ));
        }
        /** @var class-string<self> $class */
        return $class;
    }
}

class Field_Search extends Field
{
    /**
     * @var array<string, array<'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'lk' | 'nlk' | 're' | 'nre' | 'em' | 'nem' | null>>
     */
    public $_aryOperator = [];

    /**
     * @var array<string, array<'and' | 'or' | null>>
     */
    public $_aryConnector = [];

    /**
     * @var array<string, 'and'|'or'>
     */
    public $_arySeparator = [];

    /**
     * @inheritDoc
     */
    public function overload($Field, $isDeep = false)
    {
        if (!is_null($Field)) {
            parent::overload($Field, $isDeep);
            if ($Field instanceof Field_Search) {
                $this->_aryOperator     = $Field->_aryOperator;
                $this->_aryConnector    = $Field->_aryConnector;
                $this->_arySeparator    = $Field->_arySeparator;
            }
        }
    }

    /**
     * フィールド検索条件のクエリ文字列をパースする。
     *
     * クエリ書式は a-blog cms 独自の URL パス形式で、スラッシュ区切りで
     * フィールド名・connector・operator・value を順に並べる:
     *
     *   field/op/value                      ... 単一値（op 省略時は eq）
     *   field/op/value1/op/value2           ... 同一フィールドの連続値（connector は and）
     *   field/or/op/v1/or/op/v2             ... 同一フィールド内 OR 結合
     *   fieldA/v1/_and_/fieldB/v2           ... フィールド間 AND（_and_ セパレーター）
     *   fieldA/v1/_or_/fieldB/v2            ... フィールド間 OR
     *   fieldA/v1/and/fieldB/v2             ... 裸の and もフィールド間 AND として後方互換で受ける
     *
     * 重要な決まりごと:
     * - `and` トークンは parse では常に separator として扱う（connector ではない）。
     *   serialize 側で connector='and' を出力しない仕様（後述）と対になっており、
     *   出力に裸の and が現れない以上、入力で見た and は separator しかありえない。
     *   同一フィールド内の連続値は connector='and' を「省略形」として扱い、
     *   `field/op/v1/op/v2` のように and を書かずに表現する。
     * - 連続スラッシュ `//` は「演算子のみ・値なし」を意味する空トークンとして残し、
     *   末尾の `/` だけは無視する。
     */
    public function parse($query)
    {
        $tokens = preg_split('@(?<!\\\\)/@', $query);
        if ($tokens === false) {
            return;
        }

        // 末尾の空トークンは無視する（`price/200/300/` のような末尾スラッシュ対策）。
        // 途中の空トークン（連続 `//`）は値なし演算子の表現として保持する。
        while (count($tokens) > 0 && end($tokens) === '') {
            array_pop($tokens);
        }

        // 状態機械の 5 変数:
        //   field        ... 現在処理中のフィールド名（null = 次トークンをフィールド名として読む）
        //   connector    ... 値の結合演算子 'and' | 'or'（null = 未確定）
        //   operator     ... 比較演算子 'eq' | 'neq' | ...（null = 未確定）
        //   value        ... 値（null = 未確定、em/nem では '' に確定）
        //   tmpSeparator ... 直前に出現した separator を次フィールドに引き継ぐためのバッファ
        $field          = null;
        $connector      = null;
        $operator       = null;
        $value          = null;
        $tmpSeparator   = null;

        while (null !== ($token = array_shift($tokens))) {
            //-----------------------------------------------------------------
            // [1] フィールド名トークン
            //   field が null のとき、次のトークンは必ずフィールド名として読む。
            //   直前の separator（tmpSeparator）があればそれを採用し、
            //   なければデフォルトで 'and' separator を割り当てる。
            //-----------------------------------------------------------------
            if (is_null($field)) {
                $field      = $token;

                if (in_array($tmpSeparator, ['or', 'and'], true)) {
                    $this->addSeparator($field, $tmpSeparator);
                } else {
                    $this->addSeparator($field, 'and');
                }

                continue;
            }

            //-----------------------------------------------------------------
            // [2] 空トークン `//` のハンドリング
            //   connector が既に確定済みで operator がまだ未確定なら、
            //   eq とみなして「値なし eq フィルター」を作る（serialize の往復で
            //   `or//` のような表現が空 eq として復元されるようにするため）。
            //-----------------------------------------------------------------
            if ('' === $token) {
                if (!is_null($connector) && is_null($operator)) {
                    $operator   = 'eq';
                }
            }

            //-----------------------------------------------------------------
            // [3] operator トークンの判定
            //   fd/op/...      … connector 省略形（後段で 'and' を補う）
            //   fd/or/op/...   … connector 明示後の operator
            //-----------------------------------------------------------------
            if (is_null($operator)) {
                switch ($token) {
                    case 'eq':
                        // eq + 値 のときは「等値の列挙」を意味し、暗黙に OR で結ぶ仕様。
                        // 旧仕様との互換のため eq が現れた瞬間に connector を or に強制する。
                        $operator   = $token;
                        $connector  = 'or';
                        break;
                    case 'neq':
                    case 'lt':
                    case 'lte':
                    case 'gt':
                    case 'gte':
                    case 'lk':
                    case 'nlk':
                    case 're':
                    case 'nre':
                        $operator   = $token;
                        break;
                    case 'em':
                    case 'nem':
                        // em/nem は「値が空 / 空でない」を判定するため value を '' に固定する。
                        // 次のトークンを value として消費しないので、ここで value を確定させる。
                        $operator   = $token;
                        $value      = '';
                        break;
                }

                if (!is_null($operator)) {
                    // operator が確定したら connector のデフォルトを 'and' にする
                    // （eq の場合は上で既に 'or' になっているのでここでは上書きされない）。
                    if (is_null($connector)) {
                        $connector  = 'and';
                    }
                    // em/nem 以外は次トークンが value。それ以外（em/nem）は value 確定済みなので、
                    // 後続の値受け取りステップは飛ばして次トークンへ進む。
                    if (is_null($value)) {
                        continue;
                    }
                }
            }

            //-----------------------------------------------------------------
            // [4] connector トークンの判定
            //   ここで認識するのは `or` のみ。`and` は separator として後段で処理する。
            //   どちらでもないトークンが来た場合は「connector 省略の eq 値」と解釈し、
            //   connector='or' / operator='eq' / value=token をその場で確定させる。
            //-----------------------------------------------------------------
            if (is_null($connector)) {
                if ('or' === $token) {
                    $connector  = $token;
                    continue;
                } else {
                    // fd/val 形式（operator も connector も省略）
                    $connector  = 'or';
                    $operator   = 'eq';
                    $value      = $token;
                }
            }

            //-----------------------------------------------------------------
            // [5] value トークン と separator の振り分け
            //   value が未確定: 現トークンを value として確定。
            //   value 確定済み: 現トークンが and / _and_ / _or_ ならフィールド境界。
            //                   それ以外は通常 add に進む（値の重複追加など）。
            //-----------------------------------------------------------------
            if (is_null($value)) {
                if (is_null($operator)) {
                    $operator   = 'eq';
                }
                $value  = $token;
            } elseif (in_array($token, ['and', '_and_', '_or_'], true)) {
                // separator を検出: 次フィールドへの切り替えを準備する。
                // `_or_` だけ OR、それ以外は AND。状態変数をリセットして field を null に戻す。
                if ($token == '_or_') {
                    $tmpSeparator = 'or';
                } else {
                    $tmpSeparator = 'and';
                }

                $field      = null;
                $connector  = null;
                $operator   = null;
                $value      = null;

                continue;
            }

            $this->add($field, $value);
            $this->addOperator($field, $operator);
            $this->addConnector($field, $connector);

            $connector  = null;
            $operator   = null;
            $value      = null;
        }
    }

    /**
     * 指定したフィールド名のフィールドに対する結合子を追加する
     * @param string $fd
     * @param 'and' | 'or' $connector
     */
    public function addConnector($fd, $connector)
    {
        $this->_aryConnector[$fd][] = $connector;
    }

    /**
     * 指定したフィールド名のフィールドに対する演算子を追加する
     * @param string $fd
     * @param 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'lk' | 'nlk' | 're' | 'nre' | 'em' | 'nem' | null $operator
     */
    public function addOperator($fd, $operator)
    {
        $this->_aryOperator[$fd][] = $operator;
    }


    /**
     * 指定したフィールド名のフィールドに対する論理演算子を追加する
     * @param string $fd
     * @param 'and' | 'or' $separator
     */
    public function addSeparator($fd, $separator)
    {
        $this->_arySeparator[$fd] = $separator;
    }

    /**
     * 指定したフィールド名のフィールドに対する結合子を設定する
     * @param string $fd
     * @param 'and' | 'or' | null $connector
     */
    public function setConnector($fd, $connector = null)
    {
        if (is_null($connector)) {
            $this->_aryConnector[$fd] = [];
        } else {
            $this->_aryConnector[$fd] = [$connector];
        }
    }

    /**
     * 指定したフィールド名のフィールドに対する演算子を設定する
     * @param string $fd
     * @param 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'lk' | 'nlk' | 're' | 'nre' | 'em' | 'nem' | null $operator
     */
    public function setOperator($fd, $operator = null)
    {
        if (is_null($operator)) {
            $this->_aryOperator[$fd] = [];
        } else {
            $this->_aryOperator[$fd] = [$operator];
        }
    }

    /**
     * 指定したフィールド名のフィールドに対する結合子を取得する
     * 第2引数を指定しない場合は、フィールドに対する結合子の配列を返す
     *
     * @template T of int|null
     * @param string $fd フィールド名
     * @param T $i 結合子の指定 (省略可能)
     * @return (T is null ?
     *     array<'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'lk' | 'nlk' | 're' | 'nre' | 'em' | 'nem' | null> :
     *     'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'lk' | 'nlk' | 're' | 'nre' | 'em' | 'nem' | null
     * )
     */
    public function getOperator($fd, $i = 0)
    {
        return is_null($i) ?
            (!is_null($this->_aryOperator[$fd]) ? $this->_aryOperator[$fd] : null) :
            (isset($this->_aryOperator[$fd][$i]) ? $this->_aryOperator[$fd][$i] : null);
    }

    /**
     * 指定したフィールド名のフィールドに対する演算子を取得する
     * 第2引数を指定しない場合は、フィールドに対する演算子の配列を返す
     * @template T of int|null
     * @param string $fd
     * @param T $i
     * @return (T is null ? array<'and' | 'or' | null> : 'and' | 'or' | null)
     */
    public function getConnector($fd, $i = 0)
    {
        return is_null($i) ?
            (!is_null($this->_aryConnector[$fd]) ? $this->_aryConnector[$fd] : null) :
            (isset($this->_aryConnector[$fd][$i]) ? $this->_aryConnector[$fd][$i] : null);
    }

    /**
     * 指定したフィールド名のフィールドに対する論理演算子を取得する
     * @param string $fd
     * @return 'and' | 'or'
     */
    public function getSeparator($fd)
    {
        return isset($this->_arySeparator[$fd]) ? $this->_arySeparator[$fd] : 'and';
    }

    /**
     * @inheritDoc
     */
    public function delete($fd)
    {
        if (!is_string($fd)) {
            return false;
        }
        parent::delete($fd);
        unset($this->_aryOperator[$fd]);
        unset($this->_aryConnector[$fd]);
        unset($this->_arySeparator[$fd]);

        return true;
    }

    /**
     * フィールド検索条件を URL パス形式の文字列にシリアライズする。
     *
     * 出力上のルール:
     * - connector='and' は出力に現れない（同名フィールドの暗黙のデフォルトとして扱う）。
     *   裸の `and` を出すと parse 側が separator として解釈してしまうため、必ず省略する。
     * - connector='or' は明示的に出力する。
     * - operator='eq' は connector='or' と組み合わさったときに限り省略する
     *   （parse 側で eq は connector を or に強制する仕様の裏返し）。
     * - em / nem は値を持たないので value トークンを出さない。
     * - 空値を持つ連続フィルター（`or//or/op/val` のような表現）に備えて、
     *   空値の出現回数をカウント保留し、後続に値ありフィルターが現れた時点でまとめて出力する。
     * - フィールド境界は `_and_` / `_or_` で出力し、先頭フィールドにつくものだけ後段で除去する。
     */
    public function serialize()
    {
        $aryQuery   = [];

        foreach ($this->listFields() as $fd) {
            $aryValue       = $this->getArray($fd);
            $aryOperator    = $this->getOperator($fd, null);
            $aryConnector   = $this->getConnector($fd, null);
            $separator      = $this->getSeparator($fd);

            // 値・演算子・結合子のいずれも空ならこのフィールドは出力対象外
            if (!($cnt = max(count($aryValue), count($aryOperator), count($aryConnector)))) {
                continue;
            }

            // $empty: 出力を保留している空値の個数。後段で値ありが出たときに合流させる。
            // $buf  : このフィールド分のトークン列バッファ。
            $empty  = 0;
            $buf    = [];

            for ($i = 0; $i < $cnt; $i++) {
                $value      = isset($aryValue[$i]) ? $aryValue[$i] : '';
                $connector  = isset($aryConnector[$i]) ? $aryConnector[$i] : '';
                $operator   = isset($aryOperator[$i]) ? $aryOperator[$i] : '';

                switch ($operator) {
                    case 'eq':
                    case 'neq':
                    case 'lt':
                    case 'lte':
                    case 'gt':
                    case 'gte':
                    case 'lk':
                    case 'nlk':
                    case 're':
                    case 'nre':
                        if ('' !== $value) {
                            // 保留していた空値を先にフラッシュ（連続スラッシュで往復再現できるように）
                            for ($j = 0; $j < $empty; $j++) {
                                $buf[]  = '';
                            }
                            $empty  = 0;

                            if ('or' == $connector) {
                                // eq は or との組合せのときだけ省略する（parse 側の eq→or 強制と対）
                                if ('eq' != $operator) {
                                    $buf[]  = 'or';
                                    $buf[]  = $operator;
                                }
                                $buf[]  = $value;
                            } else {
                                // connector='and' は出さない。operator と value のみ。
                                $buf[]  = $operator;
                                $buf[]  = $value;
                            }
                            break;
                        } else {
                            // 値が空文字: いったん保留に積む。後続に値ありフィルターが
                            // 来れば '' トークンとしてフラッシュ、来なければ捨てる。
                            $empty++;
                        }
                        break;
                    case 'em':
                    case 'nem':
                        // em/nem は値なしでも必ず出力する。保留空値を先にフラッシュしてから
                        // connector='or' のときだけ 'or' を前置し、最後に演算子トークンを置く。
                        for ($j = 0; $j < $empty; $j++) {
                            $buf[]  = '';
                        }
                        $empty  = 0;
                        if ('or' == $connector) {
                            $buf[]  = 'or';
                        }
                        $buf[]  = $operator;
                        break;
                    default:
                        // 未知の演算子: 何も書かず空トークンとして残す（防御的）
                        $buf[]  = '';
                }
            }

            // フィールド分のトークン列が 1 つ以上できたら、separator + フィールド名を頭に付けて aryQuery に連結。
            // 先頭フィールドにも separator が必ず付くが、ループ外で先頭分を後から削る。
            $aryTmp = [];
            if (!empty($buf)) {
                if ($separator === 'or') {
                    $aryTmp[] = '_or_';
                } else {
                    $aryTmp[] = '_and_';
                }
                $aryTmp[] = $fd;
                foreach ($buf as $token) {
                    $aryTmp[] = $token;
                }

                $buf    = [];
                $aryQuery = array_merge($aryQuery, $aryTmp);
            }
        }
        // 先頭フィールドの separator は不要（前段がないため）。`_or_` / `_and_` / `and` のいずれかなら除去。
        if (!empty($aryQuery) && in_array($aryQuery[0], ['_or_', '_and_', 'and'], true)) {
            array_shift($aryQuery);
        }

        return join('/', $aryQuery);
    }

    /**
     * 指定したフィールドが em 演算子を持つかどうかを判定する
     * @param string $fd
     * @return bool
     */
    public function hasEmptyOperator(string $fd): bool
    {
        return in_array('em', $this->_aryOperator[$fd], true);
    }

    /**
     * @inheritDoc
     */
    public function cloneWith(array $fieldNames)
    {
        $field = parent::cloneWith($fieldNames);
        foreach ($fieldNames as $fieldName) {
            $operators = $this->_aryOperator[$fieldName] ?? [];
            $connectors = $this->_aryConnector[$fieldName] ?? [];
            $separator = $this->_arySeparator[$fieldName] ?? 'and';
            $field->setOperator($fieldName);
            foreach ($operators as $operator) {
                $field->addOperator($fieldName, $operator);
            }
            $field->setConnector($fieldName);
            foreach ($connectors as $connector) {
                $field->addConnector($fieldName, $connector);
            }
            $field->addSeparator($fieldName, $separator);
        }
        return $field;
    }

    /**
     * POST データ（extract('field') の返り値など）から Field_Search を生成するファクトリーメソッド。
     * field[field][]=key, field[key][]=value, field[key@operator][]=op,
     * field[key@connector][]=con, field[key@separator]=sep の形式に対応。
     *
     * @param Field $post
     * @return self
     */
    public static function fromPost(Field $post): self
    {
        /** @var list<'eq'|'neq'|'gt'|'gte'|'lt'|'lte'|'lk'|'nlk'|'re'|'nre'|'em'|'nem'> */
        static $allowedOperators = ['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'lk', 'nlk', 're', 'nre', 'em', 'nem'];

        $fieldSearch = new self();

        $keys = $post->getArray('field');
        foreach ($keys as $rawKey) {
            $key = trim((string) $rawKey);
            if ($key === '') {
                continue;
            }

            $aryValue     = $post->getArray($key);
            $aryConnector = $post->getArray($key . '@connector');
            $aryOperator  = $post->getArray($key . '@operator');
            $separator    = $post->get($key . '@separator', 'and') === 'or' ? 'or' : 'and';

            $cnt = max(count($aryValue), count($aryConnector), count($aryOperator));

            // 値が一件もない場合はフィールドだけ登録して終了（legacyBuildFieldSearch と同一挙動）
            if ($cnt <= 0) {
                $fieldSearch->set($key);
                $fieldSearch->setConnector($key);
                $fieldSearch->setOperator($key);
                continue;
            }

            $defaultConnector = (count($aryConnector) === 0 && count($aryOperator) === 0) ? 'or' : 'and';
            if (count($aryConnector) > 0) {
                $defaultConnector = $aryConnector[0];
            }
            $defaultOperator = 'eq';
            if (count($aryOperator) > 0 && in_array($aryOperator[0], $allowedOperators, true)) {
                $defaultOperator = $aryOperator[0];
            }

            // 値を先に収集し、空値かつ em/nem 以外の行を除外する。
            // Filter._field() の else 分岐が空 WHERE を外側クエリに直接追記するバグを防ぐため。
            $rows = [];
            for ($i = 0; $i < $cnt; $i++) {
                $val         = $aryValue[$i] ?? '';
                $connector   = $aryConnector[$i] ?? $defaultConnector;
                $operatorRaw = $aryOperator[$i] ?? $defaultOperator;
                $operator    = in_array($operatorRaw, $allowedOperators, true) ? $operatorRaw : 'eq';

                if ($val === '' && $operator !== 'em' && $operator !== 'nem') {
                    continue;
                }
                $rows[] = [$val, $connector, $operator];
            }

            // 有効な行がゼロ（全値が空文字の eq/neq 等）なら field ごとスキップ
            if ($rows === []) {
                continue;
            }

            $fieldSearch->set($key);
            $fieldSearch->setConnector($key);
            $fieldSearch->setOperator($key);
            $fieldSearch->addSeparator($key, $separator);

            foreach ($rows as [$val, $connector, $operator]) {
                $fieldSearch->add($key, $val);
                $fieldSearch->addConnector($key, $connector);
                $fieldSearch->addOperator($key, $operator);
            }
        }

        return $fieldSearch;
    }

    /**
     * フィールド検索条件を人間が読みやすい文字列（スナップショット）に変換する。
     *
     * 出力例（各行が1つのフィールドキーに対応する）:
     *   pref: 東京都 OR 大阪府
     *   gender: 男性
     *   age: >= 18 AND < 65
     *
     * ─ 同一フィールド内の複数値（コネクター） ─
     * _aryConnector[$key] に従い AND / OR で連結する（省略時は AND）。
     * 上記の例では "pref" の値 "東京都" と "大阪府" は OR で結合されている。
     *
     * ─ 異なるフィールドキー間（セパレーター） ─
     * _arySeparator[$key] に AND / OR が設定される。2行目以降の行頭に
     * セパレーターをプレフィックスとして付与する（最初のフィールドは先行条件がないため省略）。
     *
     *   pref: 東京都 OR 大阪府
     *   AND gender: 男性
     *   OR age: >= 18 AND < 65
     *
     * セパレーターには以下の制約がある:
     * - フィールドの並び順は AND 条件を OR 条件より前にしなければならない（AND → OR の順序のみ許可）
     * - em（空）演算子は OR セパレーターのフィールドでは使用できない
     *
     * 演算子の出力形式:
     * - eq（等値一致）はラベルなしで値のみ出力する
     * - em / nem は値の代わりに [空] / [非空] を出力する
     * - その他の演算子は値の前にラベル（"!= ", "> ", "<= " 等）を付与する
     *
     * @return string 改行区切りの条件文字列（条件がない場合は空文字列）
     */
    public function toSnapshot(): string
    {
        $opLabels = [
            'neq' => '!= ', 'gt'  => '> ',  'gte' => '>= ',
            'lt'  => '< ',  'lte' => '<= ', 'lk'  => '[lk] ',
            'nlk' => '[nlk] ', 're' => '[re] ', 'nre' => '[nre] ',
        ];
        $lines   = [];
        $isFirst = true;
        foreach ($this->listFields() as $key) {
            $values     = $this->getArray($key);
            $operators  = $this->_aryOperator[$key] ?? [];
            $connectors = $this->_aryConnector[$key] ?? [];
            $parts      = [];
            // em/nem は空文字の値を持つため getArray が除去してしまう。
            // operators の件数を基準にループし、値は存在しなければ '' で補う。
            for ($i = 0, $count = count($operators); $i < $count; $i++) {
                $value = $values[$i] ?? '';
                $op    = $operators[$i] ?? 'eq';
                $con   = strtoupper($connectors[$i] ?? 'and');
                if ($op === 'em') {
                    $token = '[空]';
                } elseif ($op === 'nem') {
                    $token = '[非空]';
                } else {
                    $token = ($opLabels[$op] ?? '') . $value;
                }
                $parts[] = ($i > 0 ? $con . ' ' : '') . $token;
            }
            if ($parts !== []) {
                $separator = strtoupper($this->_arySeparator[$key] ?? 'and');
                $line      = $key . ': ' . implode(' ', $parts);
                $lines[]   = $isFirst ? $line : $separator . ' ' . $line;
                $isFirst   = false;
            }
        }
        return implode("\n", $lines);
    }

    /**
     * @inheritDoc
     *
     * 追加で operator / connector / separator を含める。
     *
     * @return array<string, mixed>
     */
    public function toArray(): array
    {
        return array_merge(parent::toArray(), [
            'operators'  => $this->_aryOperator,
            'connectors' => $this->_aryConnector,
            'separators' => $this->_arySeparator,
        ]);
    }

    /**
     * @inheritDoc
     *
     * @param array<string, mixed> $data
     * @return static
     */
    public static function fromArray(array $data): static
    {
        $field = parent::fromArray($data);
        $field->_aryOperator  = is_array($data['operators']  ?? null) ? $data['operators']  : [];
        $field->_aryConnector = is_array($data['connectors'] ?? null) ? $data['connectors'] : [];
        $field->_arySeparator = is_array($data['separators'] ?? null) ? $data['separators'] : [];
        return $field;
    }
}

class Field_Validation extends Field
{
    /**
     * @var array<string, array<string, array<int, bool>>>
     */
    public $_aryV = [];

    /**
     * @var array<string, array<string, mixed>>
     */
    public $_aryMethod = [];

    /**
     * @var array<string, string>
     */
    public $_aryGroup = [];

    /**
     * @inheritDoc
     */
    public function overload($Field, $isDeep = false)
    {
        if (!is_null($Field)) {
            parent::overload($Field, $isDeep);
            if ($Field instanceof Field_Validation) {
                $this->_aryV        = $Field->_aryV;
                $this->_aryMethod   = $Field->_aryMethod;
                $this->_aryGroup    = $Field->_aryGroup;
            }
        }
    }

    /**
     * @static
     * @param string $key
     * @param null|Field $Field
     * @return Field_Validation
     */
    public static function & singleton($key, $Field = null)
    {
        static $aryField  = [];

        if (!isset($aryField[$key]) || $Field !== null) {
            $aryField[$key] = new Field_Validation($Field);
        }

        return $aryField[$key];
    }

    /**
     * $validatorがtrueの場合は、バリデーションの値を含めたフィールド名を返す
     *
     * @param bool $validator
     * @return string[]
     */
    public function listFields($validator = false)
    {
        $aryFd  = parent::listFields();
        if (!!$validator) {
            $aryFd = array_unique(array_merge($aryFd, array_keys($this->_aryV)));
        }
        return $aryFd;
    }

    /**
     * @inheritDoc
     */
    public function delete($fd)
    {
        if (!is_string($fd)) {
            return false;
        }
        parent::delete($fd);
        unset($this->_aryV[$fd]);
        unset($this->_aryMethod[$fd]);
        unset($this->_aryGroup[$fd]);

        return true;
    }

    /**
     * バリデーションメソッドを設定する
     * @param string|null $fd
     * @param string|null $name
     * @param mixed $arg
     */
    public function setMethod($fd = null, $name = null, $arg = null)
    {
        if (is_null($fd) || !is_string($fd)) {
            $this->_aryMethod = [];
        } elseif (is_null($name)) {
            $this->_aryMethod[$fd]    = null;
        } else {
            $this->_aryMethod[$fd][$name] = $arg;
        }
    }

    /**
     * 指定したフィールド名のフィールドをフィールドグループに属するフィールドとして設定する
     * @param string|null $fd
     * @param string|null $group
     * @return void
     */
    public function setGroup($fd = null, $group = null)
    {
        if (is_null($fd) || !is_string($fd)) {
            $this->_aryGroup = [];
        } elseif (is_null($group)) {
            $this->_aryGroup[$fd] = null;
        } else {
            $this->_aryGroup[$fd] = $group;
        }
    }

    /**
     * 指定したフィールド名のフィールドがフィールドグループに属するフィールドかどうかを判定する
     * @param string $fd
     * @return bool
     */
    public function isGroup($fd)
    {
        if (isset($this->_aryGroup[$fd]) && !!$this->_aryGroup[$fd]) {
            return true;
        }
        return false;
    }

    /**
     * 指定したフィールド名のフィールドに対するバリデーションメソッドを配列で取得する
     * @param string $fd
     * @return string[]
     */
    public function listMethods($fd)
    {
        if (!is_string($fd)) {
            return [];
        }
        if (!isset($this->_aryV[$fd])) {
            return [];
        }
        return array_keys($this->_aryV[$fd]);
    }

    /**
     * ailas for listMethods
     * @param string $fd
     * @return string[]
     */
    public function getMethods($fd)
    {
        return $this->listMethods($fd);
    }

    /**
     * 指定したフィールド名のフィールドに対するバリデーションメソッドのバリデーション結果を設定する
     * @param string $fd
     * @param string $method
     * @param bool $validation
     * @param int $i
     * @return bool
     */
    public function setValidator($fd, $method = null, $validation = null, $i = 0)
    {
        if (!is_string($fd)) { // @phpstan-ignore-line
            return false;
        }
        $this->_aryV[$fd][$method][$i]  = $validation;
        return true;
    }

    /**
     * バリデーションをリセットする
     *
     * バリデーション結果（_aryV）・登録済みメソッド（_aryMethod）・グループ（_aryGroup）
     * に加え、テンプレート由来の `xxx:validator#yyy` / `xxx:v#yyy` 形式の hidden input
     * フィールドも除去する。これらはバリデーションメソッド引数を運ぶ宣言データであり、
     * 残しておくとレスポンス時のテンプレート置換で誤った値が描画される。
     *
     * @param bool $isDeep 非推奨の引数です。使用しないでください。
     * @return true
     */
    public function reset($isDeep = false)
    {
        $this->_aryV        = [];
        $this->_aryMethod   = [];
        $this->_aryGroup    = [];
        foreach ($this->listFields() as $fieldName) {
            if (preg_match('@:(?:v|validator)#@', $fieldName)) {
                $this->deleteField($fieldName);
            }
        }
        foreach ($this->listChildren() as $child) {
            $Child  = $this->getChild($child);
            $Child->reset($isDeep);
        }
        return true;
    }

    /**
     * 指定したフィールド名のフィールドの指定したバリデーションメソッドによる検証結果を判定する
     * $fdを指定しない場合は、すべてのフィールドに対するバリデーションメソッドによる検証結果を判定する
     * @param string|null $fd
     * @param string|null $method
     * @param int|null $i
     * @return bool
     */
    public function isValid($fd = null, $method = null, $i = null)
    {
        if (is_null($fd)) {
            // フィールド名が指定されていない場合は、すべてのフィールドに対するバリデーションメソッドによる検証結果を判定する
            foreach ($this->_aryV as $fdata) {
                foreach ($fdata as $vdata) {
                    foreach ($vdata as $validation) {
                        if ($validation === false) {
                            return false;
                        }
                    }
                }
            }
            return true;
        }

        if (is_null($method) && !is_null($i)) {
            // メソッド名と指定されていない & インデックスが指定されている場合は、指定したフィールドの指定インデックスに対するすべてのバリデーションメソッドによる検証結果を判定する
            if (isset($this->_aryV[$fd])) {
                foreach ($this->_aryV[$fd] as $vdata) {
                    if (isset($vdata[$i])) {
                        if ($vdata[$i] === false) {
                            return false;
                        };
                    }
                }
            }
            return true;
        }

        if (is_null($method)) {
            // メソッド名が指定されていない場合は、指定したフィールドに対するすべてのバリデーションメソッドによる検証結果を判定する
            if (isset($this->_aryV[$fd])) {
                foreach ($this->_aryV[$fd] as $vdata) {
                    foreach ($vdata as $validation) {
                        if ($validation === false) {
                            return false;
                        }
                    }
                }
            }
            return true;
        }

        if (is_null($i)) {
            // インデックスが指定されていない場合は、指定したフィールドに対する指定したバリデーションメソッドによる検証結果を判定する
            if (isset($this->_aryV[$fd][$method])) {
                foreach ($this->_aryV[$fd][$method] as $validation) {
                    if ($validation === false) {
                        return false;
                    }
                }
            }
            return true;
        }

        if (isset($this->_aryV[$fd][$method][$i])) {
            return $this->_aryV[$fd][$method][$i];
        }

        return true;
    }

    /**
     * 失敗したフィールドと失敗メソッド名の対応を返す。
     *
     * 戻り値はフィールド名キー → 失敗したバリデーションメソッド名のリスト の連想配列。
     * 全 valid なら空配列を返す。1 つでもインデックスが invalid であればそのメソッドは
     * 失敗扱いとして配列に含める (`isValid($fd, $method)` のセマンティクスに揃える)。
     *
     * Twig 設定画面ヘルパや Modules/Get/V2/Form.php::error() のように
     * 「失敗フィールドの集計」を必要とする利用側で共通化するための API。
     *
     * @return array<string, list<string>>
     */
    public function errors(): array
    {
        $errors = [];
        foreach ($this->listFields(true) as $key) {
            if ($this->isValid($key)) {
                continue;
            }
            $methods = [];
            foreach ($this->getMethods($key) as $method) {
                if (!$this->isValid($key, $method)) {
                    $methods[] = $method;
                }
            }
            $errors[$key] = $methods;
        }
        return $errors;
    }

    /**
     * 子フィールドも含めすべてのフィールドに対するバリデーションメソッドによる検証結果を判定する
     * @return bool
     */
    public function isValidAll()
    {
        $res = $this->isValid();
        foreach ($this->listChildren() as $child) {
            $Child  = $this->getChild($child);
            if ($Child instanceof Field_Validation) {
                if (!$Child->isValidAll()) {
                    return false;
                }
            }
        }

        return $res;
    }

    /**
     * すべてのフィールドに対して、バリデーションメソッドによる検証を実行する
     * @param \ACMS_Validator|null $V
     * @return true
     */
    public function validate($V = null)
    {
        $this->_aryV    = [];
        foreach ($this->_aryMethod as $fd => $method) {
            foreach ($method as $name => $arg) {
                if ($aryFd = $this->getArray($fd)) {
                    if (substr($name, 0, 4) == 'all_') {
                        $res = is_callable([$V, $name]) ? $V->$name($aryFd, $arg, $this) : !!$arg; // @phpstan-ignore-line
                        $this->setValidator($fd, $name, $res, 0);
                    } else {
                        foreach ($aryFd as $i => $val) {
                            $res = is_callable([$V, $name]) ? $V->$name($val, $arg, $this) : !!$arg; // @phpstan-ignore-line
                            $this->setValidator($fd, $name, $res, $i);
                        }
                    }
                } elseif (!$this->isGroup($fd)) {
                    $value = substr($name, 0, 4) === 'all_' ? [] : null;
                    $res = is_callable([$V, $name]) ? $V->$name($value, $arg, $this) : !!$arg; // @phpstan-ignore-line
                    $this->setValidator($fd, $name, $res, 0);
                }
            }
        }
        return true;
    }

    /**
     * @inheritDoc
     */
    public function &dig($scp = 'field')
    {
        $Field =& $this->getChild($scp);

        if (!($Field instanceof Field_Validation)) {
            return parent::dig($scp);
        }

        if ($aryFd = $this->getArray($scp, true)) {
            //-------
            // group
            foreach ($aryFd as $fd) {
                if (preg_match('/^@(.*)$/', $fd, $match) && $match[1]) {
                    $group = $match[1];
                    foreach ($this->getArray($fd) as $item) {
                        $this->setGroup($item, $group);
                        $Field->setGroup($item, $group);
                    }
                }
            }

            //--------
            // fields
            foreach ($aryFd as $fd) {
                //if ( !$this->isExists($fd) ) continue;
                $Field->setField($fd, $this->getArray($fd));
                $this->deleteField($fd);
            }

            //-----------
            // reference
            foreach ($aryFd as $fd) {
                if ('&' !== substr($Field->get($fd), 0, 1)) {
                    continue;
                }
                $_fd    = preg_replace('@^\s*&\s*|\s*;$@', '', $Field->get($fd));
                if ($Field->isNull($_fd)) {
                    continue;
                }
                $Field->setField($fd, $Field->get($_fd));
            }

            //-----------
            // validator
            $aryFdSearch    = $this->listFields();
            foreach ($aryFd as $fd) {
                if (!is_string($fd)) {
                    continue;
                }
                foreach ($aryFdSearch as $search) {
                    if (
                        preg_match(
                            '@^' . preg_quote($fd, '@') . '(?:\:v#|\:validator#)(.+)$@',
                            $search,
                            $match
                        )
                    ) {
                        $Field->setMethod($fd, $match[1], $this->get($match[0]));
                        $this->deleteField($match[0]);
                    }
                }
            }
            $Field->validate();
        }
        $this->deleteField($scp);

        return $Field;
    }

    /**
     * @inheritDoc
     */
    public function cloneWith(array $fieldNames)
    {
        $field = parent::cloneWith($fieldNames);
        foreach ($fieldNames as $fieldName) {
            $validators = $this->_aryV[$fieldName] ?? [];
            $methods = $this->_aryMethod[$fieldName] ?? [];
            $group = $this->_aryGroup[$fieldName] ?? '';
            if ($group !== '') {
                $field->setGroup($fieldName, $group);
            }
            foreach ($validators as $method => $validations) {
                foreach ($validations as $i => $validation) {
                    $field->setValidator($fieldName, $method, $validation, $i);
                }
            }
            $field->setMethod($fieldName);
            foreach ($methods as $method => $arg) {
                $field->setMethod($fieldName, $method, $arg);
            }
        }
        return $field;
    }

    /**
     * @inheritDoc
     *
     * 追加で method / group を含める。
     * _aryV（バリデーション実行結果）は実行時に再計算されるため永続化対象に含めない。
     *
     * @return array<string, mixed>
     */
    public function toArray(): array
    {
        return array_merge(parent::toArray(), [
            'methods' => $this->_aryMethod,
            'groups'  => $this->_aryGroup,
        ]);
    }

    /**
     * @inheritDoc
     *
     * @param array<string, mixed> $data
     * @return static
     */
    public static function fromArray(array $data): static
    {
        $field = parent::fromArray($data);
        $field->_aryMethod = is_array($data['methods'] ?? null) ? $data['methods'] : [];
        $field->_aryGroup  = is_array($data['groups']  ?? null) ? $data['groups']  : [];
        return $field;
    }
}
