<?php

namespace Acms\Services\Installer;

use Acms\Services\Blog\BlogRepository;
use Acms\Services\Blog\MultiBlogImport;
use Acms\Services\Blog\MultiBlogImportResult;
use Acms\Services\Facades\Cache;
use Acms\Services\Facades\Database as DB;
use Acms\Services\SiteSeed\Contracts\SiteSeedLogger;
use Acms\Services\SiteSeed\Logger\CollectingSiteSeedLogger;
use Acms\Services\SiteSeed\SiteSeedPath;
use Acms\Services\SiteSeed\SiteSeedPathResolver;
use Acms\Services\SiteSeed\SiteSeedResult;
use Acms\Services\SiteSeed\SiteSeeder;
use SQL;
use Throwable;

/**
 * a-blog cms の初回インストール orchestrator
 *
 * 「空 DB → 管理者 login 可能な状態」までを 1 呼び出しで完結させる。
 * 既存の Web インストーラ (setup/install.php) と CLI の両方から利用できる。
 *
 * 処理順序:
 *
 *   0. スキーマ作成 (DDL なのでトランザクション外)
 *   1. sequence 初期化
 *   2. 空 DB チェック (blog が存在しないこと)
 *   --- ここからトランザクション ---
 *   3. ルート blog 作成 → admin user 作成
 *   4. 関連 bin (`*@<root>`) があれば、各々に対応する子 blog を作成
 *   5. `MultiBlogImport::run()` で全 bin を一括投入
 *      (内部で全 bin の yaml をマージ: `<table>_blog_id` を新 blog_id に書き換え、
 *       `<table>_id` 重複は先勝ちで除去される。`同じ ID = 1 行` 原則を yaml 段階で保証)
 *   --- コミット ---
 *   6. 各 bin の archives / media / storage / themes を SCRIPT_DIR 配下にコピー
 *      (DB トランザクションで巻き戻せないため、コミット成功後に実行)
 *   7. キャッシュ全クリア
 *
 * Step 3〜5 のいずれかで失敗するとロールバックして InstallerException を再 throw。
 * Step 0 (DDL) は MySQL 仕様で暗黙コミットされるためトランザクション外。Step 1/2 は
 * 副作用が小さいため簡単のためトランザクション外に置く (繰り返し呼んでも冪等)。
 */
class Installer
{
    public function __construct(
        private readonly SequenceInitializer $sequence,
        private readonly AdminUserFactory $adminUserFactory,
        private readonly SiteSeeder $siteSeeder,
        private readonly SiteSeedPathResolver $paths,
        private readonly SchemaCreator $schema,
        // MultiBlogImport は実 DB 操作のみで差し替え不要なため内部生成。
        private readonly MultiBlogImport $multiImport = new MultiBlogImport(),
        // ブログ作成（nested-set 予約＋INSERT）は共通の BlogRepository に集約。
        private readonly BlogRepository $blogRepository = new BlogRepository(),
    ) {
    }

    public function install(InstallerOptions $options, ?SiteSeedLogger $logger = null): InstallerResult
    {
        $logger ??= new CollectingSiteSeedLogger();
        $started = microtime(true);

        // 事前に bin パスが有効か検証 (DB は触らない)
        $this->paths->resolve($options->binName);

        // ドメインは options を最優先し、未指定なら config.server.php の DOMAIN にフォールバックする。
        // どちらも空なら blog のホスト名がない不完全な状態になるので、ここで早期エラーにする。
        $effectiveDomain = $this->resolveBlogDomain($options);
        if ($effectiveDomain === '') {
            throw new InstallerException(
                'Blog domain is empty. Set DOMAIN in config.server.php before running install.',
                4,
            );
        }

        // dry-run は DB に一切書き込まない (DDL/DML 共に抑制)。
        // 既存 blog の有無チェックも DDL/DML を伴わないため省略する。
        if ($options->dryRun) {
            $logger->info('dry-run mode: skip schema creation / sequence / blog / user / bin seed');
            return new InstallerResult(
                blogId: 0,
                adminUserId: 0,
                seedResult: $this->makeDryRunSeedResult($options),
                elapsedSec: round(microtime(true) - $started, 3),
            );
        }

        // Step 0: スキーマ確認・作成
        try {
            $createdTables = $this->schema->isCreated() ? [] : $this->schema->create();
        } catch (\Throwable $e) {
            throw new InstallerException(
                sprintf('Failed to create schema: %s', $e->getMessage()),
                3,
                $e,
            );
        }
        if (count($createdTables) > 0) {
            $logger->info(sprintf('Created %d table(s)', count($createdTables)));
        }

        // Step 1: sequence 初期化
        if ($this->sequence->ensureInitialized()) {
            $logger->info('Initialized sequence table');
        } else {
            $logger->info('sequence table is already initialized');
        }
        $logger->progress('sequence', 1, 5);

        // Step 2: 空 DB チェック
        $this->assertNoExistingBlog();

        // Step 3〜5 (DB 書き込み一式) を Database::transaction() で囲む。
        // 途中で例外が発生したら rollBack して、ゴミ blog / 中途半端な user 等が DB に残らないようにする。
        // ファイルコピー (Step 6) は DB ロールバックで巻き戻せないため、コミット成功後に実行する。
        //
        // Database::transaction() (Engine::beginTransaction/commit/rollBack) は、既に外側で
        // トランザクションが進行中 (例: DatabaseTestCase が PDO レベルで BEGIN 済み) でも SAVEPOINT で
        // 自動的にネストするため、own/inTransaction をここで手動判定する必要はない。
        try {
            /**
             * @var array{
             *     rootBlogId: int,
             *     adminUserId: int,
             *     relatedBins: list<string>,
             *     binPaths: array<string, SiteSeedPath>,
             *     importResult: MultiBlogImportResult,
             * } $seeded
             */
            $seeded = DB::transaction(function () use ($options, $logger, $effectiveDomain): array {
                // Step 3: ルート blog 作成
                // ルート blog はドメイン直下表示が前提のため、blog_code は空文字を採用する。
                $rootBlogId = $this->createBlog(
                    parentBlogId: 0,
                    binName: $options->binName,
                    explicitCode: $options->blogCode ?? '',
                    explicitName: $options->blogName,
                    domain: $effectiveDomain,
                );
                $logger->info(sprintf('Created root blog: blog_id=%d', $rootBlogId));
                $logger->progress('create root blog', 2, 5);

                // Step 4: admin user 作成
                try {
                    $adminUserId = $this->adminUserFactory->create(
                        blogId: $rootBlogId,
                        code: $options->adminUserCode,
                        name: $options->adminUserName,
                        password: $options->adminUserPassword,
                        mail: $options->adminUserMail,
                    );
                } catch (Throwable $e) {
                    throw new InstallerException(
                        sprintf('Failed to create admin user: %s', $e->getMessage()),
                        3,
                        $e,
                    );
                }
                $logger->info(sprintf(
                    'Created admin user: user_id=%d (code=%s)',
                    $adminUserId,
                    $options->adminUserCode,
                ));
                $logger->progress('create admin user', 3, 5);

                // Step 5: 関連 bin の解決
                $relatedBins = !$options->rootOnly
                    ? $this->paths->listRelatedBins($options->binName)
                    : [];

                $rootPath = $this->paths->resolve($options->binName);
                // SiteSeedPathResolver::resolve が isFile を通している (= 不在なら SiteSeedException) ため、
                // ここに到達した時点では yaml は存在する。LocalStorage::get は Web 公開範囲の
                // traversal 防御を伴うため、ここでは生 file_get_contents を使う。
                $rootYaml = (string) @file_get_contents($rootPath->yaml);

                /** @var array<string, SiteSeedPath> $binPaths binName => path 情報 (ファイルコピーで使う) */
                $binPaths = [$options->binName => $rootPath];

                // MultiBlogImport に渡す bin リスト。
                // 配列の最初のエントリ (= 基準 bin) は rootBlogId に投入され、
                // 2 番目以降は MultiBlogImport が baseBlogId 配下に新規子 blog を作って投入する。
                // archives prefix の書き換えは MultiBlogImport が確定 blog_id で行う。
                /** @var array<string, array{yaml: string, blogCode?: string, blogName?: string}> $bins */
                $bins = [
                    $options->binName => ['yaml' => $rootYaml],
                ];

                if (count($relatedBins) > 0) {
                    $logger->info(sprintf(
                        'Detected %d related bin(s): %s',
                        count($relatedBins),
                        implode(', ', $relatedBins),
                    ));
                }
                foreach ($relatedBins as $childBin) {
                    $childPath = $this->paths->resolve($childBin);
                    $childMeta = $this->paths->loadBinConfig($childBin);
                    $childYaml = (string) @file_get_contents($childPath->yaml);
                    $bins[$childBin] = [
                        'yaml' => $childYaml,
                        'blogCode' => $childMeta['code'],
                        'blogName' => $childMeta['name'],
                    ];
                    $binPaths[$childBin] = $childPath;
                }
                $logger->progress('seed blogs', 4, 5);

                // Step 6: MultiBlogImport で全 bin を一括投入。
                //
                // 内部で各 bin の子 blog を作成 → 各 bin yaml の archives prefix を確定 blog_id で書き換え →
                // 全 bin yaml をマージ (<table>_id 重複は先勝ちで除去、<table>_blog_id は各 bin の確定
                // blog_id に書き換え) → 1 度だけ DB 投入する。
                // これにより「同じ ID のレコードは DB に 1 行しか存在しない」原則が保たれる。
                $importResult = $this->multiImport->run(
                    $bins,
                    $rootBlogId,
                    $adminUserId,
                    $effectiveDomain,
                );

                return [
                    'rootBlogId' => $rootBlogId,
                    'adminUserId' => $adminUserId,
                    'relatedBins' => $relatedBins,
                    'binPaths' => $binPaths,
                    'importResult' => $importResult,
                ];
            });
        } catch (Throwable $e) {
            if ($e instanceof InstallerException) {
                throw $e;
            }
            throw new InstallerException(
                sprintf('Install failed: %s', $e->getMessage()),
                3,
                $e,
            );
        }

        $rootBlogId = $seeded['rootBlogId'];
        $adminUserId = $seeded['adminUserId'];
        $relatedBins = $seeded['relatedBins'];
        $binPaths = $seeded['binPaths'];
        $importResult = $seeded['importResult'];

        // ここからはコミット済み。失敗してもユーザーは管理画面にログインできる状態。
        $warnings = [];
        foreach ($importResult->errors as $error) {
            $logger->warning('import warning: ' . $error);
            $warnings[] = $error;
        }
        $logger->info('All bins seeded');

        // Step 7: 各 bin の archives/media/storage/themes をコピー (DB ロールバック対象外)
        foreach ($importResult->binBlogIds as $binName => $blogId) {
            $this->siteSeeder->copyBinFiles($binPaths[$binName], $blogId, $logger, $warnings);
        }

        // Step 8: キャッシュクリア
        if (!$options->skipCacheFlush) {
            try {
                Cache::allFlush();
            } catch (Throwable $e) {
                $logger->warning('cache flush failed: ' . $e->getMessage());
            }
        }
        $logger->progress('done', 5, 5);

        $elapsed = round(microtime(true) - $started, 3);
        $logger->info(sprintf(
            'Install completed: blog_id=%d, admin_user_id=%d, %.3f sec',
            $rootBlogId,
            $adminUserId,
            $elapsed,
        ));

        // 結果オブジェクトの組み立て (互換性のため、ルート結果に related を足した形を維持)
        $rootSeedResult = new SiteSeedResult(
            binName: $options->binName,
            blogId: $rootBlogId,
            ids: [],
            counts: [],
            elapsedSec: $elapsed,
            warnings: $warnings,
        );
        $relatedSeedResults = [];
        foreach ($relatedBins as $childBin) {
            $relatedSeedResults[] = new SiteSeedResult(
                binName: $childBin,
                blogId: $importResult->binBlogIds[$childBin] ?? 0,
                ids: [],
                counts: [],
                elapsedSec: 0.0,
            );
        }
        $combinedSeedResult = $rootSeedResult->withRelated($relatedSeedResults);

        return new InstallerResult(
            blogId: $rootBlogId,
            adminUserId: $adminUserId,
            seedResult: $combinedSeedResult,
            elapsedSec: $elapsed,
        );
    }

    /**
     * ルート/子 blog を作成し、`blog_id` を返す。
     *
     * blog_code / blog_name を正規化したうえで、nested-set の予約と INSERT を BlogRepository の
     * プリミティブ（findLastChildInParent / findBoundary / shiftLeftRightAfter / nextBlogId / insert）を
     * 合成して行う（管理画面 ACMS_POST_Blog_Insert と同じ作法。生 SQL は BlogRepository に集約済み）。
     */
    private function createBlog(
        int $parentBlogId,
        string $binName,
        ?string $explicitCode,
        ?string $explicitName,
        string $domain,
    ): int {
        // ルート blog ($parentBlogId === 0) はドメイン直下表示が前提なので、
        // 呼び出し側 (CLI の --blog-code / Web フォーム) が値を渡しても **常に空文字に固定** する。
        // 子 blog はオプションコード優先、未指定なら bin 名にフォールバック。
        if ($parentBlogId === 0) {
            $code = '';
        } elseif ($explicitCode !== null) {
            $code = $explicitCode;
        } else {
            $code = $binName;
        }
        // binName が `<子>@<親>` 形式 (CMS-7829: そのような bin をルートとして直接選ぶケース) の場合、
        // blog_name の既定値には '@' 以降を含めない。MultiBlogImport::fallbackBlogCode/fallbackBlogName
        // が子 blog 作成時に使う規則 (`explode('@', $binName)[0]`) と揃える。
        $name = $explicitName !== null && $explicitName !== '' ? $explicitName : explode('@', $binName)[0];

        try {
            $lastChild = $this->blogRepository->findLastChildInParent($parentBlogId);
            if ($lastChild !== null) {
                $left = $lastChild['right'] + 1;
                $sort = $lastChild['sort'] + 1;
            } elseif ($parentBlogId === 0) {
                // 空 DB の最初のルートブログ。
                $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('@', $code)[0],
                'blog_status' => 'open',
                'blog_parent' => $parentBlogId,
                'blog_sort' => $sort,
                'blog_left' => $left,
                'blog_right' => $left + 1,
                'blog_name' => $name,
                'blog_domain' => $domain,
                'blog_indexing' => 'on',
                'blog_generated_datetime' => date('Y-m-d H:i:00'),
            ]);

            return $blogId;
        } catch (Throwable $e) {
            throw new InstallerException(
                sprintf('Failed to create blog: %s', $e->getMessage()),
                3,
                $e,
            );
        }
    }


    /**
     * blog のドメインを options 優先、なければ config.server.php の DOMAIN にフォールバックする。
     *
     * 実際の解決は `InstallerOptions::effectiveBlogDomain()` が担う。Installer は
     * 「フォールバック値として DOMAIN 定数を提供する」役割だけ持つ。
     *
     * Web インストーラ (setup/install.php) は `blogDomain: DOMAIN` を明示的に渡しているが、
     * CLI 経由ではユーザが `--blog-domain` を毎回付けなくても同じ挙動になるようにここで吸収する。
     */
    private function resolveBlogDomain(InstallerOptions $options): string
    {
        $fallback = defined('DOMAIN') ? DOMAIN : null;
        return $options->effectiveBlogDomain($fallback);
    }

    private function assertNoExistingBlog(): void
    {
        try {
            $sql = SQL::newSelect('blog');
            $sql->setSelect('blog_id', 'count', null, 'count');
            $count = (int) DB::query($sql->get(dsn()), 'one');
        } catch (Throwable) {
            return;
        }
        if ($count > 0) {
            throw new InstallerException(
                'install() cannot run against a database that already contains a blog. '
                . 'Drop the existing data and try again.',
                6,
            );
        }
    }

    private function makeDryRunSeedResult(InstallerOptions $options): SiteSeedResult
    {
        return new SiteSeedResult(
            binName: $options->binName,
            blogId: 0,
            ids: [],
            counts: [],
            elapsedSec: 0.0,
        );
    }
}
