<?php

namespace Acms\Services\Blog;

use SQL;
use DB;
use Symfony\Component\Yaml\Yaml;

/**
 * 基準ブログを起点に、複数 bin の yaml をマージして一括投入する。
 *
 * 「基準 bin (root) は既存の baseBlogId に投入、それ以外は baseBlogId 配下に
 * 新規子 blog を作って投入」というユースケース専用。
 * 単一 bin → 単一 blog のシンプルなインポートは {@see Import} を使う。
 *
 * ## 利用フロー
 *
 *   $multi = new MultiBlogImport();
 *   $multi->run(
 *       bins: [
 *           // 配列の最初のエントリが基準 bin。baseBlogId にそのまま投入される
 *           'site' => ['yaml' => $rootYaml],
 *           // 2 番目以降は baseBlogId 配下に子 blog を新規作成して投入
 *           'blog@site' => ['yaml' => $childYaml, 'blogCode' => 'blog', 'blogName' => 'ブログ'],
 *           'news@site' => ['yaml' => $newsYaml, 'blogCode' => 'news', 'blogName' => 'ニュース'],
 *       ],
 *       baseBlogId: 1,
 *       userId: $adminUserId,
 *       blogDomain: 'example.com',
 *   );
 *
 * ## 動作の要点
 *
 *  - **マージ段階で「同じ ID = 1 行」原則を保証**:
 *    全 bin yaml を 1 本にマージする際、`<table>_id` 重複は **先勝ちで除去**。
 *    `<table>_blog_id` は各 bin の確定 blog_id に書き換える。
 *  - **基準ブログのみ dropData を実行**:
 *    基準ブログは既存データを破壊リプレース。子ブログは新規作成するため drop 不要。
 */
class MultiBlogImport
{
    use ImportTrait;

    /** @var array */
    protected $yaml;

    /** @var int */
    protected $blogId;

    /** @var int */
    protected $userId;

    /** @var array */
    protected $ids;

    /** @var array */
    protected $errors;

    /** @var array */
    protected $mediaFieldFix = [];

    /**
     * 自前 `<table>_id` を持たないが、複合カラムで PRIMARY KEY を構成するテーブル。
     *
     * これらは mergeBlogYaml 時に「PK カラムの組」で先勝ち重複除去する。
     * 例: site bin と blog@site bin が同じ (media_id=12, sort=1) の media_tag を持つ場合、
     * 先勝ちで site bin のレコードを採用し、blog@site bin の同 PK レコードは捨てる。
     */
    private const COMPOUND_PRIMARY_KEYS = [
        'tag' => ['tag_entry_id', 'tag_sort'],
        'media_tag' => ['media_tag_media_id', 'media_tag_sort'],
    ];

    public function __construct(
        // ブログ作成（nested-set 予約＋INSERT）は共通の BlogRepository に集約。
        private readonly BlogRepository $blogRepository = new BlogRepository(),
    ) {
    }

    /**
     * 基準ブログを起点に複数 bin を一括投入する。
     *
     * @param array<string, array{yaml: string, blogCode?: string|null, blogName?: string|null}> $bins
     *        binName => bin 情報。**配列の最初のエントリが基準 bin** (baseBlogId に投入)。
     *        2 番目以降は baseBlogId 配下に新規子 blog を作って投入。
     *        子 bin は `blogCode` / `blogName` で blog レコードのフィールドを指定 (省略時は binName ベース)。
     * @param int $baseBlogId 基準ブログ ID (既に DB に存在している前提)
     * @param int $userId 実行者 user_id (entry_last_update_user_id 等で使用)
     * @param string|null $blogDomain 子 blog 作成時の blog_domain (省略時は空文字)
     * @return MultiBlogImportResult エラーと bin → blog_id 対応
     */
    public function run(array $bins, int $baseBlogId, int $userId, ?string $blogDomain = null): MultiBlogImportResult
    {
        if ($bins === []) {
            return new MultiBlogImportResult([], []);
        }

        // 基準ブログの既存データを破壊リプレース。
        // 子ブログは下で createBlog() で新規作成するため drop 不要。
        $this->blogId = $baseBlogId;
        $this->dropData();

        // 基準 bin (= 配列の最初のエントリ) を baseBlogId に紐付け、
        // 残りは baseBlogId 配下に新規子 blog を作って紐付ける。
        // 各 bin の yaml は確定 blog_id で archives prefix を書き換える
        // (yaml 内の "001/..." のようなパスを "{blog_id}/..." に置換)。
        $resolved = [];
        $binBlogIds = [];
        $isFirst = true;
        foreach ($bins as $binName => $info) {
            $rawYaml = $info['yaml'];
            if ($isFirst) {
                $resolved[$binName] = [
                    'blogId' => $baseBlogId,
                    'yaml' => $this->rewriteArchivesPrefix($rawYaml, $baseBlogId),
                ];
                $binBlogIds[$binName] = $baseBlogId;
                $isFirst = false;
                continue;
            }
            $childBlogId = $this->createBlog(
                parentBlogId: $baseBlogId,
                blogCode: $info['blogCode'] ?? $this->fallbackBlogCode($binName),
                blogName: $info['blogName'] ?? $this->fallbackBlogName($binName),
                blogDomain: $blogDomain ?? '',
            );
            $resolved[$binName] = [
                'blogId' => $childBlogId,
                'yaml' => $this->rewriteArchivesPrefix($rawYaml, $childBlogId),
            ];
            $binBlogIds[$binName] = $childBlogId;
        }

        // 全 bin yaml をマージ。各レコードの <table>_blog_id は確定済 (mergeBlogYaml が書き換え)。
        $mergedYaml = $this->mergeBlogYaml($resolved);
        $errors = $this->executeImport($mergedYaml, $baseBlogId, $userId);

        return new MultiBlogImportResult(array_values($errors), $binBlogIds);
    }

    private function fallbackBlogCode(string $binName): string
    {
        // 'blog@site' → 'blog'
        return explode('@', $binName)[0];
    }

    private function fallbackBlogName(string $binName): string
    {
        return $this->fallbackBlogCode($binName);
    }

    /**
     * blog テーブルに新規レコードを INSERT し、`blog_id` を返す。
     *
     * nested-set の予約と INSERT は BlogRepository のプリミティブ（findLastChildInParent /
     * findBoundary / shiftLeftRightAfter / nextBlogId / insert）を合成して行う（管理画面
     * ACMS_POST_Blog_Insert と同じ作法。生 SQL は BlogRepository に集約済み）。
     *
     * @param string $blogCode 先頭 '@' より前を blog_code として使う
     */
    private function createBlog(int $parentBlogId, string $blogCode, string $blogName, string $blogDomain = ''): int
    {
        $lastChild = $this->blogRepository->findLastChildInParent($parentBlogId);
        if ($lastChild !== null) {
            $left = $lastChild['right'] + 1;
            $sort = $lastChild['sort'] + 1;
        } elseif ($parentBlogId === 0) {
            $left = 1;
            $sort = 1;
        } else {
            $boundary = $this->blogRepository->findBoundary($parentBlogId);
            $left = $boundary !== null ? $boundary['right'] : 1;
            $sort = 1;
        }

        $this->blogRepository->shiftLeftRightAfter($left);

        $blogId = $this->blogRepository->nextBlogId();
        $this->blogRepository->insert([
            'blog_id' => $blogId,
            'blog_code' => explode('@', $blogCode)[0],
            'blog_status' => 'open',
            'blog_parent' => $parentBlogId,
            'blog_sort' => $sort,
            'blog_left' => $left,
            'blog_right' => $left + 1,
            'blog_name' => $blogName,
            'blog_domain' => $blogDomain,
            'blog_indexing' => 'on',
            'blog_generated_datetime' => date('Y-m-d H:i:00'),
        ]);

        return $blogId;
    }

    /**
     * yaml 内の `DDD/...` 形式 (media / storage の blog_id プレフィックス) を新 blog_id で書き換え。
     * (例: 旧 blog_id=001 で配置されていたパスを、新 blog_id={blogIdPrefix} に置換)
     *
     * ACMS_POST_Backup_BlogImport::fixYaml と同じ正規表現。
     */
    private function rewriteArchivesPrefix(string $yaml, int $blogId): string
    {
        $prefix = sprintf('%03d', $blogId);
        return preg_replace(
            '@([\d]{3})/(.*)\.([^\.]{2,6})@ui',
            $prefix . '/$2.$3',
            $yaml,
        ) ?? $yaml;
    }

    /**
     * 複数 bin の yaml を 1 本の yaml 文字列にマージする。
     *
     *  - <table>_id 重複は先勝ちで除去 (= 同じ ID のレコードは DB に 1 行のみ存在する原則)
     *  - <table>_blog_id を、その bin に割り当てられた確定 blog_id に書き換え
     *  - <table>_id を持たないテーブル (tag / media_tag / field / geo / layout_grid 等) は
     *    各 bin のレコードがそのままマージ後 yaml に並ぶ (旧フローの挙動と同じ)
     *
     * @param array<string, array{blogId: int, yaml: string}> $bins
     */
    private function mergeBlogYaml(array $bins): string
    {
        /** @var array<string, list<array<string, mixed>>> $merged */
        $merged = [];
        /** @var array<string, array<int|string, true>> $seenIds  table => oldId => true */
        $seenIds = [];
        /** @var array<string, true> $seenCompoundKeys  "<table>:<col1>=<v1>:<col2>=<v2>" => true */
        $seenCompoundKeys = [];

        foreach ($bins as $info) {
            $blogId = $info['blogId'];
            $data = Yaml::parse($info['yaml']);
            if (!is_array($data)) {
                continue;
            }
            foreach ($data as $table => $records) {
                if (!is_array($records)) {
                    continue;
                }
                if (!isset($merged[$table])) {
                    $merged[$table] = [];
                }

                // blog テーブルは特別処理。
                // 各 bin の blog レコードは「その bin が投入される blog」のメタ情報
                // (blog_config_set_id 等) を持っている。bin ごとに別の確定 blog_id に
                // 紐付くので、`blog_id` を `$blogId` で書き換えてから、dedup せず全レコードを残す。
                // (旧 blog_id で先勝ち dedup すると、子 bin の config_set / theme_set / editor_set
                // 情報が失われる)
                if ($table === 'blog') {
                    foreach ($records as $record) {
                        if (!is_array($record)) {
                            continue;
                        }
                        if (array_key_exists('blog_id', $record)) {
                            $record['blog_id'] = $blogId;
                        }
                        $merged[$table][] = $record;
                    }
                    continue;
                }

                $idCol = $table . '_id';
                $blogIdCol = $table . '_blog_id';
                foreach ($records as $record) {
                    if (!is_array($record)) {
                        continue;
                    }
                    if (array_key_exists($idCol, $record)) {
                        // 自前 <table>_id を持つテーブル: 旧 ID で先勝ち除去
                        $oldId = $record[$idCol];
                        if (isset($seenIds[$table][$oldId])) {
                            continue;
                        }
                        $seenIds[$table][$oldId] = true;
                    } elseif (isset(self::COMPOUND_PRIMARY_KEYS[$table])) {
                        // 自前 ID なし & 複合 PK のテーブル (tag / media_tag): PK カラムの組で先勝ち除去
                        $compoundKey = $this->buildCompoundKey($table, $record);
                        if ($compoundKey === null) {
                            // PK カラムが record に揃っていない異常データは skip
                            continue;
                        }
                        if (isset($seenCompoundKeys[$compoundKey])) {
                            continue;
                        }
                        $seenCompoundKeys[$compoundKey] = true;
                    }
                    if (array_key_exists($blogIdCol, $record)) {
                        $record[$blogIdCol] = $blogId;
                    }
                    $merged[$table][] = $record;
                }
            }
        }

        return Yaml::dump($merged, 4, 2);
    }

    /**
     * COMPOUND_PRIMARY_KEYS 対象テーブルのレコードから一意キー文字列を作る。
     * PK カラムが揃っていなければ null を返す。
     *
     * @param array<string, mixed> $record
     */
    private function buildCompoundKey(string $table, array $record): ?string
    {
        $key = $table;
        foreach (self::COMPOUND_PRIMARY_KEYS[$table] as $col) {
            if (!array_key_exists($col, $record)) {
                return null;
            }
            $value = $record[$col];
            if (!is_scalar($value) && $value !== null) {
                return null;
            }
            $key .= ':' . $col . '=' . (string) $value;
        }
        return $key;
    }

    /**
     * マージ済 yaml に含まれる全 ID を一括採番する。
     */
    protected function prepareIds(): void
    {
        $this->ids = [];
        $tables = ['column', 'alias', 'form', 'entry', 'fulltext', 'media', 'module', 'rule', 'config_set'];
        foreach ($tables as $table) {
            $this->registerNewIDsForTable($table);
        }
        $this->registerCategoryNewId();
    }

    private function registerNewIDsForTable(string $table): void
    {
        if (!$this->existsYaml($table)) {
            return;
        }
        foreach ($this->yaml[$table] as $record) {
            if (!is_array($record) || !isset($record[$table . '_id'])) {
                continue;
            }
            $id = $record[$table . '_id'];
            if (isset($this->ids[$table][$id])) {
                continue;
            }
            if ($table === 'column') {
                $this->ids[$table][$id] = uuidv4();
            } else {
                $this->ids[$table][$id] = DB::query(SQL::nextval($table . '_id', dsn()), 'seq');
            }
        }
    }

    /**
     * カテゴリーIDの新規マッピング。同名コードが既存ブログに存在する場合は再利用、
     * 無い場合のみ nextval で新規採番する (旧 setup/lib/import.php と同じセマンティクス)。
     *
     * MultiBlogImport では複数 blog のカテゴリが yaml に混在するため、bid 毎ではなく
     * `category_blog_id` を引数として既存検索する点が単発 Import との差異。
     *
     * 既存検索の対象は **基準ブログ + その子孫ブログ** に限定する。無関係なブログツリーに
     * たまたま同じ category_code のカテゴリーがあっても再利用候補にしない (意味的にも、
     * 性能的にも)。
     */
    private function registerCategoryNewId(): void
    {
        if (!$this->existsYaml('category')) {
            return;
        }

        $codeArray = [];
        foreach ($this->yaml['category'] as $record) {
            $codeArray[] = $record['category_code'];
        }
        $codeArray = array_unique($codeArray);

        $sql = SQL::newSelect('category');
        $sql->addLeftJoin('blog', 'blog_id', 'category_blog_id');
        $sql->addWhereIn('category_code', $codeArray);
        \ACMS_Filter::blogTree($sql, $this->blogId, 'self-or-descendant');
        $statement = DB::query($sql->get(dsn()), 'exec');

        $categoryTable = [];
        while ($row = DB::next($statement)) {
            $key = $row['category_blog_id']
                . ':' . $row['category_code']
                . ':' . intval($row['category_parent']);
            $categoryTable[$key] = $row['category_id'];
        }

        $categories = $this->yaml['category'];
        usort($categories, fn ($a, $b) => intval($a['category_parent']) <=> intval($b['category_parent']));

        foreach ($categories as $record) {
            $id = $record['category_id'];
            $blogId = $record['category_blog_id'];
            $code = $record['category_code'];
            $oldParentId = intval($record['category_parent']);

            $newParentId = 0;
            if ($oldParentId > 0 && isset($this->ids['category'][$oldParentId])) {
                $newParentId = $this->ids['category'][$oldParentId];
            }

            $key = $blogId . ':' . $code . ':' . $newParentId;

            if (isset($categoryTable[$key])) {
                $this->ids['category'][$id] = $categoryTable[$key];
            } else {
                $this->ids['category'][$id] = DB::query(SQL::nextval('category_id', dsn()), 'seq');
            }
        }
    }
}
