import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../infrastructure/database/prisma.service';
import { PriorityRulesService } from './priority-rules.service';

/** Protocol version negotiated with Runtime via capabilities.supportedSnapshotVersions. Bump on schema changes. */
const SNAPSHOT_VERSION = 'ai_snapshot_v1';

/** Tracks aggregation logic version. Bump when computeSignals, getScoreWeights, classifyMasteryLevel, or window constants change. */
export const SOURCE_DATA_VERSION = '1.0';

/** Aggregation windows */
const BEHAVIOR_WINDOW_DAYS = 7;
const SCORE_WINDOW_DAYS = 30;

/** Result limits */
const MAX_DAILY_ACTIVITIES = 7;
const MAX_RECENT_RECORDS = 20;
const MAX_MATERIALS = 50;
const MAX_KNOWLEDGE_ITEMS = 200;
const MAX_RECENT_ANALYSES = 5;

/** Snapshot expires after 24 hours by default */
const SNAPSHOT_TTL_HOURS = 24;

interface DailyActivitySlice {
  activityDate: Date;
  durationSeconds: number;
  sessionsCount: number;
  readingSeconds: number;
  materialsReadCount: number;
  activeRecallCount: number;
  reviewCount: number;
  aiAnalysisCount: number;
  activityLevel: number;
}

interface TodayActivitySlice {
  durationSeconds: number;
  sessionsCount: number;
  activityLevel: number;
}

interface StreakRecordSlice {
  length: number;
  startDate: Date;
  endDate: Date;
}

interface ReadingEventSlice {
  clientTimestampMs: bigint;
  clientTimezoneOffsetMinutes: number | null;
}

interface SessionSlice {
  id: string;
  totalActiveSeconds: number;
  status: string;
  startedAt: Date;
}

interface ProgressAggregateSlice {
  _sum: { totalActiveSeconds: number | null; sessionCount: number | null };
  _count: number;
}

@Injectable()
export class SnapshotBuilderService {
  private readonly logger = new Logger(SnapshotBuilderService.name);

  constructor(
    private readonly prisma: PrismaService,
    private readonly priorityRules: PriorityRulesService,
  ) {}

  async buildSnapshot(userId: string, targetType: string, targetId: string) {
    try {
      const [settings, profile] = await Promise.all([
        this.prisma.userAiSettings.findUnique({ where: { userId } }),
        this.prisma.userLearningProfile.findUnique({ where: { userId } }),
      ]);

      const privacyScope = {
        allowDocumentContent: settings?.allowUseDocumentContent ?? false,
        allowLearningBehavior: settings?.allowUseLearningBehavior ?? true,
        allowUserProfile: settings?.allowUseUserProfile ?? true,
      };

      const behaviorData = privacyScope.allowLearningBehavior
        ? await this.fetchBehaviorData(userId, targetType, targetId)
        : null;

      const [
        learningBehaviorSummary,
        materialProgressSummary,
        contentStructureSummary,
        behaviorSignals,
        scoreSignals,
        deviceContext,
      ] = await Promise.all([
        behaviorData
          ? this.aggregateBehavior(userId, targetType, targetId, behaviorData)
          : undefined,
        this.aggregateProgress(userId, targetType, targetId),
        privacyScope.allowDocumentContent
          ? this.aggregateContent(userId, targetType, targetId)
          : undefined,
        behaviorData
          ? this.calculateBehaviorSignals(userId, behaviorData)
          : undefined,
        this.calculateScores(userId, profile?.qualityPreference ?? 'standard'),
        this.buildDeviceContext(userId),
      ]);

      const expiresAt = new Date(Date.now() + SNAPSHOT_TTL_HOURS * 60 * 60 * 1000);

      const snapshot = await this.prisma.learningAnalysisSnapshot.create({
        data: {
          userId,
          scopeType: targetType,
          scopeId: targetId,
          snapshotVersion: SNAPSHOT_VERSION,
          sourceDataVersion: SOURCE_DATA_VERSION,
          privacyScope: privacyScope as any,
          userProfile: privacyScope.allowUserProfile ? this.pickSafeFields(profile) : undefined,
          aiSettings: settings ? this.pickAiSettingsFields(settings) : undefined,
          deviceContext: deviceContext as any,
          learningBehaviorSummary: learningBehaviorSummary as any,
          materialProgressSummary: materialProgressSummary as any,
          contentStructureSummary: contentStructureSummary as any,
          behaviorSignals: behaviorSignals as any,
          scoreSignals: scoreSignals as any,
          constraints: {
            dailyAvailableMinutes: profile?.dailyAvailableMinutes ?? null,
            qualityPreference: profile?.qualityPreference ?? 'standard',
            learningGoal: profile?.learningGoal ?? null,
            priorityRules: this.priorityRules.computePriorityRules(profile, settings, targetType, targetId) as any,
          },
          allowedModelFields: this.buildAllowedFields(settings, profile),
          expiresAt,
        },
      });

      return snapshot;
    } catch (error: any) {
      this.logger.error(
        `Snapshot build failed for user=${userId} scope=${targetType}/${targetId}: ${error.message}`,
        error.stack,
      );
      throw error;
    }
  }

  // ── Field Pickers ──

  private pickSafeFields(profile: any) {
    if (!profile) return undefined;
    return {
      learningGoal: profile.learningGoal,
      currentLevel: profile.currentLevel,
      qualityPreference: profile.qualityPreference,
      ageRange: profile.ageRange,
      preferredLanguage: profile.preferredLanguage,
      learningStyle: profile.learningStyle,
      examTarget: profile.examTarget,
      preferredQuestionTypes: profile.preferredQuestionTypes,
    };
  }

  private pickAiSettingsFields(settings: any) {
    if (!settings) return undefined;
    return {
      apiKeyMode: settings.apiKeyMode,
      defaultCredentialId: settings.defaultCredentialId,
      fallbackToPlatformKey: settings.fallbackToPlatformKey,
      maxDailyAiJobs: settings.maxDailyAiJobs,
      maxDailyTokenBudget: settings.maxDailyTokenBudget,
    };
  }

  // ── Shared Behavior Data ──

  private async fetchBehaviorData(userId: string, targetType: string, targetId: string) {
    const now = new Date();
    const weekAgo = new Date(now.getTime() - BEHAVIOR_WINDOW_DAYS * 24 * 60 * 60 * 1000);

    const sessionWhere: any = { userId };
    if (targetType && targetId) {
      if (targetType === 'material') {
        sessionWhere.materialId = targetId;
      } else if (targetType === 'knowledge_base') {
        sessionWhere.knowledgeBaseId = targetId;
      }
    }

    const [
      sessionAgg,
      weeklyScopedSessions,
      weeklyGlobalSessions,
      dailyActivities,
      todayActivity,
    ] = await Promise.all([
      this.prisma.learningSession.aggregate({
        where: sessionWhere,
        _sum: { totalActiveSeconds: true },
        _count: true,
      }),
      this.prisma.learningSession.count({
        where: { ...sessionWhere, startedAt: { gte: weekAgo } },
      }),
      this.prisma.learningSession.count({
        where: { userId, startedAt: { gte: weekAgo } },
      }),
      this.prisma.dailyLearningActivity.findMany({
        where: { userId, activityDate: { gte: weekAgo } },
        orderBy: { activityDate: 'desc' },
        take: MAX_DAILY_ACTIVITIES,
        select: {
          activityDate: true,
          durationSeconds: true,
          sessionsCount: true,
          readingSeconds: true,
          materialsReadCount: true,
          activeRecallCount: true,
          reviewCount: true,
          aiAnalysisCount: true,
          activityLevel: true,
        },
      }),
      this.prisma.dailyLearningActivity.findUnique({
        where: {
          userId_activityDate: { userId, activityDate: new Date(now.toISOString().split('T')[0]) },
        },
        select: { durationSeconds: true, sessionsCount: true, activityLevel: true },
      }),
    ]);

    const activeDaysCount = dailyActivities.filter(d => d.durationSeconds > 0).length;

    return {
      sessionAgg,
      weeklyScopedSessions,
      weeklyGlobalSessions,
      dailyActivities,
      activeDaysCount,
      todayActivity,
    };
  }

  // ── Aggregators ──

  /**
   * Aggregate learning behavior data.
   *
   * Scope support:
   * - LearningSession: scoped by targetType/targetId (materialId or knowledgeBaseId)
   * - DailyLearningActivity: user-global (model lacks materialId/knowledgeBaseId fields)
   * - LearningRecord: user-global (model lacks materialId/knowledgeBaseId fields)
   */
  private async aggregateBehavior(
    userId: string,
    targetType: string,
    targetId: string,
    shared: Awaited<ReturnType<SnapshotBuilderService['fetchBehaviorData']>>,
  ) {
    const recentRecords = await this.prisma.learningRecord.findMany({
      where: { userId },
      orderBy: { occurredAt: 'desc' },
      take: MAX_RECENT_RECORDS,
      select: {
        id: true,
        recordType: true,
        title: true,
        durationSeconds: true,
        occurredAt: true,
      },
    });

    return {
      totalSessions: shared.sessionAgg._count,
      totalActiveSeconds: shared.sessionAgg._sum.totalActiveSeconds ?? 0,
      weeklySessions: shared.weeklyScopedSessions,
      dailyActivities: shared.dailyActivities,
      recentRecords,
    };
  }

  private async aggregateProgress(userId: string, targetType: string, targetId: string) {
    const where: any = { userId };
    if (targetType && targetId) {
      if (targetType === 'knowledge_base') {
        where.knowledgeBaseId = targetId;
      } else {
        where.readingTargetType = targetType;
        where.materialId = targetId;
      }
    }

    const progress = await this.prisma.materialReadingProgress.findMany({
      where,
      select: {
        materialId: true,
        readingTargetType: true,
        knowledgeBaseId: true,
        status: true,
        lastProgress: true,
        totalActiveSeconds: true,
        sessionCount: true,
        lastReadAt: true,
        isMarkedRead: true,
      },
      orderBy: { lastReadAt: 'desc' },
      take: MAX_MATERIALS,
    });

    return {
      totalMaterials: progress.length,
      completedCount: progress.filter(p => p.status === 'completed').length,
      inProgressCount: progress.filter(p => p.status === 'in_progress').length,
      totalActiveSeconds: progress.reduce((sum, p) => sum + p.totalActiveSeconds, 0),
      materials: progress,
    };
  }

  private async aggregateContent(userId: string, targetType: string, targetId: string) {
    if (!targetId) return undefined;

    if (targetType === 'knowledge_base') {
      const items = await this.prisma.knowledgeItem.findMany({
        where: { userId, knowledgeBaseId: targetId, status: 'active', deletedAt: null },
        select: {
          id: true,
          itemType: true,
          title: true,
          summary: true,
          learnable: true,
          orderIndex: true,
          durationSeconds: true,
        },
        orderBy: { orderIndex: 'asc' },
        take: MAX_KNOWLEDGE_ITEMS,
      });

      return {
        itemCount: items.length,
        items,
      };
    }

    if (targetType === 'material') {
      const progress = await this.prisma.materialReadingProgress.findFirst({
        where: { userId, materialId: targetId },
        select: {
          materialId: true,
          readingTargetType: true,
          lastPosition: true,
          lastProgress: true,
        },
      });

      return {
        materialId: targetId,
        lastPosition: progress?.lastPosition,
        lastProgress: progress?.lastProgress,
      };
    }

    this.logger.warn(`aggregateContent: unsupported targetType="${targetType}", returning undefined`);
    return undefined;
  }

  // ── Score Calculator ──

  private async calculateScores(userId: string, qualityPreference: string) {
    const normalizedPref = this.normalizeQualityPreference(qualityPreference);

    const now = new Date();
    const monthAgo = new Date(now.getTime() - SCORE_WINDOW_DAYS * 24 * 60 * 60 * 1000);

    const [quizAgg, quizAttempts, reviewLogs, recentAnalyses, activeWeakPoints] = await Promise.all([
      this.prisma.quizAttempt.aggregate({
        where: { userId, startedAt: { gte: monthAgo } },
        _avg: { score: true, correctCount: true, totalQuestions: true },
        _count: true,
      }),
      this.prisma.quizAttempt.findMany({
        where: { userId, startedAt: { gte: monthAgo } },
        select: { score: true, startedAt: true },
        orderBy: { startedAt: 'asc' },
        take: 100,
      }),
      this.prisma.reviewLog.findMany({
        where: { userId, reviewedAt: { gte: monthAgo } },
        select: { rating: true, reviewedAt: true },
        orderBy: { reviewedAt: 'asc' },
        take: 200,
      }),
      this.prisma.aiLearningAnalysis.findMany({
        where: { userId, createdAt: { gte: monthAgo } },
        orderBy: { createdAt: 'desc' },
        take: MAX_RECENT_ANALYSES,
        select: {
          id: true, targetType: true, targetId: true,
          learningState: true, riskLevel: true, confidence: true, createdAt: true,
        },
      }),
      this.prisma.weakPointCandidate.findMany({
        where: { userId, status: 'active' },
        select: { confidence: true, title: true },
        take: 20,
      }),
    ]);

    // Base scores (existing)
    const base = {
      quiz: {
        attempts: quizAgg._count,
        avgScore: quizAgg._avg.score ?? null,
        avgCorrectCount: quizAgg._avg.correctCount ?? null,
        avgTotalQuestions: quizAgg._avg.totalQuestions ?? null,
      },
      review: {
        totalLogs: reviewLogs.length,
        ratingDistribution: this.countBy(reviewLogs, r => r.rating),
      },
      recentAnalyses,
    };

    // Derived scores (API-AI-020)
    const weightedQuizScore = this.computeWeightedQuizScore(quizAttempts, now);
    const weightedReviewScore = this.computeWeightedReviewScore(reviewLogs, now);
    const aiConfidenceScore = this.computeAIScore(recentAnalyses);
    const weakPointSeverity = this.computeWeakPointSeverity(activeWeakPoints);
    const quizTrend = this.computeQuizTrend(quizAttempts);

    const weights = this.getScoreWeights(normalizedPref);
    const compositeMastery = this.computeCompositeMastery(
      weightedQuizScore, weightedReviewScore, aiConfidenceScore, weakPointSeverity, weights,
    );
    const masteryLevel = this.classifyMasteryLevel(compositeMastery);

    return {
      ...base,
      derived: {
        weightedQuizScore,
        weightedReviewScore,
        aiConfidenceScore,
        weakPointSeverity,
        quizTrend,
        compositeMastery,
        masteryLevel,
        qualityPreference: normalizedPref,
        weightConfiguration: weights,
      },
    };
  }

  // ── Recency Weighting ──

  private expDecayWeight(daysAgo: number, halfLifeDays: number = 7): number {
    return Math.exp(-Math.LN2 * daysAgo / halfLifeDays);
  }

  // ── Weighted Quiz Score ──

  private computeWeightedQuizScore(
    attempts: { score: number; startedAt: Date }[],
    now: Date,
  ): number | null {
    if (attempts.length === 0) return null;
    let weightedSum = 0;
    let weightSum = 0;
    for (const a of attempts) {
      const daysAgo = (now.getTime() - new Date(a.startedAt).getTime()) / (24 * 60 * 60 * 1000);
      const w = this.expDecayWeight(daysAgo);
      weightedSum += (a.score / 100) * w;
      weightSum += w;
    }
    return weightSum > 0 ? Math.round((weightedSum / weightSum) * 100) / 100 : null;
  }

  // ── Weighted Review Score ──

  private computeWeightedReviewScore(
    logs: { rating: string; reviewedAt: Date }[],
    now: Date,
  ): number | null {
    if (logs.length === 0) return null;
    const ratingMap: Record<string, number> = { again: 0, hard: 0.33, good: 0.66, easy: 1.0 };
    let weightedSum = 0;
    let weightSum = 0;
    for (const l of logs) {
      const num = ratingMap[l.rating] ?? 0.5;
      const daysAgo = (now.getTime() - new Date(l.reviewedAt).getTime()) / (24 * 60 * 60 * 1000);
      const w = this.expDecayWeight(daysAgo);
      weightedSum += num * w;
      weightSum += w;
    }
    return weightSum > 0 ? Math.round((weightedSum / weightSum) * 100) / 100 : null;
  }

  // ── AI Confidence Score ──

  private computeAIScore(
    analyses: { learningState: string | null; confidence: number | null }[],
  ): number | null {
    const stateMap: Record<string, number> = {
      struggling: 0.25, in_progress: 0.50, advanced: 0.75, mastered: 0.90,
    };
    const valid = analyses.filter(a => a.learningState && stateMap[a.learningState] !== undefined);
    if (valid.length === 0) return null;
    let weightedSum = 0;
    let weightSum = 0;
    for (const a of valid) {
      const stateNum = stateMap[a.learningState!];
      const conf = a.confidence ?? 0.5;
      weightedSum += stateNum * conf;
      weightSum += conf;
    }
    return weightSum > 0 ? Math.round((weightedSum / weightSum) * 100) / 100 : null;
  }

  // ── Weak Point Severity ──

  private computeWeakPointSeverity(
    weakPoints: { confidence: number | null }[],
  ): number | null {
    if (weakPoints.length === 0) return null;
    const totalConfidence = weakPoints.reduce((sum, wp) => sum + (wp.confidence ?? 0.5), 0);
    return Math.round(Math.min(1.0, totalConfidence / 5.0) * 100) / 100;
  }

  // ── Quiz Trend ──

  private computeQuizTrend(
    attempts: { score: number; startedAt: Date }[],
  ) {
    if (attempts.length < 4) {
      return { direction: 'insufficient_data', percentChange: null, recentAvg: null, priorAvg: null };
    }
    const midPoint = Math.floor(attempts.length / 2);
    const recentHalf = attempts.slice(midPoint);
    const priorHalf = attempts.slice(0, midPoint);
    const recentAvg = recentHalf.reduce((s, a) => s + a.score, 0) / recentHalf.length;
    const priorAvg = priorHalf.reduce((s, a) => s + a.score, 0) / priorHalf.length;
    const percentChange = priorAvg > 0 ? ((recentAvg - priorAvg) / priorAvg) * 100 : null;
    let direction = 'stable';
    if (percentChange !== null && percentChange >= 10) direction = 'increasing';
    else if (percentChange !== null && percentChange <= -10) direction = 'decreasing';
    return { direction, percentChange: percentChange !== null ? Math.round(percentChange * 100) / 100 : null, recentAvg, priorAvg };
  }

  // ── Composite Mastery ──

  private computeCompositeMastery(
    quizScore: number | null,
    reviewScore: number | null,
    aiScore: number | null,
    weakSeverity: number | null,
    weights: ReturnType<typeof SnapshotBuilderService.prototype.getScoreWeights>,
  ): number | null {
    if (quizScore === null && reviewScore === null && aiScore === null) return null;
    const q = (quizScore ?? 0) * weights.w_quiz;
    const r = (reviewScore ?? 0) * weights.w_review;
    const a = (aiScore ?? 0) * weights.w_ai;
    const penalty = (weakSeverity ?? 0) * weights.w_weak;
    const composites = [quizScore, reviewScore, aiScore].filter(s => s !== null);
    const usedWeights = [
      quizScore !== null ? weights.w_quiz : 0,
      reviewScore !== null ? weights.w_review : 0,
      aiScore !== null ? weights.w_ai : 0,
    ];
    const totalWeight = usedWeights.reduce((s, w) => s + w, 0);
    if (totalWeight === 0) return null;
    const score = (q + r + a) / totalWeight - penalty;
    return Math.round(Math.max(0, Math.min(1, score)) * 100) / 100;
  }

  // ── Mastery Level Classification ──

  private classifyMasteryLevel(composite: number | null): 'struggling' | 'developing' | 'progressing' | 'proficient' | 'mastered' | 'unknown' {
    if (composite === null) return 'unknown';
    if (composite >= 0.8) return 'mastered';
    if (composite >= 0.6) return 'proficient';
    if (composite >= 0.4) return 'progressing';
    if (composite >= 0.2) return 'developing';
    return 'struggling';
  }

  // ── Score Weights by Quality Preference ──

  private normalizeQualityPreference(raw: string): 'light' | 'standard' | 'deep' | 'exam' {
    const valid = ['light', 'standard', 'deep', 'exam'];
    const lower = raw?.toLowerCase();
    if (valid.includes(lower)) return lower as 'light' | 'standard' | 'deep' | 'exam';
    return 'standard';
  }

  private getScoreWeights(pref: string) {
    const presets: Record<string, { w_quiz: number; w_review: number; w_ai: number; w_weak: number }> = {
      light:    { w_quiz: 0.35, w_review: 0.45, w_ai: 0.10, w_weak: 0.10 },
      standard: { w_quiz: 0.35, w_review: 0.30, w_ai: 0.25, w_weak: 0.10 },
      deep:     { w_quiz: 0.25, w_review: 0.20, w_ai: 0.40, w_weak: 0.15 },
      exam:     { w_quiz: 0.50, w_review: 0.25, w_ai: 0.10, w_weak: 0.15 },
    };
    return presets[pref] ?? presets.standard;
  }

  // ── Behavior Signals ──

  private async calculateBehaviorSignals(
    userId: string,
    shared: Awaited<ReturnType<SnapshotBuilderService['fetchBehaviorData']>>,
  ) {
    const { dailyActivities, weeklyGlobalSessions, activeDaysCount, todayActivity } = shared;
    const now = new Date();
    const weekAgo = new Date(now.getTime() - BEHAVIOR_WINDOW_DAYS * 24 * 60 * 60 * 1000);

    const [
      streakRecords,
      readingEvents,
      recentSessions,
      progressSummary,
    ] = await Promise.all([
      this.prisma.streakRecord.findMany({
        where: { userId, streakType: 'reading' },
        orderBy: { endDate: 'desc' },
        take: 2,
        select: { length: true, startDate: true, endDate: true },
      }),
      this.prisma.readingEvent.findMany({
        where: { userId, clientTimestampMs: { gte: BigInt(weekAgo.getTime()) } },
        select: { clientTimestampMs: true, clientTimezoneOffsetMinutes: true },
        take: 500,
      }),
      this.prisma.learningSession.findMany({
        where: { userId, startedAt: { gte: weekAgo } },
        select: { id: true, totalActiveSeconds: true, status: true, startedAt: true },
        orderBy: { startedAt: 'asc' },
        take: 50,
      }),
      this.prisma.materialReadingProgress.aggregate({
        where: { userId, status: { in: ['completed', 'in_progress'] } },
        _sum: { totalActiveSeconds: true, sessionCount: true },
        _count: true,
      }),
    ]);

    return this.computeSignals(
      dailyActivities,
      weeklyGlobalSessions,
      activeDaysCount,
      todayActivity,
      now,
      streakRecords,
      readingEvents,
      recentSessions,
      progressSummary,
    );
  }

  private computeSignals(
    dailyActivities: DailyActivitySlice[],
    weeklyGlobalSessions: number,
    activeDaysCount: number,
    todayActivity: TodayActivitySlice | null,
    now: Date,
    streakRecords: StreakRecordSlice[],
    readingEvents: ReadingEventSlice[],
    recentSessions: SessionSlice[],
    progressSummary: ProgressAggregateSlice,
  ) {
    // ── 1. Composite Engagement ──
    const avgDailySeconds = dailyActivities.length > 0
      ? dailyActivities.reduce((sum: number, d: any) => sum + d.durationSeconds, 0) / dailyActivities.length
      : 0;
    let engagementScore = 0;
    if (weeklyGlobalSessions >= 4) engagementScore += 1;
    else if (weeklyGlobalSessions >= 2) engagementScore += 0.5;
    if (activeDaysCount >= 5) engagementScore += 1;
    else if (activeDaysCount >= 3) engagementScore += 0.5;
    if (avgDailySeconds >= 1800) engagementScore += 1;
    else if (avgDailySeconds >= 900) engagementScore += 0.5;
    const engagementSignal = engagementScore >= 2.5 ? 'high' : engagementScore >= 1.5 ? 'medium' : 'low';

    // ── 2. Learning Consistency ──
    const durations = dailyActivities.map((d: any) => d.durationSeconds);
    const mean = durations.length > 0 ? durations.reduce((a: number, b: number) => a + b, 0) / durations.length : 0;
    const variance = durations.length > 1
      ? durations.reduce((sum: number, v: number) => sum + (v - mean) ** 2, 0) / durations.length
      : 0;
    const stddev = Math.sqrt(variance);
    const cv = mean > 0 ? stddev / mean : null;
    let consistencyLabel = 'irregular';
    if (cv !== null && cv < 0.5) consistencyLabel = 'consistent';
    else if (cv !== null && cv <= 1.0) consistencyLabel = 'moderate';
    const zeroDaysCount = durations.filter((d: number) => d === 0).length;

    // ── 3. Streak Metrics ──
    const currentStreak = streakRecords.length > 0 ? streakRecords[0].length : 0;
    const longestStreak = streakRecords.reduce((max: number, s: any) => Math.max(max, s.length), 0);
    const streakAtRisk = todayActivity === null && dailyActivities.length > 0 && dailyActivities[0].durationSeconds === 0;

    // ── 4. Session Pattern Metrics ──
    const completedSessions = recentSessions.filter((s: any) => s.status !== 'active' && s.status !== 'interrupted');
    const totalSessions = recentSessions.length;
    const sessionDurations = recentSessions.map((s: any) => s.totalActiveSeconds).sort((a: number, b: number) => a - b);
    const avgSessionDuration = sessionDurations.length > 0
      ? sessionDurations.reduce((a: number, b: number) => a + b, 0) / sessionDurations.length
      : 0;
    const medianSessionDuration = sessionDurations.length > 0
      ? sessionDurations[Math.floor(sessionDurations.length / 2)]
      : 0;
    const sessionsPerDay = activeDaysCount > 0 ? totalSessions / activeDaysCount : 0;
    const sessionCompletionRate = totalSessions > 0 ? completedSessions.length / totalSessions : 0;
    let avgTimeBetweenSessions: number | null = null;
    if (recentSessions.length >= 2) {
      const gaps: number[] = [];
      for (let i = 1; i < recentSessions.length; i++) {
        gaps.push((new Date(recentSessions[i].startedAt).getTime() - new Date(recentSessions[i - 1].startedAt).getTime()) / 1000);
      }
      avgTimeBetweenSessions = gaps.reduce((a: number, b: number) => a + b, 0) / gaps.length;
    }

    // ── 5. Reading Velocity ──
    const totalActiveSeconds = progressSummary._sum.totalActiveSeconds ?? 0;
    const totalSessionCount = progressSummary._sum.sessionCount ?? 0;
    const totalMaterialsTracked = progressSummary._count ?? 0;
    const avgProgressPerSession = totalSessionCount > 0 ? totalActiveSeconds / totalSessionCount : 0;
    const progressRate = totalActiveSeconds > 0 ? (totalMaterialsTracked / totalActiveSeconds) * 3600 : 0;

    // ── 6. Time-of-Day Distribution ──
    const bins = { morning: 0, afternoon: 0, evening: 0, night: 0 };
    const totalEvents = readingEvents.length;
    for (const e of readingEvents) {
      const ms = Number(e.clientTimestampMs);
      const tzOffset = (e.clientTimezoneOffsetMinutes ?? 0) as number;
      const localHour = new Date(ms + tzOffset * 60 * 1000).getUTCHours();
      if (localHour >= 6 && localHour < 12) bins.morning++;
      else if (localHour >= 12 && localHour < 18) bins.afternoon++;
      else if (localHour >= 18 && localHour < 24) bins.evening++;
      else bins.night++;
    }
    const timeDistribution = totalEvents > 0 ? {
      morning: Math.round((bins.morning / totalEvents) * 100) / 100,
      afternoon: Math.round((bins.afternoon / totalEvents) * 100) / 100,
      evening: Math.round((bins.evening / totalEvents) * 100) / 100,
      night: Math.round((bins.night / totalEvents) * 100) / 100,
    } : { morning: 0, afternoon: 0, evening: 0, night: 0 };

    // ── 7. Activity Balance ──
    const totals = dailyActivities.reduce((acc: any, d: any) => ({
      readingSeconds: acc.readingSeconds + (d.readingSeconds ?? 0),
      activeRecallCount: acc.activeRecallCount + (d.activeRecallCount ?? 0),
      reviewCount: acc.reviewCount + (d.reviewCount ?? 0),
      aiAnalysisCount: acc.aiAnalysisCount + (d.aiAnalysisCount ?? 0),
    }), { readingSeconds: 0, activeRecallCount: 0, reviewCount: 0, aiAnalysisCount: 0 });
    const activityUnits = totals.readingSeconds + totals.activeRecallCount * 120 + totals.reviewCount * 60 + totals.aiAnalysisCount * 120;
    const activityBalance = activityUnits > 0 ? {
      readingPct: Math.round((totals.readingSeconds / activityUnits) * 100) / 100,
      activeRecallPct: Math.round((totals.activeRecallCount * 120 / activityUnits) * 100) / 100,
      reviewPct: Math.round((totals.reviewCount * 60 / activityUnits) * 100) / 100,
      aiAnalysisPct: Math.round((totals.aiAnalysisCount * 120 / activityUnits) * 100) / 100,
    } : { readingPct: 0, activeRecallPct: 0, reviewPct: 0, aiAnalysisPct: 0 };
    const maxPct = Math.max(activityBalance.readingPct, activityBalance.activeRecallPct, activityBalance.reviewPct, activityBalance.aiAnalysisPct);
    let primaryActivity = 'reading';
    if (maxPct === activityBalance.activeRecallPct) primaryActivity = 'activeRecall';
    else if (maxPct === activityBalance.reviewPct) primaryActivity = 'review';
    else if (maxPct === activityBalance.aiAnalysisPct) primaryActivity = 'aiAnalysis';

    // ── 8. Weekly Trend ──
    let trendDirection = 'insufficient_data';
    let percentChange: number | null = null;
    let recentAvg = 0;
    let priorAvg = 0;
    if (dailyActivities.length >= 3) {
      const midPoint = Math.floor(dailyActivities.length / 2);
      const recentHalf = dailyActivities.slice(0, midPoint);
      const priorHalf = dailyActivities.slice(midPoint);
      recentAvg = recentHalf.length > 0 ? recentHalf.reduce((s: number, d: any) => s + d.durationSeconds, 0) / recentHalf.length : 0;
      priorAvg = priorHalf.length > 0 ? priorHalf.reduce((s: number, d: any) => s + d.durationSeconds, 0) / priorHalf.length : 0;
      percentChange = priorAvg > 0 ? ((recentAvg - priorAvg) / priorAvg) * 100 : null;
      trendDirection = 'stable';
      if (percentChange !== null && percentChange > 20) trendDirection = 'increasing';
      else if (percentChange !== null && percentChange < -20) trendDirection = 'decreasing';
    }

    // ── 9. Fatigue / Decline Risk ──
    const riskFactors: string[] = [];
    let consecutiveZeros = 0;
    for (const d of dailyActivities) {
      if (d.durationSeconds === 0) consecutiveZeros++;
      else break;
    }
    if (consecutiveZeros >= 2) riskFactors.push('consecutive_inactive_days');
    if (trendDirection === 'decreasing') riskFactors.push('declining_trend');
    const interruptedSessions = recentSessions.filter((s: any) => s.status === 'interrupted').length;
    if (interruptedSessions > 0) riskFactors.push('interrupted_sessions');
    if (recentSessions.length > 0) {
      const lastSession = recentSessions[recentSessions.length - 1];
      const hoursSinceLastSession = (now.getTime() - new Date(lastSession.startedAt).getTime()) / (1000 * 60 * 60);
      if (hoursSinceLastSession > 48) riskFactors.push('long_gap_since_last_session');
    }
    const fatigueRisk = riskFactors.length > 0;

    return {
      // Base signals — user-global (unlike learningBehaviorSummary which may be scoped)
      totalWeeklySessions: weeklyGlobalSessions,
      activeDays: activeDaysCount,
      avgSecondsPerActiveDay: Math.round(avgDailySeconds),
      today: todayActivity ? {
        durationSeconds: todayActivity.durationSeconds,
        sessionsCount: todayActivity.sessionsCount,
        activityLevel: todayActivity.activityLevel,
      } : null,
      recentActivityLevels: dailyActivities.map((d: any) => ({
        date: d.activityDate,
        level: d.activityLevel,
        seconds: d.durationSeconds,
      })),
      // New signals (API-AI-018)
      engagementSignal,
      engagement: { score: engagementScore, label: engagementSignal },
      consistency: { coefficientOfVariation: cv, label: consistencyLabel, activeDaysCount, zeroDaysCount },
      streak: { currentStreak, longestStreak, streakAtRisk },
      sessionPattern: { avgSessionDuration, medianSessionDuration, sessionCompletionRate, sessionsPerDay, avgTimeBetweenSessions },
      readingVelocity: { avgProgressPerSession, progressRate, totalMaterialsTracked },
      timeDistribution,
      activityBalance: { ...activityBalance, primaryActivity },
      weeklyTrend: { trendDirection, percentChange, recentAvg, priorAvg },
      fatigue: { fatigueRisk, riskFactors },
    };
  }

  // ── Device Context ──

  private async buildDeviceContext(userId: string) {
    const now = new Date();
    const weekAgo = new Date(now.getTime() - BEHAVIOR_WINDOW_DAYS * 24 * 60 * 60 * 1000);

    const [latestEvent, devices, recentPlatformEvents] = await Promise.all([
      this.prisma.readingEvent.findFirst({
        where: { userId, platform: { not: null } },
        orderBy: { clientTimestampMs: 'desc' },
        select: { platform: true, appVersion: true, clientTimestampMs: true },
      }),
      this.prisma.userDevice.findMany({
        where: { userId },
        select: { deviceId: true, deviceName: true, osVersion: true, lastSeenAt: true },
        orderBy: { lastSeenAt: 'desc' },
      }),
      this.prisma.readingEvent.findMany({
        where: { userId, clientTimestampMs: { gte: BigInt(weekAgo.getTime()) } },
        select: { platform: true, appVersion: true, clientTimezoneOffsetMinutes: true },
        orderBy: { clientTimestampMs: 'asc' },
        take: 200,
      }),
    ]);

    const platformCategory = this.mapPlatformCategory(latestEvent?.platform ?? null);

    // Platform distribution in last 7 days
    const platformCounts: Record<string, number> = { phone: 0, tablet: 0, desktop: 0, web: 0 };
    for (const e of recentPlatformEvents) {
      const cat = this.mapPlatformCategory(e.platform);
      if (cat) platformCounts[cat]++;
    }
    const totalPlatformEvents = Object.values(platformCounts).reduce((a, b) => a + b, 0);
    const platformDistribution = totalPlatformEvents > 0
      ? {
          phone: Math.round((platformCounts.phone / totalPlatformEvents) * 100) / 100,
          tablet: Math.round((platformCounts.tablet / totalPlatformEvents) * 100) / 100,
          desktop: Math.round((platformCounts.desktop / totalPlatformEvents) * 100) / 100,
          web: Math.round((platformCounts.web / totalPlatformEvents) * 100) / 100,
        }
      : { phone: 0, tablet: 0, desktop: 0, web: 0 };

    // Primary device detection (>70% of events from one category)
    const maxCount = Math.max(...Object.values(platformCounts));
    const hasPrimaryDevice = totalPlatformEvents > 0 && maxCount / totalPlatformEvents > 0.7;
    const primaryPlatformCategory = hasPrimaryDevice
      ? (Object.entries(platformCounts).find(([, c]) => c === maxCount)?.[0] as typeof platformCategory) ?? null
      : null;

    // Device switch frequency
    const deviceSwitchFrequency = this.computeSwitchFrequency(recentPlatformEvents);

    // Timezone analysis
    const timezones = recentPlatformEvents
      .map(e => e.clientTimezoneOffsetMinutes)
      .filter((t): t is number => t !== null);
    const primaryTimezoneOffsetMinutes = timezones.length > 0
      ? this.modeOf(timezones)
      : null;
    const hasTimezoneVariance = new Set(timezones).size > 1;
    const lastSeenTimezoneOffsetMinutes = timezones.length > 0 ? timezones[timezones.length - 1] : null;

    // App version freshness
    const versions = recentPlatformEvents
      .map(e => e.appVersion)
      .filter((v): v is string => v !== null);
    const latestAppVersion = latestEvent?.appVersion ?? null;
    const versionChanges = new Set(versions).size;
    const isOutdated = versionChanges > 1 && latestAppVersion
      ? versions.filter(v => v === latestAppVersion).length < versions.length * 0.5
      : false;

    // Device task suitability per privacy doc §4.4
    const deviceTaskSuitability = this.mapDeviceTaskSuitability(platformCategory);

    return {
      latestPlatform: latestEvent?.platform ?? null,
      latestAppVersion,
      deviceCount: devices.length,
      devices,

      // Signal 1: Platform categorization
      platformCategory,

      // Signal 2: Device task suitability
      deviceTaskSuitability,

      // Signal 3: Multi-device pattern
      hasPrimaryDevice,
      primaryPlatformCategory,
      deviceSwitchFrequency,
      platformDistribution,

      // Signal 4: Timezone / scene
      primaryTimezoneOffsetMinutes,
      hasTimezoneVariance,
      lastSeenTimezoneOffsetMinutes,

      // Signal 5: App version freshness
      appVersion: {
        latest: latestAppVersion,
        isOutdated,
        versionCount: new Set(versions).size,
      },
    };
  }

  // ── Platform Category Mapping ──

  /**
   * Map raw platform string to allowed device categories per privacy doc §4.4.
   *
   * Limitation: Android does not distinguish phone vs tablet at the platform
   * string level. All 'android' platforms are mapped to 'phone'. A future
   * enhancement could use screen-size hints or UserDevice metadata to detect
   * Android tablets.
   */
  private mapPlatformCategory(platform: string | null): 'phone' | 'tablet' | 'desktop' | 'web' | null {
    if (!platform) return null;
    const p = platform.toLowerCase();
    if (p === 'ios' || p === 'android') return 'phone';
    if (p === 'ipados') return 'tablet';
    if (p === 'macos' || p === 'windows' || p === 'linux') return 'desktop';
    if (p === 'web' || p === 'browser') return 'web';
    this.logger.warn(`mapPlatformCategory: unknown platform="${platform}", returning null`);
    return null;
  }

  // ── Device Switch Frequency ──

  private computeSwitchFrequency(
    events: { platform: string | null; clientTimezoneOffsetMinutes?: number | null }[],
  ): 'daily' | 'weekly' | 'rarely' | 'unknown' {
    const platforms = events.map(e => e.platform).filter((p): p is string => p !== null);
    if (platforms.length < 2) return 'unknown';

    let switches = 0;
    for (let i = 1; i < platforms.length; i++) {
      if (platforms[i] !== platforms[i - 1]) switches++;
    }

    if (switches >= platforms.length * 0.3) return 'daily';
    if (switches >= 1) return 'weekly';
    return 'rarely';
  }

  // ── Device Task Suitability ──

  private mapDeviceTaskSuitability(category: 'phone' | 'tablet' | 'desktop' | 'web' | null) {
    if (!category) {
      return { suitableTaskTypes: [], unsuitableTaskTypes: [] };
    }

    const mapping: Record<string, { suitableTaskTypes: string[]; unsuitableTaskTypes: string[] }> = {
      phone: {
        suitableTaskTypes: ['flashcard', 'single_choice', 'short_review', 'light_quiz'],
        unsuitableTaskTypes: ['deep_analysis', 'long_reading', 'complex_quiz'],
      },
      tablet: {
        suitableTaskTypes: ['reading', 'annotation', 'light_quiz', 'flashcard'],
        unsuitableTaskTypes: ['complex_quiz'],
      },
      desktop: {
        suitableTaskTypes: ['deep_analysis', 'complex_quiz', 'long_reading', 'content_organization'],
        unsuitableTaskTypes: [],
      },
      web: {
        suitableTaskTypes: ['deep_analysis', 'complex_quiz', 'long_reading', 'content_organization'],
        unsuitableTaskTypes: [],
      },
    };

    return mapping[category] ?? { suitableTaskTypes: [], unsuitableTaskTypes: [] };
  }

  // ── Math Helper ──

  private modeOf(numbers: number[]): number | null {
    if (numbers.length === 0) return null;
    const freq: Record<number, number> = {};
    let maxCount = 0;
    let mode = numbers[0];
    for (const n of numbers) {
      freq[n] = (freq[n] ?? 0) + 1;
      if (freq[n] > maxCount) {
        maxCount = freq[n];
        mode = n;
      }
    }
    return mode;
  }

  // ── Allowed Fields Builder ──

  private buildAllowedFields(settings: any, profile: any): string[] {
    const fields: string[] = ['constraints', 'materialProgressSummary', 'scoreSignals', 'deviceContext'];

    if (settings?.allowUseUserProfile !== false) {
      fields.push('userProfile', 'aiSettings');
    }
    if (settings?.allowUseLearningBehavior !== false) {
      fields.push('learningBehaviorSummary', 'behaviorSignals');
    }
    if (settings?.allowUseDocumentContent === true) {
      fields.push('contentStructureSummary');
    }

    return fields;
  }

  // ── Helpers ──

  private countBy<T>(items: T[], fn: (item: T) => string): Record<string, number> {
    const result: Record<string, number> = {};
    for (const item of items) {
      const key = fn(item);
      result[key] = (result[key] ?? 0) + 1;
    }
    return result;
  }
}
