<?php

namespace Acms\Services\Blog;

use SQL;
use DB;
use Common;
use Acms\Services\Facades\BlockEditor;
use Symfony\Component\Yaml\Yaml;

/**
 * blog 投入処理の共通ロジック。
 *
 * `Import` (単発 bin → 単一 blog) と `MultiBlogImport` (複数 bin → 複数 blog)
 * の両方で共有される処理を集約する。ID マッピングの採番方式 (`prepareIds`) は
 * 各クラスが個別に実装する。
 *
 * 役割:
 *  - yaml parse / dropData / insertData / generateFulltext / updateBlogConfigSet
 *  - getNewID による旧ID→新IDの解決
 *  - 各テーブル向けの fix 系コールバック (categoryFix / entryFix / columnFix 等)
 *
 * 取り扱う $this 状態:
 *  - $blogId: 投入対象 blog_id
 *  - $userId: 実行者 user_id (entry_last_update_user_id 等で利用)
 *  - $yaml: 入力 yaml をパースした連想配列
 *  - $ids: 旧ID→新IDのマッピング (各テーブル別)
 *  - $errors: 投入中に発生したエラーメッセージ
 *  - $mediaFieldFix: field 投入後に media id を再リンクする情報
 *
 * @property int $blogId
 * @property int $userId
 * @property array $yaml
 * @property array $ids
 * @property array $errors
 * @property array $mediaFieldFix
 */
trait ImportTrait
{
    /**
     * カテゴリーIDを値として保持する config キー。
     * 管理画面のカテゴリー選択UI（js-admin-category-select）に紐づくキーが対象。
     *
     * (PHP 8.1 互換維持のため trait const ではなく static プロパティで保持する)
     *
     * @var array<string>
     */
    private static array $categoryIdConfigKeys = [
        'entry_edit_category_default',
        'entry_edit_category_filter',
        'entry_edit_sub_category_filter',
        'static_page_cid',
        'static_archive_cid',
    ];

    /**
     * メディアIDを値として保持する config キー。
     *
     * @var array<string>
     */
    private static array $mediaIdConfigKeys = [
        'media_banner_mid',
        'navigation_media',
    ];

    /**
     * yaml を実際に投入する。
     *
     * 前提:
     *  - yaml 内の <table>_blog_id は呼び出し側が事前に正しい値に書き換え済み
     *  - 投入先 blog の既存データを破壊リプレースする場合、呼び出し側が事前に dropData() を呼ぶ
     *
     * @param string $yaml 投入する yaml 文字列 (`<table>_blog_id` は確定済)
     * @param int $blogId 投入対象 blog_id (generateFulltext / updateBlogConfigSet で利用)
     * @param int $userId entry_last_update_user_id 等で使う実行者 user_id
     * @return array エラーメッセージの一覧 (空なら成功)
     */
    protected function executeImport(string $yaml, int $blogId, int $userId): array
    {
        $this->errors = [];

        $data = Yaml::parse($yaml);
        if (!is_array($data)) {
            $this->errors[] = 'Invalid YAML format.';
            return $this->errors;
        }
        $this->blogId = $blogId;
        $this->userId = $userId;
        $this->yaml = $data;

        $this->prepareIds();

        $tables = [
            'category', 'form', 'entry', 'tag',
            'entry_sub_category',
            'module', 'layout_grid',
            'rule', 'config', 'column', 'config_set',
            'dashboard', 'field', 'media', 'media_tag',
            'geo',
        ];
        foreach ($tables as $table) {
            $this->insertData($table);
        }
        $this->updateBlogConfigSet();

        Common::flushCache();
        $this->generateFulltext();

        return $this->errors;
    }

    /**
     * `$this->ids` を投入前に整える。
     *
     * 単発 import では yaml 走査により nextval 採番、複数 bin import では既に外部で
     * 採番済みの完全 ID マップを引き継ぐだけ。各クラスで実装する。
     */
    abstract protected function prepareIds(): void;

    private function generateFulltext(): void
    {
        $DB = DB::singleton(dsn());
        foreach (['category', 'entry'] as $type) {
            $SQL = SQL::newSelect($type);
            $SQL->addSelect($type . '_id');
            $SQL->addWhereOpr($type . '_blog_id', $this->blogId);
            $all = $DB->query($SQL->get(dsn()), 'all');

            foreach ($all as $row) {
                $id = $row[$type . '_id'];
                switch ($type) {
                    case 'category':
                        Common::saveFulltext('cid', $id, Common::loadCategoryFulltext($id));
                        break;
                    case 'entry':
                        Common::saveFulltext('eid', $id, Common::loadEntryFulltext($id));
                        break;
                }
            }
        }
    }

    private function insertData(string $table): void
    {
        if (!$this->existsYaml($table)) {
            return;
        }
        foreach ($this->yaml[$table] as $record) {
            if (!is_array($record)) {
                continue;
            }
            // <table>_id を持つテーブルは、その新ID と一致するレコードが DB に
            // 既にあれば skip する (= 同じ ID のレコードは 1 行のみ存在する原則を保つ)。
            // <table>_id が無いテーブル (tag, media_tag, field, geo, layout_grid 等) は
            // この時点では skip しない (記録の重複は yaml 段階で解決済み or DB レベルで衝突しない構造)。
            if ($this->recordExists($table, $record)) {
                continue;
            }
            $SQL = SQL::newInsert($table);
            foreach ($record as $field => $value) {
                $value = $this->fix($table, $field, $value);
                $method = $table . 'Fix';
                if (is_callable([$this, $method])) {
                    /** @var callable $callback */
                    $callback = [$this, $method];
                    $value = call_user_func_array($callback, [$field, $value, $record]);
                }
                if ($value !== false) {
                    $SQL->addInsert($field, $value);
                }
            }
            // geo テーブルの場合、geo_lat と geo_lng から geo_geometry を再構築
            if ($table === 'geo') {
                $lat = $record['geo_lat'] ?? null;
                $lng = $record['geo_lng'] ?? null;
                if ($lat !== null && $lng !== null) {
                    $SQL->addInsert('geo_geometry', SQL::newGeometry($lat, $lng));
                } else {
                    continue; // 位置情報がない場合はスキップ
                }
            }
            try {
                DB::query($SQL->get(dsn()), 'exec');
            } catch (\Exception $e) {
                $this->errors[] = $e->getMessage();
            }
        }
        foreach ($this->mediaFieldFix as $data) {
            $SQL = SQL::newUpdate('field');
            $SQL->addUpdate('field_value', $data['value']);
            $SQL->addWhereOpr('field_sort', $data['sort']);
            $SQL->addWhereOpr('field_key', $data['name']);
            $SQL->addWhereOpr('field_eid', $data['eid']);
            $SQL->addWhereOpr('field_cid', $data['cid']);
            $SQL->addWhereOpr('field_uid', $data['uid']);
            $SQL->addWhereOpr('field_bid', $data['bid']);
            $SQL->addWhereOpr('field_mid', $data['mid']);
            DB::query($SQL->get(dsn()), 'exec');
        }
    }

    /**
     * yaml の各 blog レコードに含まれる config_set / theme_set / editor_set 系 ID を、
     * 対応する blog テーブルレコードに UPDATE で反映する。
     *
     * 単発 Import: yaml の blog レコードは 1 つで $this->blogId に対応する。
     * MultiBlogImport: yaml に複数 blog (基準 + 子) があり、それぞれの blog_id は
     * mergeBlogYaml で確定 blog_id に書き換え済み。各 blog レコードを個別に UPDATE する。
     */
    private function updateBlogConfigSet(): void
    {
        if (!$this->existsYaml('blog')) {
            return;
        }
        foreach ($this->yaml['blog'] as $blog) {
            if (!is_array($blog) || !isset($blog['blog_id'])) {
                continue;
            }
            $this->updateOneBlogConfigSet($blog);
        }
    }

    /**
     * @param array<string, mixed> $blog yaml の 1 つの blog レコード (blog_id は確定済)
     */
    private function updateOneBlogConfigSet(array $blog): void
    {
        $sql = SQL::newUpdate('blog');
        $hasUpdate = false;
        if (isset($blog['blog_config_set_id'])) {
            $sql->addUpdate('blog_config_set_id', $this->getNewID('config_set', $blog['blog_config_set_id']));
            $hasUpdate = true;
        }
        if (isset($blog['blog_config_set_scope'])) {
            $sql->addUpdate('blog_config_set_scope', $blog['blog_config_set_scope']);
            $hasUpdate = true;
        }
        if (isset($blog['blog_theme_set_id'])) {
            $sql->addUpdate('blog_theme_set_id', $this->getNewID('config_set', $blog['blog_theme_set_id']));
            $hasUpdate = true;
        }
        if (isset($blog['blog_theme_set_scope'])) {
            $sql->addUpdate('blog_theme_set_scope', $blog['blog_theme_set_scope']);
            $hasUpdate = true;
        }
        if (isset($blog['blog_editor_set_id'])) {
            $sql->addUpdate('blog_editor_set_id', $this->getNewID('config_set', $blog['blog_editor_set_id']));
            $hasUpdate = true;
        }
        if (isset($blog['blog_editor_set_scope'])) {
            $sql->addUpdate('blog_editor_set_scope', $blog['blog_editor_set_scope']);
            $hasUpdate = true;
        }
        if (!$hasUpdate) {
            return;
        }
        $sql->addWhereOpr('blog_id', $blog['blog_id']);
        DB::query($sql->get(dsn()), 'exec');
    }

    /**
     * 旧IDを新IDに解決する共通の fix。フィールド名のサフィックスを見て、対応するテーブルの ID マップを引く。
     *
     * @return mixed
     */
    private function fix(string $table, string $field, mixed $value): mixed
    {
        if (is_null($value)) {
            return null;
        }

        $key = substr($field, strlen($table . '_'));
        if ($key === 'id') {
            $value = $this->getNewID($table, $value);
        } elseif ($key === 'category_id') {
            $value = $this->getNewID('category', $value);
        } elseif ($key === 'user_id') {
            $value = $this->getNewID('user', $value);
        } elseif ($key === 'entry_id') {
            $value = $this->getNewID('entry', $value);
        } elseif ($key === 'rule_id') {
            $value = $this->getNewID('rule', $value);
        } elseif ($key === 'module_id') {
            $value = $this->getNewID('module', $value);
        } elseif ($key === 'media_id') {
            $value = $this->getNewID('media', $value);
        } elseif ($key === 'form_id') {
            $value = $this->getNewID('form', $value);
        } elseif ($key === 'set_id' || $key === 'config_set_id' || $key === 'theme_set_id' || $key === 'editor_set_id') {
            $value = $this->getNewID('config_set', $value);
        } elseif ($key === 'unit_id') {
            $value = $this->getNewID('column', $value);
        }

        // 注: <table>_blog_id は yaml 側で事前に正しい blog_id に書き換えられている前提なので、
        // ここでは触らない (呼び出し側 Import::run / MultiBlogImport::run が yaml 正規化済み)。

        return $value;
    }

    /**
     * yaml レコードが持つ旧 <table>_id を新 ID にマッピングし、
     * 同じ新 ID のレコードが既に DB に存在するか確認する。
     *
     * 自前 ID を持たないテーブル (tag, media_tag, field, geo 等) は対象外で、
     * 常に false を返す (= INSERT を試行する)。
     */
    private function recordExists(string $table, array $record): bool
    {
        $idCol = $table . '_id';
        if (!array_key_exists($idCol, $record)) {
            return false;
        }
        $oldId = $record[$idCol];
        if ($oldId === null || $oldId === '') {
            return false;
        }
        // 旧ID→新IDの解決 (registerNewIDs で採番済みのはず。未登録なら元値そのまま)
        $newId = $this->getNewID($table, $oldId);
        if ($newId === '') {
            return false;
        }
        $sql = SQL::newSelect($table);
        $sql->setSelect($idCol, 'cnt', null, 'count');
        $sql->addWhereOpr($idCol, $newId);
        $count = DB::query($sql->get(dsn()), 'one');
        return is_numeric($count) && (int) $count > 0;
    }

    /**
     * @param array<string, mixed> $record
     * @return mixed
     */
    private function configFix(string $field, mixed $value, array $record): mixed
    {
        if (is_null($value) || $field !== 'config_value') {
            return $value;
        }
        if (in_array($record['config_key'], self::$mediaIdConfigKeys, true)) {
            return $this->getNewID('media', $value);
        }
        if (in_array($record['config_key'], self::$categoryIdConfigKeys, true)) {
            return $this->getNewID('category', $value);
        }
        return $value;
    }

    /**
     * @param array<string, mixed> $record
     * @return mixed
     */
    private function entryFix(string $field, mixed $value, array $record): mixed
    {
        if ($field === 'entry_current_rev_id') {
            $value = 0;
        } elseif ($field === 'entry_last_update_user_id') {
            $value = $this->userId;
        } elseif ($field === 'entry_primary_image' && $value !== null && $value !== '' && $value !== 0 && $value !== '0') {
            $value = $this->getNewID('column', $value);
        }
        return $value;
    }

    /**
     * @param array<string, mixed> $record
     * @return mixed
     */
    private function columnFix(string $field, mixed $value, array $record): mixed
    {
        $type = detectUnitTypeSpecifier($record['column_type']);

        if ($type === 'media' && $field === 'column_field_1' && $value) {
            $newMediaId = $this->getNewID('media', $value);
            $value = $newMediaId ? $newMediaId : 0;
        } elseif ($type === 'block-editor' && $field === 'column_field_1' && $value) {
            $value = $this->fixBlockEditorMedia($value);
        } elseif ($type === 'module' && $field === 'column_field_1' && $value) {
            $newModuleId = $this->getNewID('module', $value);
            $value = $newModuleId ? $newModuleId : 0;
        } elseif ($field === 'column_parent_id' && $value) {
            $value = $this->getNewID('column', $value);
        }
        return $value;
    }

    /**
     * @param array<string, mixed> $record
     * @return mixed
     */
    private function categoryFix(string $field, mixed $value, array $record): mixed
    {
        if (!is_null($value) && $field === 'category_parent') {
            $value = $this->getNewID('category', $value);
        }
        return $value;
    }

    /**
     * @param array<string, mixed> $record
     * @return mixed
     */
    private function fulltextFix(string $field, mixed $value, array $record): mixed
    {
        if (!is_null($value) && $field === 'fulltext_eid') {
            $value = $this->getNewID('entry', $value);
        } elseif (!is_null($value) && $field === 'fulltext_cid') {
            $value = $this->getNewID('category', $value);
        } elseif (!is_null($value) && $field === 'fulltext_uid') {
            $value = $this->getNewID('user', $value);
        } elseif ($field === 'fulltext_bid' && $value !== null && $value !== '' && $value !== 0 && $value !== '0') {
            $value = false;
        } elseif ($field === 'fulltext_ngram' && ($value === null || $value === '')) {
            $value = '';
        }

        return $value;
    }

    /**
     * @param array<string, mixed> $record
     * @return mixed
     */
    private function moduleFix(string $field, mixed $value, array $record): mixed
    {
        if (!is_null($value) && $field === 'module_eid') {
            $value = $this->getNewID('entry', $value);
        } elseif (!is_null($value) && $field === 'module_cid') {
            $value = $this->getNewID('category', $value);
        } elseif (!is_null($value) && $field === 'module_uid') {
            $value = $this->getNewID('user', $value);
        } elseif ($field === 'module_bid') {
            $value = ($value === null || $value === '' || $value === 0 || $value === '0') ? null : $value;
        }
        return $value;
    }

    /**
     * @param array<string, mixed> $record
     * @return mixed
     */
    private function layout_gridFix(string $field, mixed $value, array $record): mixed
    {
        if (!is_null($value) && $field === 'layout_grid_mid') {
            $value = $this->getNewID('module', $value);
        }
        return $value;
    }

    /**
     * @param array<string, mixed> $record
     * @return mixed
     */
    private function ruleFix(string $field, mixed $value, array $record): mixed
    {
        if (!is_null($value) && $field === 'rule_eid') {
            $value = $this->getNewID('entry', $value);
        } elseif (!is_null($value) && $field === 'rule_cid') {
            $value = $this->getNewID('category', $value);
        } elseif (!is_null($value) && $field === 'rule_uid') {
            $value = $this->getNewID('user', $value);
        } elseif (!is_null($value) && $field === 'rule_aid') {
            $value = $this->getNewID('alias', $value);
        }
        return $value;
    }

    /**
     * @param array<string, mixed> $record
     * @return mixed
     */
    private function fieldFix(string $field, mixed $value, array $record): mixed
    {
        if (!is_null($value) && $field === 'field_eid') {
            $value = $this->getNewID('entry', $value);
        } elseif (!is_null($value) && $field === 'field_cid') {
            $value = $this->getNewID('category', $value);
        } elseif (!is_null($value) && $field === 'field_uid') {
            $value = $this->getNewID('user', $value);
        } elseif (!is_null($value) && $field === 'field_mid') {
            $value = $this->getNewID('module', $value);
        } elseif (
            !is_null($value) &&
            $field === 'field_value' &&
            preg_match('/@media$/', $record['field_key'])
        ) {
            $value = $this->getNewID('media', $value);
            // 注: $record['field_bid'] は yaml 側で正しい blog_id に書き換え済 (呼び出し側責任)
            $this->mediaFieldFix[] = [
                'name' => substr($record['field_key'], 0, -6),
                'value' => $value,
                'sort' => $record['field_sort'],
                'eid' => !$record['field_eid'] ? null : $this->getNewID('entry', $record['field_eid']),
                'cid' => !$record['field_cid'] ? null : $this->getNewID('category', $record['field_cid']),
                'uid' => !$record['field_uid'] ? null : $this->getNewID('user', $record['field_uid']),
                'bid' => !$record['field_bid'] ? null : $record['field_bid'],
                'mid' => !$record['field_mid'] ? null : $this->getNewID('module', $record['field_mid']),
            ];
        } elseif (
            !is_null($value) &&
            $field === 'field_value' &&
            $record['field_type'] === 'block-editor'
        ) {
            $value = $this->fixBlockEditorMedia($value);
        }
        return $value;
    }

    private function fixBlockEditorMedia(string $value): string
    {
        return BlockEditor::fixMediaId($value, $this->ids['media'] ?? []);
    }

    /**
     * This method is called dynamically via call_user_func_array().
     *
     * @param array<string, mixed> $record
     * @return mixed
     * @phpstan-ignore-next-line
     */
    private function entry_sub_categoryFix(string $field, mixed $value, array $record): mixed
    {
        if (!is_null($value) && $field === 'entry_sub_category_eid') {
            $value = $this->getNewID('entry', $value);
        } elseif (!is_null($value) && $field === 'entry_sub_category_id') {
            $value = $this->getNewID('category', $value);
        }
        return $value;
    }

    /**
     * This method is called dynamically via call_user_func_array().
     *
     * @param array<string, mixed> $record
     * @return mixed
     * @phpstan-ignore-next-line
     */
    private function geoFix(string $field, mixed $value, array $record): mixed
    {
        if (!is_null($value) && $field === 'geo_eid') {
            $value = $this->getNewID('entry', $value);
        } elseif (!is_null($value) && $field === 'geo_cid') {
            $value = $this->getNewID('category', $value);
        } elseif ($field === 'geo_lat' || $field === 'geo_lng') {
            // geo_lat と geo_lng はエクスポート時に ST_X/ST_Y で抽出した値
            // geo_geometry は insertData で再構築するため、ここではスキップ
            $value = false;
        }
        // 注: geo_uid はブログエクスポートに含まれないため処理不要
        return $value;
    }

    /**
     * 旧IDを新IDに解決する。マッピングが存在しない場合は元の値をそのまま返す。
     * unit_delimiter で区切られた複数IDのケースにも対応。
     *
     * 該当しない値 (テンプレート変数 `%{RCID}` のようなプレースホルダー文字列等) は
     * 解決対象外として元の値をそのまま返す。
     *
     * @param int|string $id
     * @return int|string
     */
    private function getNewID(string $table, mixed $id): mixed
    {
        if (is_numeric($id) || $table === 'column') {
            if (!isset($this->ids[$table][$id])) {
                return $id;
            }
            return $this->ids[$table][$id];
        }
        if (strpos($id, ':acms_unit_delimiter:') !== false) {
            $temp = explode(':acms_unit_delimiter:', $id);
            $responseIds = [];
            foreach ($temp as $tempId) {
                if (!isset($this->ids[$table][$tempId])) {
                    $responseIds[] = $tempId;
                    continue;
                }
                $responseIds[] = $this->ids[$table][$tempId];
            }
            return implode(':acms_unit_delimiter:', $responseIds);
        }
        // 上記いずれにも該当しない値 (プレースホルダー文字列など) は元の値をそのまま返す
        return $id;
    }

    private function existsYaml(string $table): bool
    {
        if (!isset($this->yaml[$table])) {
            return false;
        }
        $data = $this->yaml[$table];
        if (!is_array($data)) {
            return false;
        }
        return true;
    }

    /**
     * 投入先 blog の既存データを削除する (破壊リプレース用)。
     *
     * 呼び出し前に `$this->blogId` を投入先 blog_id にセットしておく必要がある。
     */
    protected function dropData(): void
    {
        $tables = [
            'category', 'form', 'entry', 'column', 'tag',
            'entry_sub_category',
            'fulltext', 'field', 'media', 'media_tag',
            'approval', 'cache_reserve', 'column_rev', 'entry_rev',
            'field_rev', 'tag_rev',
            'dashboard', 'module', 'layout_grid', 'rule', 'config', 'config_set',
            'geo',
        ];

        foreach ($tables as $table) {
            $this->clearTable($table);
        }
    }

    /**
     * yaml 内の各テーブルの `<table>_blog_id` を指定 blog_id で書き換える。
     *
     * 呼び出し側 (Import::run) が executeImport に渡す前の正規化に使う。
     * MultiBlogImport は別途 mergeBlogYaml で同等の処理を行うため、これは呼ばない。
     */
    protected function rewriteBlogIdInYaml(string $yaml, int $blogId): string
    {
        $data = Yaml::parse($yaml);
        if (!is_array($data)) {
            return $yaml;
        }
        foreach ($data as $table => &$records) {
            if (!is_array($records)) {
                continue;
            }
            $blogIdCol = $table . '_blog_id';
            foreach ($records as &$record) {
                if (!is_array($record)) {
                    continue;
                }
                if (array_key_exists($blogIdCol, $record)) {
                    $record[$blogIdCol] = $blogId;
                }
                // blog テーブルだけは自身の PK が blog_id（blog_blog_id ではない）ため上の汎用書き換えに
                // 掛からない。単発 import では yaml の blog レコード（1 件）が投入先 blog に対応するので
                // blog_id も投入先へ揃える。怠ると updateBlogConfigSet の WHERE blog_id がエクスポート元の
                // 元 blog_id（例: ルート = 1）を指し、無関係なブログの theme/editor/config set ID を破壊する
                // （MultiBlogImport::mergeBlogYaml も同じ理由で blog_id を確定 blog_id へ書き換えている）。
                if ($table === 'blog' && array_key_exists('blog_id', $record)) {
                    $record['blog_id'] = $blogId;
                }
            }
            unset($record);
        }
        unset($records);
        return Yaml::dump($data, 4, 2);
    }

    private function clearTable(string $table): void
    {
        $SQL = SQL::newDelete($table);
        if (preg_match('/^(.*)\_rev$/', $table, $match)) {
            $SQL->addWhereOpr($match[1] . '_blog_id', $this->blogId);
        } else {
            $SQL->addWhereOpr($table . '_blog_id', $this->blogId);
        }
        if ($table === 'field') {
            $SQL->addWhereOpr('field_uid', null);
        }
        DB::query($SQL->get(dsn()), 'exec');
    }
}
