import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import {
  classifyPopulation,
  type Skill,
  type SkillMatchContext,
  type ScriptTier,
} from './skill.types';
import type { DraftPlanScriptInput } from './input.types';
import { resolveBaseCommonPath, resolveBaseFormatPath } from './skill-registry.service';
import { forbiddenWordsBlock } from './safety-rules';

/**
 * SkillComposer — 纯函数式,把 input + 全 skills → matched skills + system prompt。
 *
 * 流程:
 *   1. 从 input 派生 SkillMatchContext(scenario / diagnosisCodes / age / population / relationship)
 *   2. 全 skills 过滤 applies match
 *   3. 跨维度排除(如 child + K08 → drop K08;allowedPopulation 列表非空时必须包含当前 population)
 *   4. 按 priority 升序排
 *   5. 拼装:base-system.md + matched skills body
 *   6. 算 composeHash(给 promptVersion 用,版本归因 SQL `GROUP BY promptVersion` 看效果)
 *
 * Composer 不持有状态,所有 input 走参数 — 易测、可 inline 在 call.ts 跑。
 */

export interface ComposedSystem {
  /** 拼好的完整 system prompt(base + 各 skill body) */
  systemPrompt: string;
  /** 匹配到的 skills(已按 priority 升序);测试 + agent_invocations 落账 + user prompt 末尾清单都用 */
  matchedSkills: Skill[];
  /** match context(给 user prompt build 用,避免重算) */
  context: SkillMatchContext;
  /** composeHash 给 promptVersion suffix 用,16 hex */
  composeHash: string;
}

/**
 * lazy load base —— common(共性,各档一份)+ format(每档一份),**按解析后的路径缓存**。
 *
 * ⚠️ 缓存键从 tier 改成 path,是因为 `formatPath` 覆盖(企微渠道)会让"同一个 tier
 *   对应两份 format" —— 还按 tier 缓存的话,先加载的那份会被另一个渠道复用,
 *   表现是企微稿写出分段的电话格式(或反过来),而且不报错。
 */
let cachedCommon: string | null = null;
const cachedFormat = new Map<string, string>();
function loadBase(tier: ScriptTier, formatPath?: string, timePlaceholders = true): string {
  if (cachedCommon === null) {
    cachedCommon = readFileSync(resolveBaseCommonPath(), 'utf-8').trim();
  }
  const path = formatPath ?? resolveBaseFormatPath(tier);
  let format = cachedFormat.get(path);
  if (format === undefined) {
    format = readFileSync(path, 'utf-8').trim();
    cachedFormat.set(path, format);
  }
  // 顺序:共性定位/铁律 → 本档输出格式 → 禁词(单一源)
  return `${cachedCommon}\n\n${format}\n\n${forbiddenWordsBlock({ timePlaceholders })}`;
}

/**
 * 从 input 派生 match context — orchestrator 已在 reason 上直传 scenario raw key + subKey + dxCode,
 * composer 这里不做文本推断,纯字段映射。
 */
export function deriveContext(input: DraftPlanScriptInput): SkillMatchContext {
  const reasons = input.plan.reasons ?? [];
  const scenario = input.plan.primaryScenarioKey ?? null;
  const diagnosisCodes = Array.from(
    new Set(reasons.map((r) => r.dxCode).filter((c): c is string => !!c)),
  );
  const age = input.patient.age ?? null;
  return {
    scenario,
    diagnosisCodes,
    age,
    population: classifyPopulation(age),
    relationship: input.clinicalContext.completedTreatmentCount === 0 ? 'new' : 'returning',
  };
}

/** 一个 skill 是否匹配当前 context */
export function skillApplies(skill: Skill, ctx: SkillMatchContext): boolean {
  const a = skill.frontmatter.applies;
  if (a.scenario && a.scenario !== ctx.scenario) return false;
  if (a.diagnosisCodePrefix) {
    const hit = ctx.diagnosisCodes.some((c) =>
      c.startsWith(a.diagnosisCodePrefix!),
    );
    if (!hit) return false;
  }
  // 年龄未知(null):按业务规格"未知 → 默认成人模板"——通过 ageMin(成人/成人以上),
  //   但仍被 ageMax 排除(child 的 ageMax 命中 null → 不匹配),所以 null 只落成人。
  if (a.ageMin !== undefined && ctx.age !== null && ctx.age < a.ageMin) return false;
  if (a.ageMax !== undefined && (ctx.age === null || ctx.age > a.ageMax)) return false;
  if (a.relationship && a.relationship !== ctx.relationship) return false;
  // allowedPopulation 跨维度排除:非空数组时,当前 population 必须在列表内
  if (
    skill.frontmatter.allowedPopulation.length > 0 &&
    (ctx.population === null ||
      !skill.frontmatter.allowedPopulation.includes(ctx.population))
  ) {
    return false;
  }
  return true;
}

/** skill 是否适用该档:tiers 空 = 全档共用(如人群共性知识);非空需包含当前档 */
export function skillTierOk(skill: Skill, tier: ScriptTier): boolean {
  const t = skill.frontmatter.tiers;
  return t.length === 0 || t.includes(tier);
}

/**
 * Compose 主入口(tier-aware)。
 *   base = common(共性) + format(该档) + 禁词(单一源);
 *   skills = applies 命中 且 该档适用(tiers 过滤)。
 */
/**
 * @param formatPath ⭐ **输出格式覆盖**(可选)—— 企微渠道用。
 *   `tier` 仍传 `'deep'`:它决定**挑哪些 skill**(人群共性、深度档知识),那部分与渠道无关;
 *   但输出形态(电话=分段 sections / 企微=一整块可发送消息)完全不同,只换这一份 format.md。
 *   ⛔ 别为此往 `ScriptTier` 里加 `'wecom'` —— tier 是**质量档**,渠道是另一个维度,
 *   混进同一个枚举之后"深度档企微"这种组合就表达不出来了。
 */
export function composeSystem(
  input: DraftPlanScriptInput,
  allSkills: readonly Skill[],
  tier: ScriptTier = 'stable',
  formatPath?: string,
  /**
   * 是否保留【时间段】占位。电话档 true;⛔ **企微必须 false** ——
   * 不然禁词块里那句「占位标签照旧保留」会跟企微 format.md 的「一个都不许出现」
   * 拼进同一份 system 打架,模型照哪条做全看运气。
   */
  timePlaceholders = true,
): ComposedSystem {
  const context = deriveContext(input);
  const matched = allSkills
    .filter((s) => skillApplies(s, context) && skillTierOk(s, tier))
    .sort(
      (a, b) =>
        (a.frontmatter.priority ?? 50) - (b.frontmatter.priority ?? 50),
    );

  const base = loadBase(tier, formatPath, timePlaceholders);
  // 只拼 body — 内部 skill name/version 不进提示词(版本归因走 composeHash,见下)
  const skillsBlock = matched.map((s) => s.body).join('\n\n---\n\n');

  const systemPrompt = skillsBlock
    ? `${base}\n\n# 本次适用知识 / 模板\n\n${skillsBlock}`
    : base;

  // composeHash = sha256(tier + format 覆盖 + matched.name@version)前 16 hex
  // ⚠️ formatPath 必须进哈希:同 tier 同 skills 但换了输出格式(电话/企微)是**两套 system**,
  //   不进哈希的话两者算出同一个 composeHash → promptVersion 撞车 → eval 里两个渠道的效果混在一起。
  const hashSrc = [
    tier,
    ...(formatPath ? [`fmt:${formatPath.split('/').slice(-4).join('/')}`] : []),
    ...matched.map((s) => `${s.frontmatter.name}@${s.frontmatter.version}`),
  ].join('|');
  const composeHash = createHash('sha256').update(hashSrc).digest('hex').slice(0, 16);

  return { systemPrompt, matchedSkills: matched, context, composeHash };
}
