import { Test } from '@nestjs/testing';
import {
  earliestFuture,
  nextAgeBoundary,
  nextDayBoundary,
  nextWindowExpiry,
} from '../src/modules/persona/features/time-boundary';
import {
  FeatureRegistry,
  FEATURE_EXTRACTOR_PROVIDERS,
} from '../src/modules/persona/features/feature.registry';
import { classifyGapToLabel } from '../src/modules/persona/features/potential-treatment.feature';
import type { FeatureExtractorContext } from '../src/modules/persona/features/feature.interface';
import type { PotentialGap } from '../src/modules/clinical-gap/potential-treatment.selector';

/**
 * 「到期改档」调度锚点回归。
 *
 * 为什么需要:水位闸只在**事实变了**时放行重算 —— 这是对的,没有它 40 万患者天天全量重算。
 * 但有一类变化不伴随任何事实变动,时间自己走了就会改档:
 *   急迫等级跨 30/90 天线 · 生命周期跨 180/540/730 · RFM 的 R 跨 540/730/1095/1460
 *   年龄跨段 · 种植年龄禁忌满 19 岁解除 · 时间偏好/特别关注的滑动窗口把最老记录挤出去
 * 这类患者旧实现里永远等不到重算(年龄段、年龄禁忌**完全不依赖 fact**,最极端)。
 *
 * 解法不是"每天全量扫"(那等于把闸拆了),而是让每个特征报出**确切的到期时刻**,
 * 取最小值存 personas.next_boundary_at,夜扫只捞到期那一小撮。
 *
 * 本文件守两条:① 原语算得准;② 每个"会随时间改档"的特征都真的报了边界(漏报 = 静默不刷新)。
 */

const D = (s: string) => new Date(s);
const NOW = D('2026-07-26T00:00:00Z');

describe('时间边界原语', () => {
  test('nextDayBoundary:取第一个还在未来的阈值', () => {
    const last = D('2026-06-26T00:00:00Z'); // 30 天前
    // 30 天线已过 → 落到 90 天线
    expect(nextDayBoundary(last, NOW, [30, 90])?.toISOString()).toBe('2026-09-24T00:00:00.000Z');
  });

  test('⭐ nextDayBoundary:全部阈值都已跨过 → null(再等也不改档,别白捞)', () => {
    const long = D('2020-01-01T00:00:00Z');
    expect(nextDayBoundary(long, NOW, [30, 90])).toBeNull();
  });

  test('nextDayBoundary:无锚点(从没到诊)→ null', () => {
    expect(nextDayBoundary(null, NOW, [30, 90])).toBeNull();
  });

  test('⭐ nextAgeBoundary:下一个档位下界对应的那个生日', () => {
    const birth = D('1980-09-01T00:00:00Z'); // 2026-07-26 时 45 岁
    // 年龄档下界 …31,46,55…;下一个是 46 → 2026-09-01 生日
    expect(nextAgeBoundary(birth, NOW, [0, 3, 7, 12, 18, 26, 31, 46, 55])?.toISOString()).toBe(
      '2026-09-01T00:00:00.000Z',
    );
  });

  test('⭐ nextAgeBoundary:已过最高档(老年)→ null', () => {
    const birth = D('1940-01-01T00:00:00Z'); // 86 岁
    expect(nextAgeBoundary(birth, NOW, [0, 18, 55])).toBeNull();
  });

  test('nextAgeBoundary:无生日 → null', () => {
    expect(nextAgeBoundary(null, NOW, [18])).toBeNull();
  });

  test('⭐ nextWindowExpiry:窗内最早那条滑出窗口的时刻', () => {
    const times = [D('2025-01-10T00:00:00Z'), D('2026-05-01T00:00:00Z')];
    // 近 730 天窗口 → 窗内最早是 2025-01-10,滑出 = +730 天
    expect(nextWindowExpiry(times, 730, NOW)?.toISOString()).toBe('2027-01-10T00:00:00.000Z');
  });

  test('nextWindowExpiry:窗内无记录 → null(没东西可过期)', () => {
    expect(nextWindowExpiry([D('2010-01-01T00:00:00Z')], 365, NOW)).toBeNull();
    expect(nextWindowExpiry([null, undefined], 365, NOW)).toBeNull();
  });

  test('earliestFuture:忽略过去项,取最早的未来项', () => {
    expect(
      earliestFuture(NOW, D('2020-01-01Z'), D('2027-01-01T00:00:00Z'), D('2026-08-01T00:00:00Z'))?.toISOString(),
    ).toBe('2026-08-01T00:00:00.000Z');
    expect(earliestFuture(NOW, null, D('2020-01-01Z'))).toBeNull();
  });
});

// ── 特征级:确保"会随时间改档"的都报了边界 ──────────────────────────

function ctxOf(over: Partial<FeatureExtractorContext> = {}): FeatureExtractorContext {
  return {
    patient: {
      id: 'p1', externalId: 'E1', name: null, phone: null, gender: 'F',
      birthDate: D('1980-09-01T00:00:00Z'), active: true,
    },
    profile: null,
    factsByType: new Map(),
    relations: [],
    appointmentsAll: [],
    potentialGaps: [],
    now: NOW,
    populationStats: null,
    ...over,
  } as FeatureExtractorContext;
}

const fact = (over: Record<string, unknown>) =>
  ({ id: 'f1', subjectId: 's1', type: 'encounter_record', kind: 'actual', status: 'active',
     occurredAt: null, plannedFor: null, clinicId: null, content: {}, ...over }) as never;

async function extractors() {
  const mod = await Test.createTestingModule({
    providers: [FeatureRegistry, ...FEATURE_EXTRACTOR_PROVIDERS],
  }).compile();
  return mod.get(FeatureRegistry).extractors;
}

/** 会随纯时间流逝改档的特征 —— 少一个就意味着那个标签会静默停在旧档 */
const MUST_DECLARE_BOUNDARY = [
  'rfm', // R 分档界
  'lifecycle_stage', // 末诊/首诊天数档界
  'urgency_level', // 30 / 90 天线
  'age_bracket', // 年龄跨段(完全不依赖 fact)
  'contraindication', // 满 19 岁解除(完全不依赖 fact)
  'potential_treatment', // 标签映射里的年龄闸
  'time_preference', // 近 2 年滑窗
  'special_attention', // 近 1 年滑窗
];

describe('⭐ 红线:会随时间改档的特征必须声明 nextBoundaryAt', () => {
  test('清单里的都实现了', async () => {
    const byKey = new Map((await extractors()).map((e) => [e.key as string, e]));
    const missing = MUST_DECLARE_BOUNDARY.filter((k) => typeof byKey.get(k)?.nextBoundaryAt !== 'function');
    expect(missing).toEqual([]);
  });

  test('不随时间变的没多余声明(声明了就会被白捞重算)', async () => {
    const still = ['gender', 'acquisition_channel', 'family_structure', 'referral_champion',
                   'treatment_history', 'entitlement_status', 'discount_anchor', 'treatment_sensitivity'];
    const byKey = new Map((await extractors()).map((e) => [e.key as string, e]));
    const extra = still.filter((k) => typeof byKey.get(k)?.nextBoundaryAt === 'function');
    expect(extra).toEqual([]);
  });
});

describe('特征报出的边界值', () => {
  test('⭐ age_bracket:45 岁 → 下一个生日(满 46 进中老年)', async () => {
    const ex = (await extractors()).find((e) => e.key === 'age_bracket')!;
    expect(ex.nextBoundaryAt!(ctxOf())?.toISOString()).toBe('2026-09-01T00:00:00.000Z');
  });

  test('⭐ contraindication:未成年 → 满 19 岁那天;成年 → null', async () => {
    const ex = (await extractors()).find((e) => e.key === 'contraindication')!;
    const minor = ctxOf({ patient: { ...ctxOf().patient, birthDate: D('2010-03-05T00:00:00Z') } });
    expect(ex.nextBoundaryAt!(minor)?.toISOString()).toBe('2029-03-05T00:00:00.000Z');
    expect(ex.nextBoundaryAt!(ctxOf())).toBeNull(); // 45 岁
  });

  test('⭐ urgency_level:末诊 10 天前 → 30 天线', async () => {
    const ex = (await extractors()).find((e) => e.key === 'urgency_level')!;
    const ctx = ctxOf({
      factsByType: new Map([['encounter_record', [fact({ occurredAt: D('2026-07-16T00:00:00Z') })]]]),
    });
    expect(ex.nextBoundaryAt!(ctx)?.toISOString()).toBe('2026-08-15T00:00:00.000Z');
  });

  test('lifecycle_stage:取末诊/首诊两组档界里最早的未来项', async () => {
    const ex = (await extractors()).find((e) => e.key === 'lifecycle_stage')!;
    const ctx = ctxOf({
      factsByType: new Map([
        ['encounter_record', [
          fact({ id: 'a', occurredAt: D('2026-03-01T00:00:00Z') }), // 首诊:+180 → 2026-08-28
          fact({ id: 'b', occurredAt: D('2026-07-01T00:00:00Z') }), // 末诊:+180 → 2026-12-28
        ]],
      ]),
    });
    expect(ex.nextBoundaryAt!(ctx)?.toISOString()).toBe('2026-08-28T00:00:00.000Z');
  });

  test('⭐ potential_treatment:无 gap → null(本特征不产出,别白捞)', async () => {
    const ex = (await extractors()).find((e) => e.key === 'potential_treatment')!;
    expect(ex.nextBoundaryAt!(ctxOf())).toBeNull();
  });

  test('⭐ 年龄闸 AGE_GATES 与 classifyGapToLabel 的实际分界一致', () => {
    // 用 K07(早矫 3-12 / 正畸 13-40)和 K08(种植 >18)逐岁探边界,
    // 找出"标签发生变化"的年龄,必须与 nextAgeBoundary 用的闸值集合吻合。
    const gap = (code: string) => ({ primaryCode: code, tooth: '16', daysSince: 1, confidence: 1,
                                     signalType: 'diagnosis', factId: 'f', nameZh: '' }) as unknown as PotentialGap;
    const changes: number[] = [];
    for (let age = 1; age <= 60; age++) {
      for (const code of ['K07', 'K08']) {
        const prev = classifyGapToLabel(gap(code), age - 1)?.key ?? null;
        const cur = classifyGapToLabel(gap(code), age)?.key ?? null;
        if (prev !== cur) changes.push(age);
      }
    }
    expect([...new Set(changes)].sort((a, b) => a - b)).toEqual([3, 13, 19, 41]);
  });
});
