import { Injectable } from "@nestjs/common";
import { InjectModel } from "@nestjs/mongoose";
import { Model, Types } from "mongoose";
import { Request, Response } from "express";
import { ResponseService } from "src/common/service/response.service";
import {
  ManualPerformance,
  ManualPerformanceDocument,
} from "./schemas/manual-performance.schema";
import {
  Performance,
  PerformanceDocument,
} from "src/performance/schemas/performance.schema";
import {
  WorkoutSession,
  WorkoutSessionDocument,
} from "src/workout-session/schemas/workout-session.schema";
import {
  Exercise,
  ExerciseDocument,
} from "src/exercise/schemas/exercise.schema";
import {
  ExerciseCategory,
  ExerciseCategoryDocument,
} from "src/exercise/schemas/exercise-category.schema";
import { CreateManualPerformanceDto } from "./dto/create-manual-performance.dto";
import { UpdateManualPerformanceDto } from "./dto/update-manual-performance.dto";
import { endOfCurrentDayUtc } from "src/common/constants/timezone.constant";

// Filter periods shown on the "Filter by" sheet
type StrengthPeriod = "7d" | "30d" | "3m" | "6m" | "all";

// A category exposes a set of field keys; the strongest/primary one drives the graph.
type StrengthMetricKey =
  | "weight"
  | "added_weight"
  | "assisted_weight"
  | "distance"
  | "duration"
  | "reps";

@Injectable()
export class StrengthService {
  constructor(
    @InjectModel(ManualPerformance.name)
    private manualPerformanceModel: Model<ManualPerformanceDocument>,
    @InjectModel(Performance.name)
    private performanceModel: Model<PerformanceDocument>,
    @InjectModel(WorkoutSession.name)
    private workoutSessionModel: Model<WorkoutSessionDocument>,
    @InjectModel(Exercise.name)
    private exerciseModel: Model<ExerciseDocument>,
    @InjectModel(ExerciseCategory.name)
    private exerciseCategoryModel: Model<ExerciseCategoryDocument>,
    private readonly resService: ResponseService,
  ) {}

  // POST /strength/manual-performance — upsert multiple exercises for a given date, each stored as a separate document
  async create(
    dto: CreateManualPerformanceDto,
    req: Request,
    res: Response,
    user: any,
  ) {
    try {
      const startDate = new Date(dto.date);
      startDate.setUTCHours(0, 0, 0, 0);
      const endDate = new Date(dto.date);
      endDate.setUTCHours(23, 59, 59, 999);

      const userId = new Types.ObjectId(user._id);

      // Resolve each exercise's category so a plain `weight` value lands in the
      // field the category actually tracks (assisted_weight / added_weight),
      // then collapse each exercise's sets to its strongest set up-front so we
      // can validate the weight before persisting anything.
      const fieldKeysByExercise = await this.getCategoryFieldKeys(
        dto.exercises.map((ex) => new Types.ObjectId(ex.exercise_id)),
      );
      const prepared = dto.exercises.map((ex) => ({
        ex,
        best: this.pickBestSet(
          ex.sets.map((s) =>
            this.remapWeightForCategory(s, fieldKeysByExercise.get(ex.exercise_id)),
          ),
        ),
      }));

      // A new record is only allowed when its weight is equal to or higher than
      // the user's current maximum weight (PR) for that exercise. A lower weight
      // is blocked. Reps don't gate this: an equal weight with higher reps is
      // allowed (e.g. 150 kg×1 → 150 kg×3). Only weight-based entries are checked.
      // for (const { ex, best } of prepared) {
      //   if (best.weight == null) continue;
      //   const [agg] = await this.manualPerformanceModel.aggregate([
      //     {
      //       $match: {
      //         userId,
      //         exercise_id: new Types.ObjectId(ex.exercise_id),
      //         deleted_at: null,
      //         weight: { $ne: null },
      //       },
      //     },
      //     { $group: { _id: null, maxWeight: { $max: "$weight" } } },
      //   ]);
      //   const maxWeight = agg?.maxWeight;
      //   if (maxWeight != null && best.weight < maxWeight) {
      //     return this.resService.badRequest(
      //       res,
      //       "lower_weight",
      //       req,
      //     );
      //   }
      // }

      const saved = await Promise.all(
        prepared.map(({ ex, best }) =>
          this.manualPerformanceModel.findOneAndUpdate(
            {
              userId: userId,
              exercise_id: new Types.ObjectId(ex.exercise_id),
              date: { $gte: startDate, $lte: endDate },
              deleted_at: null,
            },
            { ...best, date: startDate },
            { new: true, upsert: true },
          ),
        ),
      );

      return this.resService.success(
        res,
        "Manual performance saved successfully",
        req,
        saved,
      );
    } catch (error: any) {
      return this.resService.serverError(
        res,
        "Failed to save manual performance",
        req,
        error.message || error,
      );
    }
  }

  // GET /strength/manual-performance?date=YYYY-MM-DD — all exercises logged for a specific date
  async getByDate(date: string, req: Request, res: Response, user: any) {
    try {
      const startDate = new Date(date);
      startDate.setUTCHours(0, 0, 0, 0);
      const endDate = new Date(date);
      endDate.setUTCHours(23, 59, 59, 999);

      const data = await this.manualPerformanceModel.aggregate([
        {
          $match: {
            userId: new Types.ObjectId(user._id),
            date: { $gte: startDate, $lte: endDate },
            deleted_at: null,
          },
        },
        {
          $lookup: {
            from: "exercises",
            localField: "exercise_id",
            foreignField: "_id",
            as: "exercise",
          },
        },
        { $unwind: { path: "$exercise", preserveNullAndEmptyArrays: true } },
        {
          $lookup: {
            from: "exercisecategories",
            localField: "exercise.category",
            foreignField: "_id",
            as: "exercise.category",
          },
        },
        {
          $unwind: {
            path: "$exercise.category",
            preserveNullAndEmptyArrays: true,
          },
        },
        { $sort: { createdAt: 1 } },
      ]);

      return this.resService.success(res, "success", req, data);
    } catch (error: any) {
      return this.resService.serverError(
        res,
        "Failed to fetch manual performance",
        req,
        error.message || error,
      );
    }
  }

  // GET /strength/manual-performance/history/:exerciseId — full history for one exercise (All entries screen)
  async getExerciseHistory(
    exerciseId: string,
    page: number,
    limit: number,
    res: Response,
    req: Request,
    user: any,
  ) {
    try {
      const skip = (page - 1) * limit;
      const matchStage = {
        exercise_id: new Types.ObjectId(exerciseId),
        userId: new Types.ObjectId(user._id),
        deleted_at: null,
      };

      const [items, total] = await Promise.all([
        this.manualPerformanceModel
          .find(matchStage)
          .sort({ date: -1 })
          .skip(skip)
          .limit(limit)
          .lean(),
        this.manualPerformanceModel.countDocuments(matchStage),
      ]);

      return this.resService.success(res, "success", req, {
        items,
        total,
        page,
        limit,
        totalPages: Math.ceil(total / limit),
      });
    } catch (error: any) {
      return this.resService.serverError(
        res,
        "Failed to fetch exercise history",
        req,
        error.message || error,
      );
    }
  }

  // GET /strength/manual-performance/personal-records — best set per exercise across all sessions
  async getPersonalRecords(req: Request, res: Response, user: any) {
    try {
      const records = await this.manualPerformanceModel.aggregate([
        { $match: { userId: new Types.ObjectId(user._id), deleted_at: null } },
        {
          $group: {
            _id: "$exercise_id",
            max_weight: { $max: "$weight" },
            max_reps: { $max: "$reps" },
            max_duration: { $max: "$duration" },
            max_distance: { $max: "$distance" },
            best_assisted_weight: { $max: "$assisted_weight" }, // least negative = strongest
            max_added_weight: { $max: "$added_weight" },
            last_date: { $max: "$date" },
          },
        },
        {
          $lookup: {
            from: "exercises",
            localField: "_id",
            foreignField: "_id",
            as: "exercise",
          },
        },
        { $unwind: "$exercise" },
        {
          $lookup: {
            from: "exercisecategories",
            localField: "exercise.category",
            foreignField: "_id",
            as: "exercise.category",
          },
        },
        {
          $unwind: {
            path: "$exercise.category",
            preserveNullAndEmptyArrays: true,
          },
        },
        {
          $project: {
            _id: 0,
            exercise_id: "$_id",
            exercise: { _id: 1, name: 1, category: 1 },
            max_weight: 1,
            max_reps: 1,
            max_duration: 1,
            max_distance: 1,
            best_assisted_weight: 1,
            max_added_weight: 1,
            last_date: 1,
          },
        },
        { $sort: { last_date: -1 } },
      ]);

      return this.resService.success(res, "success", req, records);
    } catch (error: any) {
      return this.resService.serverError(
        res,
        "Failed to fetch personal records",
        req,
        error.message || error,
      );
    }
  }

  // GET /strength/manual-performance/personal-records/:exerciseId — PR progression over time for one exercise
  async getPersonalRecordHistory(
    exerciseId: string,
    req: Request,
    res: Response,
    user: any,
  ) {
    try {
      const performances = await this.manualPerformanceModel.aggregate([
        {
          $match: {
            userId: new Types.ObjectId(user._id),
            exercise_id: new Types.ObjectId(exerciseId),
            deleted_at: null,
          },
        },
        { $sort: { date: 1 } },
        {
          $project: {
            date: 1,
            weight: 1,
            reps: 1,
            duration: 1,
            distance: 1,
            assisted_weight: 1,
            added_weight: 1,
          },
        },
      ]);

      let currentPR = 0;
      const history = [];

      for (const record of performances) {
        if (
          record.weight !== null &&
          record.weight !== undefined &&
          record.weight > currentPR
        ) {
          currentPR = record.weight;
          history.push({
            date: record.date,
            weight: record.weight,
            reps: record.reps,
          });
        }
      }

      return this.resService.success(
        res,
        "Personal record history fetched successfully",
        req,
        history.reverse(), // latest first
      );
    } catch (error: any) {
      return this.resService.serverError(
        res,
        "Failed to fetch personal record history",
        req,
        error.message || error,
      );
    }
  }

  // PATCH /strength/manual-performance/:id — replace sets for a logged exercise
  async update(
    id: string,
    dto: UpdateManualPerformanceDto,
    req: Request,
    res: Response,
    user: any,
  ) {
    try {
      const { date, sets } = dto;

      let normalizedDate: Date | undefined;
      if (date !== undefined) {
        normalizedDate = new Date(date);
        normalizedDate.setUTCHours(0, 0, 0, 0);
      }

      // When sets are provided, remap a plain `weight` into the field the
      // exercise's category tracks, then keep only the strongest set, flattened
      // onto the doc.
      let best: ReturnType<StrengthService["pickBestSet"]> | undefined;
      if (sets !== undefined) {
        const existing = await this.manualPerformanceModel.findOne({
          _id: id,
          userId: new Types.ObjectId(user._id),
          deleted_at: null,
        });
        if (!existing)
          return this.resService.notFound(
            res,
            "Manual performance log not found",
            req,
          );

        const fieldKeys = (
          await this.getCategoryFieldKeys([existing.exercise_id])
        ).get(String(existing.exercise_id));
        best = this.pickBestSet(
          sets.map((s) => this.remapWeightForCategory(s, fieldKeys)),
        );

        // Editing a weight can't drop it below the user's current max weight (PR)
        // for that exercise, ignoring this very entry. Equal or higher is allowed.
        if (best.weight != null) {
          const [agg] = await this.manualPerformanceModel.aggregate([
            {
              $match: {
                userId: existing.userId,
                exercise_id: existing.exercise_id,
                deleted_at: null,
                weight: { $ne: null },
                _id: { $ne: existing._id },
              },
            },
            { $group: { _id: null, maxWeight: { $max: "$weight" } } },
          ]);
          const maxWeight = agg?.maxWeight;
          if (maxWeight != null && best.weight < maxWeight) {
            return this.resService.badRequest(res, "lower_weight", req);
          }
        }
      }

      const data = await this.manualPerformanceModel.findOneAndUpdate(
        { _id: id, userId: new Types.ObjectId(user._id), deleted_at: null },
        {
          ...(best !== undefined && best),
          ...(normalizedDate !== undefined && { date: normalizedDate }),
        },
        { new: true },
      );

      if (!data)
        return this.resService.notFound(
          res,
          "Manual performance log not found",
          req,
        );
      return this.resService.success(
        res,
        "Manual performance updated successfully",
        req,
        data,
      );
    } catch (error: any) {
      return this.resService.serverError(
        res,
        "Failed to update manual performance",
        req,
        error.message || error,
      );
    }
  }

  // DELETE /strength/manual-performance/:id — soft delete one exercise log
  async remove(id: string, req: Request, res: Response, user: any) {
    try {
      const data = await this.manualPerformanceModel.findOneAndUpdate(
        { _id: id, userId: new Types.ObjectId(user._id), deleted_at: null },
        { deleted_at: new Date() },
        { new: true },
      );

      if (!data)
        return this.resService.notFound(
          res,
          "Manual performance log not found",
          req,
        );
      return this.resService.success(
        res,
        "Manual performance deleted successfully",
        req,
        data,
      );
    } catch (error: any) {
      return this.resService.serverError(
        res,
        "Failed to delete manual performance",
        req,
        error.message || error,
      );
    }
  }

  // GET /strength/graph/:exerciseId?period=7d|30d|3m|6m|all
  // Builds the "Strength progression" chart for one exercise by unifying three
  // sources — workout sessions, performance (PR) and manual performance — and
  // taking the highest strength value per day. The strength metric is chosen
  // from the exercise's category (e.g. Deadlift -> weight, Plank -> duration).
  async getStrengthGraph(
    exerciseId: string,
    period: StrengthPeriod,
    req: Request,
    res: Response,
    user: any,
  ) {
    try {
      const exId = new Types.ObjectId(exerciseId);
      const userId = new Types.ObjectId(user._id);

      const data = await this.buildStrengthGraphData(exId, userId, period);
      if (!data) {
        return this.resService.notFound(res, "Exercise not found", req);
      }

      return this.resService.success(
        res,
        "Strength history fetched successfully",
        req,
        data,
      );
    } catch (error: any) {
      return this.resService.serverError(
        res,
        "Failed to build strength graph",
        req,
        error.message || error,
      );
    }
  }

  // Core builder for the "Strength progression" chart of one exercise. Returns
  // the `{ metric, graph, stats }` payload (or null when the exercise doesn't
  // exist) without touching the HTTP response, so it can be reused outside the
  // strength endpoint — e.g. embedded in the progress dashboard / shared profile.
  async buildStrengthGraphData(
    exId: Types.ObjectId,
    userId: Types.ObjectId,
    period: StrengthPeriod,
  ): Promise<{
    metric: { key: StrengthMetricKey; unit: string };
    graph: Array<{ label: string; value: number | null }>;
    stats: Record<string, any>;
  } | null> {
    {
      const exercise = await this.exerciseModel
        .findOne({ _id: exId, deleted_at: null })
        .lean();
      if (!exercise) {
        return null;
      }

      const category = exercise.category
        ? await this.exerciseCategoryModel.findById(exercise.category).lean()
        : null;

      // Candidate strength metrics in priority order. We compute all of them per
      // day, then pick one — preferring the category's metric, and falling back
      // to whatever data actually exists (so an orphaned/missing category still
      // resolves to weight for a Deadlift instead of defaulting to reps).
      const CANDIDATES: StrengthMetricKey[] = [
        "weight",
        "added_weight",
        "assisted_weight",
        "distance",
        "duration",
        "reps",
      ];

      const categoryKey = this.resolveMetricKey(
        (category?.fields ?? []).map((f: any) => f.key),
      );

      const { from, to } = this.resolveRange(period);
      const dateFilter = from ? { $gte: from, $lte: to } : { $lte: to };

      // Build a $max accumulator for every candidate metric.
      const setMaxAccumulators = CANDIDATES.reduce(
        (acc, key) => ({ ...acc, [key]: { $max: `$${key}` } }),
        {} as Record<string, any>,
      );
      const workoutMaxAccumulators = CANDIDATES.reduce(
        (acc, key) => ({
          ...acc,
          [key]: {
            $max: {
              $convert: {
                input: `$exercises.sets.values.${key}`,
                to: "double",
                onError: null,
                onNull: null,
              },
            },
          },
        }),
        {} as Record<string, any>,
      );

      // --- Source 1 & 2: Performance and Manual Performance (flat single set) ---
      const setPipeline = () => [
        {
          $match: {
            userId,
            exercise_id: exId,
            deleted_at: null,
            date: dateFilter,
          },
        },
        {
          $group: {
            _id: { $dateToString: { format: "%Y-%m-%d", date: "$date" } },
            ...setMaxAccumulators,
          },
        },
      ];

      // --- Source 3: Workout sessions (dynamic values keyed by field key) ---
      const workoutPipeline = [
        {
          $match: { user: userId, deleted_at: null, startedAt: dateFilter },
        },
        { $unwind: "$exercises" },
        { $match: { "exercises.exercise_id": exId } },
        { $unwind: "$exercises.sets" },
        {
          $group: {
            _id: { $dateToString: { format: "%Y-%m-%d", date: "$startedAt" } },
            ...workoutMaxAccumulators,
          },
        },
      ];

      const [perfPoints, manualPoints, workoutPoints] = await Promise.all([
        this.performanceModel.aggregate(setPipeline()),
        this.manualPerformanceModel.aggregate(setPipeline()),
        this.workoutSessionModel.aggregate(workoutPipeline),
      ]);

      // Merge by day → per-metric daily best across all three sources.
      const byDay = new Map<string, Record<string, number>>();
      for (const row of [...perfPoints, ...manualPoints, ...workoutPoints]) {
        const day: string = row._id;
        if (day == null) continue;
        const dayMetrics = byDay.get(day) ?? {};
        for (const key of CANDIDATES) {
          const value: number | null = row[key];
          if (value == null) continue;
          if (dayMetrics[key] === undefined || value > dayMetrics[key]) {
            dayMetrics[key] = value;
          }
        }
        byDay.set(day, dayMetrics);
      }

      // Decide the metric: trust the category if it resolved AND actually has
      // logged data; otherwise fall back to whichever candidate has data.
      // Legacy entries may hold the value under another field (e.g. assistance
      // stored as `weight`), which would otherwise render an empty graph
      // despite existing logs.
      const hasData = (key: StrengthMetricKey) =>
        Array.from(byDay.values()).some((m) => m[key] !== undefined);
      const metricKey: StrengthMetricKey =
        (categoryKey && hasData(categoryKey) ? categoryKey : null) ??
        CANDIDATES.find((k) => hasData(k)) ??
        categoryKey ??
        "weight";

      const unit =
        (category?.fields ?? []).find((f: any) => f.key === metricKey)?.unit ??
        this.defaultUnitFor(metricKey);

      // Flat list of daily-best values for the chosen metric, oldest first.
      const entries = Array.from(byDay.entries())
        .filter(([, m]) => m[metricKey] !== undefined)
        .map(([day, m]) => ({
          date: new Date(`${day}T00:00:00.000Z`),
          value: m[metricKey],
        }))
        .sort((a, b) => a.date.getTime() - b.date.getTime());

      const graph = this.buildGraphData(entries, period, to);

      // --- Stats cards above the chart (ALL-TIME, independent of `period`) ---
      // The chart respects the period filter, but the cards summarise the user's
      // whole history: strength increase since the very first entry, total logged
      // sessions, All-Time PR (from PRs/performances) and the last performance.
      const metricNe = { [metricKey]: { $ne: null } };

      // All-time daily-best of the chosen metric (same shape as the graph query,
      // but with no date filter) — used for starting/current/min/max.
      const allTimeSetPipeline = () => [
        { $match: { userId, exercise_id: exId, deleted_at: null } },
        {
          $group: {
            _id: { $dateToString: { format: "%Y-%m-%d", date: "$date" } },
            ...setMaxAccumulators,
          },
        },
      ];
      const allTimeWorkoutPipeline = [
        { $match: { user: userId, deleted_at: null } },
        { $unwind: "$exercises" },
        { $match: { "exercises.exercise_id": exId } },
        { $unwind: "$exercises.sets" },
        {
          $group: {
            _id: { $dateToString: { format: "%Y-%m-%d", date: "$startedAt" } },
            ...workoutMaxAccumulators,
          },
        },
      ];

      // Latest metric set from a Performance / ManualPerformance collection:
      // pick the most recent date, and within it the best set for the metric.
      const latestSetPipeline = (): any[] => [
        { $match: { userId, exercise_id: exId, deleted_at: null } },
        { $match: metricNe },
        { $sort: { date: -1, [metricKey]: -1 } },
        { $limit: 1 },
        {
          $project: {
            _id: 0,
            value: `$${metricKey}`,
            reps: "$reps",
            date: "$date",
          },
        },
      ];

      const [
        allPerf,
        allManual,
        allWorkout,
        totalSessions,
        prRows,
        lastPerf,
        lastManual,
        lastWorkout,
      ] = await Promise.all([
        this.performanceModel.aggregate(allTimeSetPipeline()),
        this.manualPerformanceModel.aggregate(allTimeSetPipeline()),
        this.workoutSessionModel.aggregate(allTimeWorkoutPipeline),

        // Card 2: total workout sessions that include this exercise.
        this.workoutSessionModel.countDocuments({
          user: userId,
          deleted_at: null,
          "exercises.exercise_id": exId,
        }),

        // Card 3: All-Time PR — best metric set from Performances (PRs) only.
        this.performanceModel.aggregate([
          { $match: { userId, exercise_id: exId, deleted_at: null } },
          { $match: metricNe },
          { $sort: { [metricKey]: -1, date: -1 } },
          { $limit: 1 },
          {
            $project: {
              _id: 0,
              value: `$${metricKey}`,
              reps: "$reps",
              date: "$date",
            },
          },
        ]),

        // Card 4: last performance — latest set from each source (below we keep
        // whichever is most recent across manual / PR / workout session).
        this.performanceModel.aggregate(latestSetPipeline()),
        this.manualPerformanceModel.aggregate(latestSetPipeline()),
        this.workoutSessionModel.aggregate([
          { $match: { user: userId, deleted_at: null } },
          { $unwind: "$exercises" },
          { $match: { "exercises.exercise_id": exId } },
          { $unwind: "$exercises.sets" },
          {
            $addFields: {
              _value: {
                $convert: {
                  input: `$exercises.sets.values.${metricKey}`,
                  to: "double",
                  onError: null,
                  onNull: null,
                },
              },
              _reps: {
                $convert: {
                  input: "$exercises.sets.values.reps",
                  to: "double",
                  onError: null,
                  onNull: null,
                },
              },
            },
          },
          { $match: { _value: { $ne: null } } },
          { $sort: { startedAt: -1, _value: -1 } },
          { $limit: 1 },
          {
            $project: {
              _id: 0,
              value: "$_value",
              reps: "$_reps",
              date: "$startedAt",
            },
          },
        ]),
      ]);

      // Merge all-time daily bests → oldest-first list of the chosen metric.
      const allByDay = new Map<string, number>();
      for (const row of [...allPerf, ...allManual, ...allWorkout]) {
        const day: string = row._id;
        if (day == null) continue;
        const value: number | null = row[metricKey];
        if (value == null) continue;
        const cur = allByDay.get(day);
        if (cur === undefined || value > cur) allByDay.set(day, value);
      }
      const allValues = Array.from(allByDay.entries())
        .sort((a, b) => a[0].localeCompare(b[0]))
        .map(([, value]) => value);

      // Card 1: strength increase from the first-ever entry to the latest.
      const startingWeight = allValues.length ? allValues[0] : null;
      const currentWeight = allValues.length
        ? allValues[allValues.length - 1]
        : null;
      const minimumWeight = allValues.length ? Math.min(...allValues) : null;
      const maximumWeight = allValues.length ? Math.max(...allValues) : null;
      // Normalise by |start| so assisted_weight (negative values) still reads as
      // a positive gain when assistance drops, e.g. -30kg -> -10kg = +66%.
      let changeSinceStart: number | null = null;
      if (
        startingWeight != null &&
        currentWeight != null &&
        startingWeight !== 0
      ) {
        changeSinceStart = Math.round(
          ((currentWeight - startingWeight) / Math.abs(startingWeight)) * 100,
        );
      }

      // Card 3: All-Time PR value + its rep count → e.g. "110 kg x3".
      const allTimePR = prRows[0]
        ? { value: prRows[0].value, reps: prRows[0].reps ?? null }
        : null;

      // Card 4: most recent performance across every source → e.g. "105 kg x5".
      const lastCandidates = [
        lastPerf[0] && { ...lastPerf[0], source: "performance" },
        lastManual[0] && { ...lastManual[0], source: "manual" },
        lastWorkout[0] && { ...lastWorkout[0], source: "workout" },
      ].filter(Boolean) as Array<{
        value: number;
        reps: number | null;
        date: Date;
        source: string;
      }>;
      const lastPerformance = lastCandidates.length
        ? lastCandidates.reduce((a, b) =>
            new Date(b.date).getTime() > new Date(a.date).getTime() ? b : a,
          )
        : null;

      return {
        metric: { key: metricKey, unit },
        graph,
        stats: {
          startingWeight, // first-ever logged value for the metric
          currentWeight, // latest logged value for the metric
          changeSinceStart, // % change first-ever -> latest (card 1)
          minimumWeight, // all-time lowest
          maximumWeight, // all-time highest across all sources
          totalSessions, // workout sessions with this exercise (card 2)
          allTimePR, // best set from PRs: { value, reps } (card 3)
          lastPerformance, // latest across sources: { value, reps, source, date } (card 4)
        },
      };
    }
  }

  // Most-recently-logged exercise for a user across all three sources
  // (performances/PRs, manual performances and workout sessions). Used to pick a
  // sensible default exercise for the progress dashboard's strength card.
  // Returns null when the user has never logged anything.
  async getMostRecentExerciseId(
    userId: Types.ObjectId,
  ): Promise<Types.ObjectId | null> {
    const [perf, manual, workout] = await Promise.all([
      this.performanceModel
        .findOne({ userId, deleted_at: null })
        .sort({ date: -1 })
        .select({ exercise_id: 1, date: 1 })
        .lean(),
      this.manualPerformanceModel
        .findOne({ userId, deleted_at: null })
        .sort({ date: -1 })
        .select({ exercise_id: 1, date: 1 })
        .lean(),
      this.workoutSessionModel.aggregate([
        { $match: { user: userId, deleted_at: null } },
        { $sort: { startedAt: -1 } },
        { $limit: 1 },
        { $unwind: "$exercises" },
        {
          $project: {
            _id: 0,
            exercise_id: "$exercises.exercise_id",
            date: "$startedAt",
          },
        },
      ]),
    ]);

    console.log("Most recent exercise candidates:", { perf, manual, workout });

    const candidates = [
      perf && { exercise_id: perf.exercise_id, date: perf.date },
      manual && { exercise_id: manual.exercise_id, date: manual.date },
      workout[0] && { exercise_id: workout[0].exercise_id, date: workout[0].date },
    ].filter(Boolean) as Array<{ exercise_id: Types.ObjectId; date: Date }>;

    if (!candidates.length) return null;

    const latest = candidates.reduce((a, b) =>
      new Date(b.date).getTime() > new Date(a.date).getTime() ? b : a,
    );
    return latest.exercise_id;
  }

  // Localised identity (name + category) for one exercise — used to label the
  // strength card on the progress dashboard. Returns null when it doesn't exist.
  async getExerciseIdentity(exId: Types.ObjectId, lang: string) {
    const exercise = await this.exerciseModel
      .findOne({ _id: exId, deleted_at: null })
      .lean();
    if (!exercise) return null;

    const category = exercise.category
      ? await this.exerciseCategoryModel.findById(exercise.category).lean()
      : null;

    return {
      _id: exercise._id,
      name: exercise.name?.[lang] ?? exercise.name?.["en"],
      category: category
        ? {
            _id: category._id,
            name: category.name?.[lang] ?? category.name?.["en"],
          }
        : null,
    };
  }

  // GET /strength/entries/:exerciseId?period=7d|30d|3m|6m|all
  // Flat "All entries" list for one exercise, unifying the same three sources as
  // the graph — completed workout sessions, manual performances and PRs. To keep
  // the list clean, only the single best set (Top Set) per day is returned, where
  // "best" is the highest value of the exercise's strength metric (the same metric
  // the graph plots). Honours the period filter shown at the top of the screen.
  async getExerciseEntries(
    exerciseId: string,
    period: StrengthPeriod,
    req: Request,
    res: Response,
    user: any,
  ) {
    try {
      const exId = new Types.ObjectId(exerciseId);
      const userId = new Types.ObjectId(user._id);

      const exercise = await this.exerciseModel
        .findOne({ _id: exId, deleted_at: null })
        .lean();
      if (!exercise) {
        return this.resService.notFound(res, "Exercise not found", req);
      }

      const category = exercise.category
        ? await this.exerciseCategoryModel.findById(exercise.category).lean()
        : null;

      // Same candidate metrics / priority as the graph so the Top Set is chosen
      // by the exact value the chart plots.
      const CANDIDATES: StrengthMetricKey[] = [
        "weight",
        "added_weight",
        "assisted_weight",
        "distance",
        "duration",
        "reps",
      ];
      const categoryKey = this.resolveMetricKey(
        (category?.fields ?? []).map((f: any) => f.key),
      );

      const { from, to } = this.resolveRange(period);
      const dateFilter = from ? { $gte: from, $lte: to } : { $lte: to };

      // --- Source 1 & 2: Performance and Manual Performance (flat single set) ---
      // Emit each entry's stored set with the owning day/date.
      const setPipeline = (source: string) => [
        {
          $match: {
            userId,
            exercise_id: exId,
            deleted_at: null,
            date: dateFilter,
          },
        },
        {
          $project: {
            _id: 0,
            id: "$_id",
            day: { $dateToString: { format: "%Y-%m-%d", date: "$date" } },
            date: "$date",
            source,
            set: {
              reps: "$reps",
              weight: "$weight",
              duration: "$duration",
              distance: "$distance",
              assisted_weight: "$assisted_weight",
              added_weight: "$added_weight",
            },
          },
        },
      ];

      // --- Source 3: Workout sessions (dynamic values keyed by field key) ---
      const workoutPipeline = [
        {
          $match: { user: userId, deleted_at: null, startedAt: dateFilter },
        },
        { $unwind: "$exercises" },
        { $match: { "exercises.exercise_id": exId } },
        { $unwind: "$exercises.sets" },
        {
          $project: {
            _id: 0,
            id: "$_id",
            day: { $dateToString: { format: "%Y-%m-%d", date: "$startedAt" } },
            date: "$startedAt",
            source: "workout",
            values: "$exercises.sets.values",
          },
        },
      ];

      const [perfSets, manualSets, workoutSets] = await Promise.all([
        this.performanceModel.aggregate(setPipeline("performance")),
        this.manualPerformanceModel.aggregate(setPipeline("manual")),
        this.workoutSessionModel.aggregate(workoutPipeline),
      ]);

      // Normalise every set (from any source) into the same field shape.
      const rows = [
        ...perfSets.map((r) => this.toEntryRow(r, r.set)),
        ...manualSets.map((r) => this.toEntryRow(r, r.set)),
        ...workoutSets.map((r) => this.toEntryRow(r, r.values)),
      ];

      // Decide the metric: trust the category if it resolved; otherwise infer
      // from whichever candidate actually has logged data (mirrors the graph).
      const hasData = (key: StrengthMetricKey) =>
        rows.some((r) => r.set[key] != null);
      const metricKey: StrengthMetricKey =
        categoryKey ?? CANDIDATES.find((k) => hasData(k)) ?? "weight";

      const unit =
        (category?.fields ?? []).find((f: any) => f.key === metricKey)?.unit ??
        this.defaultUnitFor(metricKey);

      const lang = (req.headers["accept-language"] as string) || "en";

      // Collapse to one Top Set per day: the set with the highest metric value.
      const bestByDay = new Map<string, any>();
      for (const row of rows) {
        const value = row.set[metricKey];
        if (value == null) continue;
        const current = bestByDay.get(row.day);
        if (!current || value > current.value) {
          bestByDay.set(row.day, {
            id: row.id,
            date: row.date,
            source: row.source,
            // Manual logs and PRs are single-set docs with edit/delete routes;
            // workout-session sets live inside a session and are read-only here.
            editable: row.source === "manual" || row.source === "performance",
            value,
            set: row.set,
          });
        }
      }

      // Latest day first.
      const entries = Array.from(bestByDay.values()).sort(
        (a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(),
      );

      return this.resService.success(
        res,
        "Exercise entries fetched successfully",
        req,
        {
          exercise: {
            _id: exercise._id,
            name: exercise.name?.[lang] ?? exercise.name?.["en"],
            category: category
              ? {
                  _id: category._id,
                  name: category.name?.[lang] ?? category.name?.["en"],
                }
              : null,
          },
          metric: { key: metricKey, unit },
          entries,
        },
      );
    } catch (error: any) {
      return this.resService.serverError(
        res,
        "Failed to fetch exercise entries",
        req,
        error.message || error,
      );
    }
  }

  // Normalise a raw set from any source into a full, numeric set object holding
  // every strength field. Workout-session sets store values as a dynamic
  // (possibly string-keyed) map, so coerce each field to a number.
  private toEntryRow(
    row: { id: any; day: string; date: Date; source: string },
    raw: Record<string, any> | null | undefined,
  ): {
    id: any;
    day: string;
    date: Date;
    source: string;
    set: Record<string, number | null>;
  } {
    const FIELDS = [
      "reps",
      "weight",
      "duration",
      "distance",
      "assisted_weight",
      "added_weight",
    ];
    const set: Record<string, number | null> = {};
    for (const key of FIELDS) {
      const v = raw?.[key];
      const num = typeof v === "string" ? Number(v) : v;
      set[key] = num == null || Number.isNaN(num) ? null : num;
    }
    return { id: row.id, day: row.day, date: row.date, source: row.source, set };
  }

  // Map exercise id -> the set of field keys its category defines (e.g.
  // weighted_bodyweight -> {added_weight, reps}). Exercises without a
  // resolvable category map to null, meaning "store fields as sent".
  private async getCategoryFieldKeys(
    exerciseIds: Types.ObjectId[],
  ): Promise<Map<string, Set<string> | null>> {
    const exercises = await this.exerciseModel
      .find({ _id: { $in: exerciseIds }, deleted_at: null })
      .select({ category: 1 })
      .lean();

    const categoryIds = [
      ...new Set(
        exercises.filter((e) => e.category).map((e) => String(e.category)),
      ),
    ];
    const categories = categoryIds.length
      ? await this.exerciseCategoryModel
          .find({ _id: { $in: categoryIds } })
          .select({ fields: 1 })
          .lean()
      : [];
    const keysByCategory = new Map(
      categories.map((c) => [
        String(c._id),
        new Set((c.fields ?? []).map((f: any) => f.key)),
      ]),
    );

    const map = new Map<string, Set<string> | null>();
    for (const e of exercises) {
      map.set(
        String(e._id),
        e.category ? keysByCategory.get(String(e.category)) ?? null : null,
      );
    }
    return map;
  }

  // When the client sends a plain `weight` for an exercise whose category
  // doesn't track `weight`, move the value to the field the category does
  // track: assisted_weight (stored negative — less assistance is stronger) or
  // added_weight. Sets that already populate the category field, and categories
  // that track `weight` itself, are left untouched.
  private remapWeightForCategory(
    set: Partial<Record<StrengthMetricKey, number>>,
    fieldKeys: Set<string> | null | undefined,
  ): Partial<Record<StrengthMetricKey, number>> {
    if (!fieldKeys || set.weight == null || fieldKeys.has("weight")) return set;
    if (fieldKeys.has("assisted_weight") && set.assisted_weight == null) {
      return {
        ...set,
        assisted_weight: -Math.abs(set.weight),
        weight: undefined,
      };
    }
    if (fieldKeys.has("added_weight") && set.added_weight == null) {
      return { ...set, added_weight: Math.abs(set.weight), weight: undefined };
    }
    return set;
  }

  // The six metric fields a set may carry, in strength priority order.
  private readonly SET_FIELDS: StrengthMetricKey[] = [
    "weight",
    "added_weight",
    "assisted_weight",
    "distance",
    "duration",
    "reps",
  ];

  // Score a set by its primary (highest-priority populated) metric. Higher is
  // stronger for every metric — assisted_weight is negative, so the least
  // negative (least assistance) value wins. Returns null for an empty set.
  private scoreOf(set: Record<string, number | null | undefined>): number | null {
    for (const key of this.SET_FIELDS) {
      const v = set?.[key];
      if (v !== null && v !== undefined) return v;
    }
    return null;
  }

  // Collapse an array of sets to the single strongest one, as flat fields ready
  // to store. All sets for one exercise share a category (hence the same primary
  // metric), so comparing by scoreOf picks the best set for that metric.
  private pickBestSet(sets: Array<Partial<Record<StrengthMetricKey, number>>> = []): {
    reps: number | null;
    weight: number | null;
    duration: number | null;
    distance: number | null;
    assisted_weight: number | null;
    added_weight: number | null;
  } {
    const normalize = (s: Partial<Record<StrengthMetricKey, number>> = {}) => ({
      reps: s.reps ?? null,
      weight: s.weight ?? null,
      duration: s.duration ?? null,
      distance: s.distance ?? null,
      assisted_weight: s.assisted_weight ?? null,
      added_weight: s.added_weight ?? null,
    });

    const empty = normalize();
    if (!sets.length) return empty;

    return sets.map(normalize).reduce((best, cur) => {
      const bs = this.scoreOf(best);
      const cs = this.scoreOf(cur);
      if (cs === null) return best;
      if (bs === null) return cur;
      return cs > bs ? cur : best;
    });
  }

  // Pick the strength metric from a category's field keys.
  // weight > added_weight > assisted_weight > distance > duration > reps
  // (highest value = strongest; for assisted_weight the values are negative,
  // so "highest" = least negative = least assistance = strongest).
  // Returns null when the category has no recognizable metric (e.g. missing /
  // orphaned category) so the caller can infer it from the data instead.
  private resolveMetricKey(fieldKeys: string[]): StrengthMetricKey | null {
    const priority: StrengthMetricKey[] = [
      "weight",
      "added_weight",
      "assisted_weight",
      "distance",
      "duration",
      "reps",
    ];
    return priority.find((k) => fieldKeys.includes(k)) ?? null;
  }

  // Fallback unit when the category (and therefore its field unit) is unavailable.
  private defaultUnitFor(key: StrengthMetricKey): string {
    switch (key) {
      case "weight":
      case "added_weight":
      case "assisted_weight":
        return "kg";
      case "distance":
        return "km";
      case "duration":
        return "min";
      default:
        return "";
    }
  }

  // Convert a filter period into a date range. `from = null` means all time.
  // Boundaries align with the buckets produced by buildGraphData.
  private resolveRange(period: StrengthPeriod): { from: Date | null; to: Date } {
    // Entry dates are the user's (German) calendar date pinned to UTC midnight,
    // so "today" is resolved in APP_TIMEZONE and all arithmetic stays in UTC
    // date-space to match the stored values.
    const to = endOfCurrentDayUtc();
    if (period === "all") return { from: null, to };

    let from: Date;
    switch (period) {
      case "30d":
        from = new Date(
          Date.UTC(to.getUTCFullYear(), to.getUTCMonth(), to.getUTCDate() - 29),
        );
        break;
      case "3m":
        from = new Date(Date.UTC(to.getUTCFullYear(), to.getUTCMonth() - 2, 1));
        break;
      case "6m":
        from = new Date(Date.UTC(to.getUTCFullYear(), to.getUTCMonth() - 5, 1));
        break;
      case "7d":
      default:
        from = new Date(
          Date.UTC(to.getUTCFullYear(), to.getUTCMonth(), to.getUTCDate() - 6),
        );
    }
    return { from, to };
  }

  // Bucket daily strength entries into chart points, keeping the highest value
  // per bucket. Mirrors the weight-history graph (7d -> days, 30d -> weeks,
  // 3m/6m -> months, all -> quarters/years).
  private buildGraphData(
    entries: Array<{ date: Date; value: number }>,
    period: string,
    now: Date,
  ): Array<{ label: string; value: number | null }> {
    const MONTH_NAMES = [
      "Jan", "Feb", "Mar", "Apr", "May", "Jun",
      "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
    ];
    const DAY_NAMES = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];

    const bucketList: Array<{ key: string; label: string }> = [];
    const bucketMap = new Map<string, number[]>();

    if (period === "7d") {
      for (let i = 6; i >= 0; i--) {
        const d = new Date(
          Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - i),
        );
        const key = d.toISOString().slice(0, 10);
        bucketList.push({ key, label: DAY_NAMES[d.getUTCDay()] });
        bucketMap.set(key, []);
      }
      for (const e of entries) {
        const key = new Date(e.date).toISOString().slice(0, 10);
        bucketMap.get(key)?.push(e.value);
      }
    } else if (period === "30d") {
      const cursor = new Date(
        Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - 29),
      );
      while (cursor <= now) {
        const { week, year } = this.getISOWeek(cursor);
        const key = `${year}-W${week}`;
        if (!bucketMap.has(key)) {
          bucketList.push({ key, label: `W${week}` });
          bucketMap.set(key, []);
        }
        cursor.setUTCDate(cursor.getUTCDate() + 1);
      }
      for (const e of entries) {
        const { week, year } = this.getISOWeek(new Date(e.date));
        const key = `${year}-W${week}`;
        bucketMap.get(key)?.push(e.value);
      }
    } else if (period === "3m" || period === "6m") {
      const numMonths = period === "3m" ? 3 : 6;
      for (let i = numMonths - 1; i >= 0; i--) {
        const d = new Date(
          Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - i, 1),
        );
        const key = `${d.getUTCFullYear()}-${d.getUTCMonth()}`;
        bucketList.push({ key, label: MONTH_NAMES[d.getUTCMonth()] });
        bucketMap.set(key, []);
      }
      for (const e of entries) {
        const d = new Date(e.date);
        const key = `${d.getUTCFullYear()}-${d.getUTCMonth()}`;
        bucketMap.get(key)?.push(e.value);
      }
    } else if (period === "all") {
      if (!entries.length) return [];
      const firstDate = new Date(entries[0].date);
      const lastDate = new Date(entries[entries.length - 1].date);
      const monthsDiff =
        (lastDate.getUTCFullYear() - firstDate.getUTCFullYear()) * 12 +
        (lastDate.getUTCMonth() - firstDate.getUTCMonth());

      if (monthsDiff > 24) {
        for (
          let y = firstDate.getUTCFullYear();
          y <= lastDate.getUTCFullYear();
          y++
        ) {
          const key = `${y}`;
          bucketList.push({ key, label: key });
          bucketMap.set(key, []);
        }
        for (const e of entries) {
          const key = `${new Date(e.date).getUTCFullYear()}`;
          bucketMap.get(key)?.push(e.value);
        }
      } else {
        let year = firstDate.getUTCFullYear();
        let q = Math.floor(firstDate.getUTCMonth() / 3);
        const endYear = lastDate.getUTCFullYear();
        const endQ = Math.floor(lastDate.getUTCMonth() / 3);
        while (year < endYear || (year === endYear && q <= endQ)) {
          const key = `${year}-Q${q + 1}`;
          bucketList.push({ key, label: `Q${q + 1} ${year}` });
          bucketMap.set(key, []);
          q++;
          if (q > 3) {
            q = 0;
            year++;
          }
        }
        for (const e of entries) {
          const d = new Date(e.date);
          const quarter = Math.floor(d.getUTCMonth() / 3);
          const key = `${d.getUTCFullYear()}-Q${quarter + 1}`;
          bucketMap.get(key)?.push(e.value);
        }
      }
    }

    return bucketList.map(({ key, label }) => {
      const values = bucketMap.get(key) ?? [];
      // Highest strength in the bucket (peak), null when no data.
      const value = values.length ? Math.max(...values) : null;
      return { label, value };
    });
  }

  // ISO-8601 week number + week-year, computed in UTC (entry dates are stored
  // at UTC midnight). The week-year can differ from the calendar year around
  // New Year (e.g. Dec 29 can belong to week 1 of the next year), so week keys
  // must use it instead of getFullYear() to avoid duplicate W1/W53 buckets.
  private getISOWeek(date: Date): { week: number; year: number } {
    const d = new Date(
      Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()),
    );
    const dayNum = d.getUTCDay() || 7;
    d.setUTCDate(d.getUTCDate() + 4 - dayNum);
    const year = d.getUTCFullYear();
    const yearStart = new Date(Date.UTC(year, 0, 1));
    const week = Math.ceil(
      ((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7,
    );
    return { week, year };
  }
}
