<?php

namespace Acms\Services\SiteSeed;

use Acms\Services\Blog\BlogRepository;
use Acms\Services\Blog\Import as BlogImport;
use Acms\Services\Facades\Application;
use Acms\Services\Facades\Cache;
use Acms\Services\Facades\Database as DB;
use Acms\Services\Facades\LocalStorage;
use Acms\Services\SiteSeed\Contracts\SiteSeedLogger;
use Acms\Services\SiteSeed\Logger\CollectingSiteSeedLogger;
use SQL;
use Symfony\Component\Yaml\Yaml;
use Throwable;

/**
 * SiteSeed メインオーケストレーター
 *
 * state-less。seed() 呼び出しごとに以下を実行する:
 *
 *  1. bin パス解決 (yaml / archives / media / storage / themes)
 *  2. 冪等性チェック (asChild=true なら親 blog 存在 / asChild=false なら対象 blog 存在)
 *  3. asChild=true なら新規子 blog を作成、false なら targetBlogId をそのまま使う
 *  4. yaml 内の "001/" 等のアーカイブパスを新 blog_id に書き換え
 *  5. Acms\Services\Blog\Import::run() に DB 投入を委譲
 *  6. bin のファイル群 (archives/media/storage/themes) を SCRIPT_DIR 配下にコピー
 *  7. キャッシュ全クリア
 *
 * 旧 setup/lib/import.php への依存は持たない。setup/ ディレクトリが
 * 削除された本番環境でもクラスのロード自体は可能 (実際の投入には setup/bin/
 * のデータが必要なので、そちらが無ければエラーになる)。
 */
class SiteSeeder
{
    public function __construct(
        private readonly SiteSeedPathResolver $paths,
        private readonly ?SiteSeedDestination $destination = null,
        // ブログ作成（nested-set 予約＋INSERT）は共通の BlogRepository に集約。
        private readonly BlogRepository $blogRepository = new BlogRepository(),
    ) {
    }

    /**
     * コピー先ディレクトリを解決する。
     * コンストラクタで指定されなければ SCRIPT_DIR 配下を使う。
     */
    private function destination(): SiteSeedDestination
    {
        return $this->destination ?? SiteSeedDestination::fromScriptDir();
    }

    public function paths(): SiteSeedPathResolver
    {
        return $this->paths;
    }

    public function seed(SiteSeedOptions $options, ?SiteSeedLogger $logger = null): SiteSeedResult
    {
        $logger ??= new CollectingSiteSeedLogger();
        $started = microtime(true);

        $path = $this->paths->resolve($options->binName);
        $logger->info(sprintf('Loading bin "%s": %s', $options->binName, $path->yaml));

        $yamlContent = $this->readYaml($path->yaml);
        $yamlData = Yaml::parse($yamlContent);
        if (!is_array($yamlData)) {
            throw new SiteSeedException(
                sprintf('Failed to load yaml: %s', $path->yaml),
                2,
            );
        }

        $this->assertIdempotency($options);
        $effectiveUserId = $this->resolveUserId($options, $logger);

        if ($options->dryRun) {
            $logger->info('dry-run mode: skipping actual seed');
            return new SiteSeedResult(
                binName: $options->binName,
                blogId: $options->asChild ? 0 : (int) $options->targetBlogId,
                ids: [],
                counts: $this->summariseCounts($yamlData),
                elapsedSec: round(microtime(true) - $started, 3),
            );
        }

        $targetBlogId = $this->ensureTargetBlog($options, $logger);
        $logger->progress('prepare blog', 1, 4);

        // yaml 内のアーカイブパス (001/...) を新 blog_id プレフィックスに書き換え
        $fixedYamlContent = $this->rewriteArchivesPrefix($yamlContent, $targetBlogId);

        $warnings = [];

        /** @var BlogImport $blogImport */
        $blogImport = Application::make('blog.import');
        // entry_last_update_user_id 等で使う user_id は run の第 3 引数で渡す。
        // resolveUserId で「指定値 or DB 最小 user_id」をフォールバックとして確定済み。
        $errors = $blogImport->run($fixedYamlContent, $targetBlogId, $effectiveUserId);
        // @codeCoverageIgnoreStart
        // BlogImport::run が errors を返すのは yaml データが部分的に投入失敗した時。
        // テスト用 fixture では正常パスのみ流れるため、error ループは Integration 環境では到達しにくい。
        if (count($errors) > 0) {
            foreach ($errors as $error) {
                $logger->warning('import warning: ' . $error);
                $warnings[] = $error;
            }
        }
        // @codeCoverageIgnoreEnd
        $logger->progress('seed data', 2, 4);

        // ファイル系を SCRIPT_DIR 配下にコピー (001/ → {blog_id}/ で配置)
        $this->copyBinFiles($path, $targetBlogId, $logger, $warnings);
        $logger->progress('copy files', 3, 4);

        if (!$options->skipCacheFlush) {
            try {
                Cache::allFlush();
                // @codeCoverageIgnoreStart
                // Cache 実装の例外はテスト環境で再現しにくい
            } catch (Throwable $e) {
                $warnings[] = 'cache flush failed: ' . $e->getMessage();
                $logger->warning($warnings[count($warnings) - 1]);
            }
            // @codeCoverageIgnoreEnd
        } else {
            $logger->info('Skipping cache flush due to --skip-cache-flush');
        }
        $logger->progress('done', 4, 4);

        $elapsed = round(microtime(true) - $started, 3);
        $logger->info(sprintf('blog_id=%d, elapsed %.3f sec', $targetBlogId, $elapsed));

        return new SiteSeedResult(
            binName: $options->binName,
            blogId: $targetBlogId,
            ids: [],
            counts: $this->summariseCounts($yamlData),
            elapsedSec: $elapsed,
            warnings: $warnings,
        );
    }

    private function readYaml(string $path): string
    {
        if (!LocalStorage::isFile($path)) {
            throw new SiteSeedException(sprintf('Failed to load yaml: %s', $path), 2);
        }
        // LocalStorage::get は Web 公開範囲外 (tests/ や別パス配置) のファイルを traversal
        // 防御で拒否するため、ここでは生 file_get_contents を使う。存在チェックは LocalStorage
        // 経由で済ませてあるので、失敗するのは I/O 競合状態のみ。
        $content = @file_get_contents($path);
        // @codeCoverageIgnoreStart
        if ($content === false) {
            throw new SiteSeedException(sprintf('Failed to load yaml: %s', $path), 2);
        }
        // @codeCoverageIgnoreEnd
        return $content;
    }

    private function ensureTargetBlog(SiteSeedOptions $options, SiteSeedLogger $logger): int
    {
        if ($options->asChild) {
            return $this->createBlog((int) $options->targetBlogId, $options, $logger);
        }
        return (int) $options->targetBlogId;
    }

    /**
     * 子 blog を作成し、`blog_id` を返す。
     *
     * nested-set の予約と INSERT は BlogRepository のプリミティブ（findLastChildInParent /
     * findBoundary / shiftLeftRightAfter / nextBlogId / insert）を合成して行う（管理画面
     * ACMS_POST_Blog_Insert と同じ作法。生 SQL は BlogRepository に集約済み）。
     */
    private function createBlog(int $parentBlogId, SiteSeedOptions $options, SiteSeedLogger $logger): int
    {
        $code = $options->blogCode ?? $options->binName;
        $name = $options->blogName ?? $options->binName;
        $domain = $options->blogDomain ?? $this->lookupParentDomain($parentBlogId);

        $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);

        $newBlogId = $this->blogRepository->nextBlogId();
        $this->blogRepository->insert([
            'blog_id' => $newBlogId,
            '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'),
        ]);

        $logger->info(sprintf(
            '新規 blog を作成: blog_id=%d (parent_blog_id=%d, blog_code=%s)',
            $newBlogId,
            $parentBlogId,
            $code,
        ));
        return $newBlogId;
    }

    private function lookupParentDomain(int $parentBlogId): string
    {
        // @codeCoverageIgnoreStart
        // SiteSeedOptions::validate で targetBlogId > 0 を強制しているため、SiteSeeder 経由では到達不能。
        // Installer が直接 createBlog を呼ぶ経路 (root blog 作成) でのみ通る dead path。
        if ($parentBlogId === 0) {
            return '';
        }
        // @codeCoverageIgnoreEnd
        $sql = SQL::newSelect('blog');
        $sql->setSelect('blog_domain');
        $sql->addWhereOpr('blog_id', $parentBlogId);
        $sql->setLimit(1);
        $value = DB::query($sql->get(dsn()), 'one');
        return is_string($value) ? $value : '';
    }

    /**
     * yaml 内の "DDD/..." 形式 (media/storage の blog_id プレフィックス) を新 blog_id で書き換え
     *
     * ACMS_POST_Backup_BlogImport::fixYaml と同じ正規表現。
     *
     * Installer (複数 bin 投入) でも同じロジックが必要なため public で公開している。
     */
    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 の archives/media/storage/themes を destination 配下にコピーする。
     *
     * Installer (複数 bin 投入) でも同じ処理が必要なため public で公開している。
     *
     * @param list<string> $warnings
     */
    public function copyBinFiles(SiteSeedPath $path, int $targetBlogId, SiteSeedLogger $logger, array &$warnings): void
    {
        $blogIdPrefix = sprintf('%03d', $targetBlogId);
        $destination = $this->destination();

        $pairs = [
            [$path->archives, $destination->archivesDir, 'archives'],
            [$path->media, $destination->mediaDir, 'media'],
            [$path->storage, $destination->storageDir, 'storage'],
        ];

        foreach ($pairs as [$from, $to, $label]) {
            $sourceWithPrefix = rtrim($from, '/') . '/001/';
            if (!LocalStorage::isDirectory($sourceWithPrefix)) {
                // prefix 付きが無ければ、親ディレクトリごとコピー (古い形式向けフォールバック)
                if (LocalStorage::isDirectory($from)) {
                    $this->copyDir($from, $to, $label, $logger, $warnings);
                }
                continue;
            }
            $this->copyDir($sourceWithPrefix, rtrim($to, '/') . '/' . $blogIdPrefix . '/', $label, $logger, $warnings);
        }

        // テーマは blog_id プレフィックス無し
        if (LocalStorage::isDirectory($path->themes)) {
            $this->copyDir($path->themes, $destination->themesDir, 'themes', $logger, $warnings);
        }
    }

    /**
     * @param list<string> $warnings
     */
    private function copyDir(string $from, string $to, string $label, SiteSeedLogger $logger, array &$warnings): void
    {
        try {
            LocalStorage::copyDirectory($from, $to);
            $logger->info(sprintf('Copied %s: %s → %s', $label, $from, $to));
            // @codeCoverageIgnoreStart
            // Storage 実装の例外はテスト環境で再現しにくい
        } catch (Throwable $e) {
            $warnings[] = sprintf('Failed to copy %s: %s', $label, $e->getMessage());
            $logger->warning($warnings[count($warnings) - 1]);
        }
        // @codeCoverageIgnoreEnd
    }

    private function assertIdempotency(SiteSeedOptions $options): void
    {
        $label = $options->asChild ? 'parent' : 'destination';
        $this->assertBlogExists((int) $options->targetBlogId, $label);
    }

    /**
     * `entry_last_update_user_id` 等に挿入する user_id を確定する。
     *
     * 優先順位:
     *   1. `$options->userId` が指定されていれば、その値を検証して採用
     *      (正の整数 + DB に存在)
     *   2. 未指定なら、DB 内の最小 `user_id` を fallback として採用
     *   3. DB に user が 1 件も存在しない場合はエラー
     *
     * 旧実装では未指定時に固定で 1 を使っていたが、存在しない user を
     * 参照する孤児行を作る危険があったため廃止。
     */
    private function resolveUserId(SiteSeedOptions $options, SiteSeedLogger $logger): int
    {
        if ($options->userId !== null) {
            if ($options->userId <= 0) {
                throw new SiteSeedException(
                    sprintf('userId must be a positive integer: %d', $options->userId),
                    1,
                );
            }
            if (!$this->userExists($options->userId)) {
                throw new SiteSeedException(
                    sprintf('user with userId=%d not found in database', $options->userId),
                    2,
                );
            }
            return $options->userId;
        }

        $minUserId = $this->findSmallestUserId();
        if ($minUserId === null) {
            throw new SiteSeedException(
                'userId is not specified and no user exists in the database; cannot fall back. '
                . 'Specify --user-id explicitly, or use Installer::install() to create an admin user first.',
                2,
            );
        }

        $logger->info(sprintf(
            'userId not specified; falling back to the smallest user_id=%d in the database',
            $minUserId,
        ));
        return $minUserId;
    }

    private function userExists(int $userId): bool
    {
        $sql = SQL::newSelect('user');
        $sql->setSelect('user_id');
        $sql->addWhereOpr('user_id', $userId);
        $row = DB::query($sql->get(dsn()), 'row');
        return is_array($row) && count($row) > 0;
    }

    private function findSmallestUserId(): ?int
    {
        $sql = SQL::newSelect('user');
        $sql->setSelect('user_id', 'min_user_id', null, 'min');
        $value = DB::query($sql->get(dsn()), 'one');
        if ($value === null || $value === '' || $value === false) {
            return null;
        }
        $candidate = (int) $value;
        return $candidate > 0 ? $candidate : null;
    }

    private function assertBlogExists(int $blogId, string $label): void
    {
        // @codeCoverageIgnoreStart
        // SiteSeedOptions::validate で targetBlogId > 0 が強制されているため到達不能 dead path。
        // 直接 assertBlogExists を呼ぶ将来の経路への defensive 防御として残す。
        if ($blogId <= 0) {
            throw new SiteSeedException(
                sprintf('%s blog_id is not specified or is <= 0', $label),
                1,
            );
        }
        // @codeCoverageIgnoreEnd
        $sql = SQL::newSelect('blog');
        $sql->setSelect('blog_id');
        $sql->addWhereOpr('blog_id', $blogId);
        $row = DB::query($sql->get(dsn()), 'row');
        if (!is_array($row) || count($row) === 0) {
            throw new SiteSeedException(
                sprintf('%s blog_id=%d not found in database', $label, $blogId),
                2,
            );
        }
    }

    /**
     * @param array<string, mixed> $yamlData
     * @return array<string, int>
     */
    private function summariseCounts(array $yamlData): array
    {
        $tables = [
            'blog', 'category', 'entry', 'tag', 'column', 'module', 'rule',
            'config', 'config_set', 'layout_grid', 'dashboard', 'field',
            'media', 'media_tag', 'user', 'form', 'schedule', 'comment',
        ];
        $counts = [];
        foreach ($tables as $table) {
            $counts[$table] = isset($yamlData[$table]) && is_array($yamlData[$table])
                ? count($yamlData[$table])
                : 0;
        }
        return $counts;
    }
}
