<?php

namespace App\Console\Commands;

use App\Helpers\LlmResponseParser;
use App\Http\Api\ChannelApi;
use App\Http\Resources\AiModelResource;
use App\Models\PaliText;
use App\Services\AIModelService;
use App\Services\OpenAIService;
use App\Services\SentenceService;
use App\Services\TermService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;

class ExtractPaliTerm extends Command
{
    // 断点缓存键：记录最后一个已完成的 chapter 游标 ['book'=>int,'paragraph'=>int]，
    // 使命令可重入——中断后重跑自动跳过已完成的 chapter
    private const CURSOR_KEY = 'extract:pali.term:cursor';

    /**
     * 用 LLM 从巴利文经文中提取术语。
     *
     * 以 pali_texts 表为准，扁平遍历每个 book 的 chapter（level 1-7）。从每个 book
     * 的第一个 chapter 开始，按 paragraph 顺序，每个 chapter 覆盖「本 chapter 起始段落
     * → 下一个 chapter 起始段落之前」的所有段落（含正文 bodytext）。把该 chapter 的
     * 巴利文（pali_texts.text 拼接）连同完整社区术语表发给大模型，引导其输出与本章相关
     * 的全部术语的巴利语单词（JSON 字符串数组），保存到系统术语 channel。
     *
     * 测试：
     *   php artisan extract:pali.term 554cdbc1-e170-4145-a808-3cc2cfa721c7 --book=93 --para=8
     *
     * @var string
     */
    protected $signature = 'extract:pali.term
    {model : LLM 模型 id}
    {--book= : 仅处理该 book；不传则遍历全部 book}
    {--para= : 仅处理包含该段落的 chapter（向下取所在 chapter）；需配合 --book}
    {--thinking= : 开启/关闭 deepseek thinking，true | false}
    {--fresh : 清除断点缓存，从头开始}';

    /**
     * The console command description.
     *
     * @var string
     */
    protected $description = '用 LLM 逐 chapter 提取巴利文相关术语并保存到系统术语 channel';

    /**
     * LLM 模型配置（含 model/url/key/uid，支持数组访问）。
     */
    protected AiModelResource $model;

    /**
     * 保存目标 channel（系统术语 channel）。
     */
    protected array $glossaryChannel;

    /**
     * 术语表词条原形集合（word => true），用于后置过滤：
     * 只保留确实存在于术语表的词，丢弃 LLM 输出的屈折变形或臆造词。
     *
     * @var array<string, bool>
     */
    protected array $glossarySet = [];

    protected ?bool $thinking = null;

    public function __construct(
        protected AIModelService $modelService,
        protected OpenAIService $openAIService,
        protected SentenceService $sentenceService,
        protected TermService $termService
    ) {
        parent::__construct();
    }

    /**
     * Execute the console command.
     */
    public function handle(): int
    {
        // ---------- 模型 ----------
        $model = $this->modelService->getModelById($this->argument('model'));
        if (! $model instanceof AiModelResource || $model->resource === null) {
            $this->error('invalid model id');

            return 1;
        }
        $this->model = $model;
        $this->info("model: {$this->model['model']}");

        if ($this->option('thinking') !== null) {
            $this->thinking = $this->option('thinking') === 'true';
        }

        // ---------- 保存目标 channel ----------
        $channelId = ChannelApi::getSysChannel('_system_glossary_');
        if (! $channelId) {
            $this->error('system glossary channel "_system_glossary_" not found');

            return 1;
        }
        $this->glossaryChannel = ChannelApi::getById($channelId);
        Log::info('extract:pali.term glossary channel', ['channel' => $channelId]);

        // ---------- 完整术语表（一次性加载，所有 chapter 共用）----------
        $glossaryJson = $this->loadGlossaryJson();
        if ($glossaryJson === null) {
            return 1;
        }

        if ($this->option('fresh')) {
            Cache::forget(self::CURSOR_KEY);
            $this->info('cleared checkpoint');
        }

        // ---------- 模式三：book + paragraph —— 忽略断点，只跑该 chapter ----------
        if ($this->option('book') && $this->option('para')) {
            $this->runSingleChapter((int) $this->option('book'), (int) $this->option('para'), $glossaryJson);

            return 0;
        }

        // --para 必须配合 --book
        if ($this->option('para')) {
            $this->error('--para must be used together with --book');

            return 1;
        }

        $cursor = Cache::get(self::CURSOR_KEY, []);

        // ---------- 模式二：仅 book —— 同一 book 从断点续，跑完即止，不跳到下一 book ----------
        if ($this->option('book')) {
            $book = (int) $this->option('book');
            // 断点是同一 book 才续跑，否则整本从头
            $resumeCursor = (! empty($cursor) && (int) $cursor['book'] === $book) ? $cursor : [];
            if (! empty($resumeCursor)) {
                $this->info("resume book {$book} from para {$resumeCursor['paragraph']}");
            }
            $this->runBook($book, $glossaryJson, $resumeCursor);

            return 0;
        }

        // ---------- 模式一：无参数 —— 从断点继续，跨 book 直到末尾 ----------
        $startBook = ! empty($cursor) ? (int) $cursor['book'] : 1;
        if (! empty($cursor)) {
            $this->info("resume from book {$cursor['book']} para {$cursor['paragraph']}");
        }
        for ($book = $startBook; $book <= 217; $book++) {
            // 仅断点所在的首 book 受游标限制，之后的 book 全量处理
            $this->runBook($book, $glossaryJson, $book === $startBook ? $cursor : []);
        }
        // 全部完成，清空断点
        Cache::forget(self::CURSOR_KEY);
        $this->info('all books completed');

        return 0;
    }

    /**
     * 加载完整术语表（dhamma_terms 全表、跨所有 channel 去重）并编码为发送给 LLM 的紧凑 JSON。
     *
     * @return string|null 编码后的术语表 JSON；术语表为空时返回 null
     */
    private function loadGlossaryJson(): ?string
    {
        $terms = $this->termService->getGlossary();

        if (empty($terms)) {
            $this->error('glossary is empty');

            return null;
        }

        // 词条原形集合，供 askLlm 后置过滤（O(1) 查找）
        $this->glossarySet = array_fill_keys($terms, true);

        $this->info('glossary loaded: '.count($terms).' terms (dhamma_terms + user_dicts)');

        return json_encode($terms, JSON_UNESCAPED_UNICODE);
    }

    /**
     * 处理单个 book 的全部 chapter（扁平遍历），逐 chapter 写入断点以支持重入。
     *
     * @param  array{book?: int, paragraph?: int}  $resumeCursor  断点游标；非空时跳过其
     *                                                            （含）之前已完成的 chapter
     */
    private function runBook(int $book, string $glossaryJson, array $resumeCursor = []): void
    {
        // 本书全部 chapter 节点（level 1-7），按 paragraph 顺序——扁平遍历，非树遍历
        $chapters = PaliText::where('book', $book)
            ->whereBetween('level', [1, 7])
            ->orderBy('paragraph')
            ->get(['paragraph', 'level', 'toc']);

        if ($chapters->isEmpty()) {
            Log::info('extract:pali.term no chapter', ['book' => $book]);

            return;
        }

        $maxParagraph = (int) PaliText::where('book', $book)->max('paragraph');

        foreach ($chapters as $chapter) {
            $start = (int) $chapter->paragraph;

            // 断点跳过：游标（含）之前的 chapter 视为已完成
            if ($this->isDone($resumeCursor, $book, $start)) {
                $this->info("skip {$book}-{$start} (done)");

                continue;
            }

            $end = $this->chapterEnd($book, $start, $maxParagraph);
            $this->processChapter($book, $start, $end, $glossaryJson);

            // 该 chapter 完成后写入断点（中途中断时不会误标记未完成的 chapter）
            Cache::put(self::CURSOR_KEY, ['book' => $book, 'paragraph' => $start], now()->addHours(48));
        }
    }

    /**
     * 模式三：处理 --para 所在的单个 chapter（向下取所在 chapter），不读写断点。
     */
    private function runSingleChapter(int $book, int $para, string $glossaryJson): void
    {
        // 向下取所在 chapter：≤para 的最近 chapter 节点（level 1-7）
        $target = PaliText::where('book', $book)
            ->where('paragraph', '<=', $para)
            ->whereBetween('level', [1, 7])
            ->orderBy('paragraph', 'desc')
            ->first(['paragraph']);

        if (! $target) {
            $this->error("no chapter found for book={$book} para={$para}");

            return;
        }

        $start = (int) $target->paragraph;
        $maxParagraph = (int) PaliText::where('book', $book)->max('paragraph');
        $end = $this->chapterEnd($book, $start, $maxParagraph);

        $this->processChapter($book, $start, $end, $glossaryJson);
    }

    /**
     * chapter 区间终点 = 下一个 chapter 起始段落之前；末章到本书结尾。
     */
    private function chapterEnd(int $book, int $start, int $maxParagraph): int
    {
        $next = PaliText::where('book', $book)
            ->where('paragraph', '>', $start)
            ->whereBetween('level', [1, 7])
            ->orderBy('paragraph')
            ->value('paragraph');

        return $next ? (int) $next - 1 : $maxParagraph;
    }

    /**
     * 判断某 chapter 是否在断点游标（含）之前——即已完成、应跳过。
     *
     * @param  array{book?: int, paragraph?: int}  $cursor
     */
    private function isDone(array $cursor, int $book, int $para): bool
    {
        if (empty($cursor)) {
            return false;
        }

        return $book < (int) $cursor['book']
            || ($book === (int) $cursor['book'] && $para <= (int) $cursor['paragraph']);
    }

    /**
     * 处理单个 chapter：拼接巴利文 → 调 LLM → 解析 → 保存。
     */
    private function processChapter(int $book, int $start, int $end, string $glossaryJson): void
    {
        $startAt = time();

        // chapter 巴利文 = 区间内全部段落 pali_texts.text 顺序拼接
        $paliText = PaliText::where('book', $book)
            ->whereBetween('paragraph', [$start, $end])
            ->orderBy('paragraph')
            ->pluck('text')
            ->filter(fn ($t) => filled($t))
            ->implode("\n");

        Log::info('extract:pali.term chapter', [
            'book' => $book,
            'start' => $start,
            'end' => $end,
            'pali_strlen' => mb_strlen($paliText),
        ]);

        if ($paliText === '') {
            $this->warn("skip empty chapter {$book}-{$start}");

            return;
        }

        $words = $this->askLlm($glossaryJson, $paliText);

        $this->saveChapterGlossary($book, $start, $words);

        $time = time() - $startAt;
        $this->info("chapter {$book}-{$start} (para {$start}-{$end}) => ".count($words)." terms, time={$time}s");
    }

    /**
     * 把术语表与 chapter 巴利文发给 LLM，返回相关术语的巴利语单词数组。
     *
     * @return array<int, string>
     */
    private function askLlm(string $glossaryJson, string $paliText): array
    {
        $sysPrompt = <<<'md'
        你是一位精通巴利语的佛教术语专家。

        ## 任务
        给定一段巴利文原文，以及一份术语表（巴利语单词列表），
        请从术语表中找出与这段经文相关的全部术语，并且输出。

        ## 要求
        - 输出的每个单词必须是给你的术语表中的单词。
        - 经文中的术语往往以屈折变化（变格、变位、复合、连音等）形式出现。你必须把这些
          变形还原、找到对应术语表中的词条，输出术语表里的那个原形，而不是经文中出现的变形。
          例如：经文中的 "brahmadattena"（具格）对应词条 "brahmadatta"，应输出 "brahmadatta"；
          "sattānaṃ" 对应 "satta"，输出 "satta"；"buddhassa" 对应 "buddha"，输出 "buddha"。
        - 绝不要输出经文里的屈折变形，也不要输出术语表中不存在的单词。
        - 复合词中局部包含术语的，也要输出。不要输出整个复合词，只输出其中包含的术语。
        - 只输出与本段经文内容确实相关的术语，不要输出无关术语。
        - 输出 JSON 数组，元素为术语表中的巴利语单词原形，不要包含释义或其他字段。

        ## 只保留有实义的术语，排除非术语词（即使它们出现在术语表中）
        术语指有独立含义的佛教/巴利专有概念、法相名词，或人名、地名、专有名词。
        请排除以下这类“非术语”的功能词与泛用词：
        - 语法虚词、不变词、连词、语气词：如 ca、vā、pana、kho、iti、hi、api、eva、ce、tu 等。
        - 代词、指示词、疑问词：如 so、ayaṃ、taṃ、idha、kiṃ、ya- 等。
        - 极常见的系动词、泛用动词及其变位：如 hoti、atthi、karoti 等纯功能性用法。
        - 其他你判断不构成独立术语的高频功能词、助词、量词等。
        判断标准：若去掉该词后不影响“这是一个值得收录为词条的概念/名相”，则视为非术语并排除。

        ## 输出格式（直接输出 JSON，无需任何额外说明）
        ["dukkha","nibbāna","saṅkhāra"]
        md;

        $userMessage = "## 术语表\n```json\n{$glossaryJson}\n```\n\n## 巴利文经文\n{$paliText}";

        $llm = $this->openAIService->setApiUrl($this->model['url'])
            ->setModel($this->model['model'])
            ->setApiKey($this->model['key'])
            ->setSystemPrompt($sysPrompt)
            ->setTemperature(0.3)
            ->setStream(false);
        if ($this->thinking !== null) {
            $llm = $llm->setThinking($this->thinking);
        }

        $response = $llm->send($userMessage);
        $content = is_array($response)
            ? ($response['choices'][0]['message']['content'] ?? '[]')
            : (string) $response;
        Log::debug('extract:pali.term llm response', ['content' => $content]);

        $parsed = LlmResponseParser::json($content);

        // 规范化：非空字符串、去重、去空白
        $candidates = [];
        foreach ($parsed as $item) {
            if (is_string($item) && trim($item) !== '') {
                $candidates[] = trim($item);
            }
        }
        $candidates = array_values(array_unique($candidates));

        // 后置过滤：归一为 NFC 后比对，只保留确实存在于术语表的词条原形，丢弃屈折变形/臆造词
        $words = [];
        $dropped = [];
        foreach ($candidates as $word) {
            $nfc = $this->toNfc($word);
            if (isset($this->glossarySet[$nfc])) {
                $words[] = $nfc;
            } else {
                $dropped[] = $word;
            }
        }

        if (! empty($dropped)) {
            Log::warning('extract:pali.term dropped non-glossary words', ['dropped' => $dropped]);
        }

        return $words;
    }

    /**
     * 将 chapter 术语（巴利语单词数组）以 JSON 保存为一条 Sentence 记录。
     *
     * paragraph 用 chapter 起始段落，整章一条记录故 word_start/word_end 均为 0。
     *
     * @param  array<int, string>  $words
     */
    private function saveChapterGlossary(int $book, int $startPara, array $words): void
    {
        try {
            $this->sentenceService->saveWithHistory([
                'book_id' => $book,
                'paragraph' => $startPara,
                'word_start' => 0,
                'word_end' => 0,
                'channel_uid' => $this->glossaryChannel['id'],
                'content' => json_encode($words, JSON_UNESCAPED_UNICODE),
                'content_type' => 'json',
                'lang' => $this->glossaryChannel['lang'] ?? 'pli',
                'status' => $this->glossaryChannel['status'] ?? 10,
                'editor_uid' => $this->model['uid'],
            ]);
        } catch (\Exception $e) {
            $this->error("save {$book}-{$startPara} failed: ".$e->getMessage());
            Log::error('extract:pali.term save failed', [
                'book' => $book,
                'paragraph' => $startPara,
                'error' => $e->getMessage(),
            ]);
            throw $e;
        }
    }
}
