/**
 * Shared utility functions for scheduler services.
 * Exported separately to allow testing without side effects.
 */

import { getDateLocale, type Language } from "../i18n/translations.js";
import { isLiquidContainerPackageType, isTubePackageType } from "./package-profiles.js";

export const CANONICAL_WEEKDAY_ORDER = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const;

export type Weekday = (typeof CANONICAL_WEEKDAY_ORDER)[number];
export type IntakeScheduleMode = "interval" | "weekdays";

type ScheduleLike = {
	every: number;
	start: string;
	scheduleMode?: IntakeScheduleMode;
	weekdays?: Weekday[];
};

// Legacy type - individual blister schedule (DEPRECATED: use Intake instead)
export type Blister = {
	usage: number;
	every: number;
	start: string;
	scheduleMode?: IntakeScheduleMode;
	weekdays?: Weekday[];
};

// New unified intake type with per-intake takenBy
export type Intake = {
	usage: number;
	every: number;
	start: string;
	scheduleMode?: IntakeScheduleMode;
	weekdays?: Weekday[];
	intakeUnit?: "ml" | "tsp" | "tbsp" | null;
	takenBy: string | null; // Person taking this specific intake (null = use medication-level takenBy)
	intakeRemindersEnabled: boolean;
};

const isValidIntakeUnit = (value: unknown): value is "ml" | "tsp" | "tbsp" =>
	value === "ml" || value === "tsp" || value === "tbsp";

const weekdayToJavascriptDay: Record<Weekday, number> = {
	mon: 1,
	tue: 2,
	wed: 3,
	thu: 4,
	fri: 5,
	sat: 6,
	sun: 0,
};

function isWeekday(value: unknown): value is Weekday {
	return typeof value === "string" && CANONICAL_WEEKDAY_ORDER.includes(value as Weekday);
}

function normalizeScheduleMode(value: unknown): IntakeScheduleMode {
	return value === "weekdays" ? "weekdays" : "interval";
}

function toDateOnly(date: Date): Date {
	return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0);
}

function getLocalDateOrdinal(date: Date): number {
	return Math.floor(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / 86_400_000);
}

function addLocalCalendarDays(date: Date, days: number): Date {
	const next = new Date(date);
	next.setDate(next.getDate() + days);
	return next;
}

export function getDateOnlyTimestamp(date: Date): number {
	return toDateOnly(date).getTime();
}

export function getWeekdayFromDate(date: Date): Weekday {
	const weekday = CANONICAL_WEEKDAY_ORDER.find((entry) => weekdayToJavascriptDay[entry] === date.getDay());
	return weekday ?? "mon";
}

export function getWeekdayFromStart(start: string): Weekday {
	const startDate = parseLocalDateTime(start);
	if (Number.isNaN(startDate.getTime())) {
		return "mon";
	}
	return getWeekdayFromDate(startDate);
}

export function normalizeWeekdays(value: unknown, start: string): Weekday[] {
	if (!Array.isArray(value)) {
		return [getWeekdayFromStart(start)];
	}

	const uniqueWeekdays = new Set<Weekday>();
	for (const weekday of value) {
		if (isWeekday(weekday)) {
			uniqueWeekdays.add(weekday);
		}
	}

	const normalized = CANONICAL_WEEKDAY_ORDER.filter((weekday) => uniqueWeekdays.has(weekday));
	return normalized.length > 0 ? normalized : [getWeekdayFromStart(start)];
}

function createOccurrenceAtDate(date: Date, startDate: Date): number {
	return new Date(
		date.getFullYear(),
		date.getMonth(),
		date.getDate(),
		startDate.getHours(),
		startDate.getMinutes(),
		startDate.getSeconds(),
		startDate.getMilliseconds()
	).getTime();
}

function getNormalizedWeekdays(schedule: ScheduleLike): Weekday[] {
	if (schedule.scheduleMode !== "weekdays") {
		return [];
	}

	if (schedule.weekdays && schedule.weekdays.length > 0) {
		return schedule.weekdays;
	}

	return [getWeekdayFromStart(schedule.start)];
}

export function getAverageOccurrencesPerDay(
	schedule: Pick<ScheduleLike, "every" | "start" | "scheduleMode" | "weekdays">
): number {
	if (schedule.scheduleMode === "weekdays") {
		return getNormalizedWeekdays(schedule).length / 7;
	}

	return 1 / Math.max(1, schedule.every);
}

export function getMaxScheduledGapDays(
	schedule: Pick<ScheduleLike, "every" | "start" | "scheduleMode" | "weekdays">
): number {
	if (schedule.scheduleMode !== "weekdays") {
		return Math.max(1, schedule.every);
	}

	const weekdays = getNormalizedWeekdays(schedule).map((weekday) => CANONICAL_WEEKDAY_ORDER.indexOf(weekday));
	if (weekdays.length === 0) {
		return 7;
	}

	let maxGap = 0;
	for (let index = 0; index < weekdays.length; index++) {
		const current = weekdays[index];
		const next = weekdays[(index + 1) % weekdays.length];
		const gap = index === weekdays.length - 1 ? next + 7 - current : next - current;
		if (gap > maxGap) {
			maxGap = gap;
		}
	}

	return maxGap || 7;
}

export function getScheduleMatchWindowMs(
	schedule: Pick<ScheduleLike, "every" | "start" | "scheduleMode" | "weekdays">
): number {
	return (getMaxScheduledGapDays(schedule) * 86_400_000) / 2;
}

export function getNextScheduledOccurrenceTime(
	schedule: Pick<ScheduleLike, "every" | "start" | "scheduleMode" | "weekdays">,
	fromMs: number,
	inclusive: boolean = true
): number | null {
	const startDate = parseLocalDateTime(schedule.start);
	const startTime = startDate.getTime();
	if (Number.isNaN(startTime)) {
		return null;
	}

	const lowerBound = inclusive ? fromMs : fromMs + 1;
	if (schedule.scheduleMode !== "weekdays") {
		const intervalDays = Math.max(1, schedule.every);
		if (startTime >= lowerBound) {
			return startTime;
		}

		const lowerBoundDate = new Date(lowerBound);
		const startOrdinal = getLocalDateOrdinal(startDate);
		const lowerBoundOrdinal = getLocalDateOrdinal(lowerBoundDate);
		const daysBetween = Math.max(0, lowerBoundOrdinal - startOrdinal);
		const wholeIntervals = Math.floor(daysBetween / intervalDays);

		let candidate = addLocalCalendarDays(startDate, wholeIntervals * intervalDays);
		while (candidate.getTime() < lowerBound) {
			candidate = addLocalCalendarDays(candidate, intervalDays);
		}

		return candidate.getTime();
	}

	const candidateStart = Math.max(lowerBound, startTime);
	const candidateDateOnly = toDateOnly(new Date(candidateStart));
	let nextOccurrence: number | null = null;

	for (const weekday of getNormalizedWeekdays(schedule)) {
		const candidateDate = new Date(candidateDateOnly);
		const offsetDays = (weekdayToJavascriptDay[weekday] - candidateDate.getDay() + 7) % 7;
		candidateDate.setDate(candidateDate.getDate() + offsetDays);

		let occurrenceMs = createOccurrenceAtDate(candidateDate, startDate);
		if (occurrenceMs < candidateStart) {
			candidateDate.setDate(candidateDate.getDate() + 7);
			occurrenceMs = createOccurrenceAtDate(candidateDate, startDate);
		}

		if (nextOccurrence === null || occurrenceMs < nextOccurrence) {
			nextOccurrence = occurrenceMs;
		}
	}

	return nextOccurrence;
}

export function forEachScheduledOccurrenceInRange(
	schedule: Pick<ScheduleLike, "every" | "start" | "scheduleMode" | "weekdays">,
	rangeStartMs: number,
	rangeEndMs: number,
	callback: (occurrenceMs: number) => void
): void {
	if (!Number.isFinite(rangeStartMs) || !Number.isFinite(rangeEndMs) || rangeEndMs < rangeStartMs) {
		return;
	}

	const startDate = parseLocalDateTime(schedule.start);
	const startTime = startDate.getTime();
	if (Number.isNaN(startTime) || rangeEndMs < startTime) {
		return;
	}

	if (schedule.scheduleMode !== "weekdays") {
		const intervalDays = Math.max(1, schedule.every);
		let occurrence = new Date(startDate);
		if (occurrence.getTime() < rangeStartMs) {
			const rangeStartDate = new Date(rangeStartMs);
			const startOrdinal = getLocalDateOrdinal(startDate);
			const rangeStartOrdinal = getLocalDateOrdinal(rangeStartDate);
			const daysBetween = Math.max(0, rangeStartOrdinal - startOrdinal);
			const wholeIntervals = Math.floor(daysBetween / intervalDays);
			occurrence = addLocalCalendarDays(startDate, wholeIntervals * intervalDays);

			while (occurrence.getTime() < rangeStartMs) {
				occurrence = addLocalCalendarDays(occurrence, intervalDays);
			}
		}

		for (let occurrenceMs = occurrence.getTime(); occurrenceMs <= rangeEndMs; ) {
			if (occurrenceMs >= rangeStartMs) {
				callback(occurrenceMs);
			}

			occurrence = addLocalCalendarDays(occurrence, intervalDays);
			occurrenceMs = occurrence.getTime();
		}
		return;
	}

	const lowerBound = Math.max(rangeStartMs, startTime);
	const firstDateOnly = toDateOnly(new Date(lowerBound));

	for (const weekday of getNormalizedWeekdays(schedule)) {
		const occurrenceDate = new Date(firstDateOnly);
		const offsetDays = (weekdayToJavascriptDay[weekday] - occurrenceDate.getDay() + 7) % 7;
		occurrenceDate.setDate(occurrenceDate.getDate() + offsetDays);

		let occurrenceMs = createOccurrenceAtDate(occurrenceDate, startDate);
		if (occurrenceMs < lowerBound) {
			occurrenceDate.setDate(occurrenceDate.getDate() + 7);
			occurrenceMs = createOccurrenceAtDate(occurrenceDate, startDate);
		}

		while (occurrenceMs <= rangeEndMs) {
			callback(occurrenceMs);
			occurrenceDate.setDate(occurrenceDate.getDate() + 7);
			occurrenceMs = createOccurrenceAtDate(occurrenceDate, startDate);
		}
	}
}

export function countScheduledOccurrencesInRange(
	schedule: Pick<ScheduleLike, "every" | "start" | "scheduleMode" | "weekdays">,
	rangeStartMs: number,
	rangeEndMs: number
): { count: number; lastOccurrenceMs: number | null } {
	let count = 0;
	let lastOccurrenceMs: number | null = null;

	forEachScheduledOccurrenceInRange(schedule, rangeStartMs, rangeEndMs, (occurrenceMs) => {
		count += 1;
		if (lastOccurrenceMs === null || occurrenceMs > lastOccurrenceMs) {
			lastOccurrenceMs = occurrenceMs;
		}
	});

	return { count, lastOccurrenceMs };
}

export function normalizeIntake(
	value: {
		usage?: unknown;
		every?: unknown;
		start?: unknown;
		scheduleMode?: unknown;
		weekdays?: unknown;
		intakeUnit?: unknown;
		takenBy?: unknown;
		intakeRemindersEnabled?: unknown;
	},
	defaultIntakeRemindersEnabled: boolean = false
): Intake {
	const start = typeof value.start === "string" ? value.start : new Date().toISOString();
	const scheduleMode = normalizeScheduleMode(value.scheduleMode);
	let every = 1;
	if (scheduleMode !== "weekdays") {
		if (typeof value.every === "number" && Number.isFinite(value.every) && value.every >= 1) {
			every = value.every;
		}
	}

	return {
		usage: typeof value.usage === "number" && Number.isFinite(value.usage) ? value.usage : 0,
		every,
		start,
		scheduleMode,
		weekdays: scheduleMode === "weekdays" ? normalizeWeekdays(value.weekdays, start) : [],
		intakeUnit: isValidIntakeUnit(value.intakeUnit) ? value.intakeUnit : null,
		takenBy: typeof value.takenBy === "string" && value.takenBy.trim() ? value.takenBy.trim() : null,
		intakeRemindersEnabled:
			typeof value.intakeRemindersEnabled === "boolean" ? value.intakeRemindersEnabled : defaultIntakeRemindersEnabled,
	};
}

/**
 * Normalize intake usage for stock math.
 *
 * Stock semantics:
 * - tube: no automatic depletion (unknown per-application amount)
 * - liquid_container/liquid forms: convert tsp/tbsp to ml
 * - others: usage as-is
 */
export function normalizeIntakeUsageForStock(
	intake: Pick<Intake, "usage" | "intakeUnit">,
	medicationForm?: string | null,
	packageType?: string | null
): number {
	const usage = Number(intake.usage);
	if (!Number.isFinite(usage) || usage <= 0) return 0;
	if (isTubePackageType(packageType)) return 0;

	const isLiquidStock = isLiquidContainerPackageType(packageType) || medicationForm === "liquid";
	if (!isLiquidStock) return usage;

	if (intake.intakeUnit === "tsp") return usage * 5;
	if (intake.intakeUnit === "tbsp") return usage * 15;
	return usage;
}

// =============================================================================
// Timezone utilities
// =============================================================================

/** Get current timezone from TZ env variable or default to UTC */
export function getTimezone(): string {
	return process.env.TZ || "UTC";
}

export function isValidTimezone(value: string): boolean {
	try {
		new Intl.DateTimeFormat("en-US", { timeZone: value });
		return true;
	} catch {
		return false;
	}
}

export function getEffectiveTimezone(override?: string | null): string {
	const normalized = override?.trim() ?? "";
	if (normalized && isValidTimezone(normalized)) {
		return normalized;
	}
	return getTimezone();
}

/** Format a date in the configured timezone */
export function formatInTimezone(date: Date, tz?: string): string {
	return date.toLocaleString("de-DE", {
		timeZone: tz ?? getTimezone(),
		day: "2-digit",
		month: "2-digit",
		year: "numeric",
		hour: "2-digit",
		minute: "2-digit",
	});
}

/** Get current hour in the configured timezone */
export function getCurrentHourInTimezone(tz?: string): number {
	const now = new Date();
	const timeStr = now.toLocaleString("en-US", {
		timeZone: tz ?? getTimezone(),
		hour: "numeric",
		hour12: false,
	});
	return parseInt(timeStr, 10);
}

/** Get today's date string in the configured timezone (YYYY-MM-DD) */
export function getTodayInTimezone(tz?: string): string {
	const now = new Date();
	const parts = now.toLocaleDateString("en-CA", { timeZone: tz ?? getTimezone() }).split("-");
	return parts.join("-"); // YYYY-MM-DD format
}

/** Calculate the next scheduled time for a given reminder hour */
export function getNextScheduledTime(reminderHour: number, tz?: string): Date {
	const now = new Date();
	const timezone = tz ?? getTimezone();

	// Get current time components in the target timezone
	const formatter = new Intl.DateTimeFormat("en-US", {
		timeZone: timezone,
		year: "numeric",
		month: "2-digit",
		day: "2-digit",
		hour: "2-digit",
		minute: "2-digit",
		hour12: false,
	});

	const parts = formatter.formatToParts(now);
	const getPart = (type: string) => parts.find((p) => p.type === type)?.value || "0";

	const currentHour = parseInt(getPart("hour"), 10);
	const currentMinute = parseInt(getPart("minute"), 10);

	// Calculate if we need tomorrow
	const needTomorrow = currentHour > reminderHour || (currentHour === reminderHour && currentMinute > 0);

	// Handle month overflow simply by adding a day to now if needed
	let targetDate: Date;
	if (needTomorrow) {
		targetDate = new Date(now.getTime() + 24 * 60 * 60 * 1000);
	} else {
		targetDate = new Date(now);
	}

	// Get the target date's date string in the timezone
	const targetFormatter = new Intl.DateTimeFormat("en-CA", {
		timeZone: timezone,
		year: "numeric",
		month: "2-digit",
		day: "2-digit",
	});
	const [targetYear, targetMonth, targetDay] = targetFormatter.format(targetDate).split("-").map(Number);

	// Now we need to find the UTC time that corresponds to reminderHour:00 on targetDate in the target timezone
	// Use a search approach: start with a guess and adjust
	const guessUtc = new Date(Date.UTC(targetYear, targetMonth - 1, targetDay, reminderHour, 0, 0, 0));

	// Check what hour this UTC time corresponds to in the target timezone
	const checkFormatter = new Intl.DateTimeFormat("en-US", {
		timeZone: timezone,
		hour: "2-digit",
		hour12: false,
	});

	// Adjust based on the difference
	const guessHour = parseInt(checkFormatter.format(guessUtc), 10);
	const hourDiff = guessHour - reminderHour;

	// Apply correction (if guessHour is higher, we need to subtract time)
	const correctedUtc = new Date(guessUtc.getTime() - hourDiff * 60 * 60 * 1000);

	return correctedUtc;
}

/** Calculate milliseconds until next check at the given reminder hour */
export function getMsUntilNextCheck(reminderHour: number, tz?: string): number {
	const next = getNextScheduledTime(reminderHour, tz);
	const msUntilNext = next.getTime() - Date.now();
	if (msUntilNext <= 0) {
		return msUntilNext + 24 * 60 * 60 * 1000;
	}
	return msUntilNext;
}

// =============================================================================
// Blister/medication parsing utilities
// =============================================================================

/**
 * Parse an ISO datetime string to local timestamp.
 * Extracts date/time components directly from the string to avoid
 * timezone conversion issues with Z suffix.
 *
 * "2026-01-23T20:55:00" → treated as local time 20:55
 * "2026-01-23T20:55:00.000Z" → also treated as local time 20:55 (Z ignored)
 */
export function parseLocalDateTime(isoString: string): Date {
	// Extract components: YYYY-MM-DDTHH:MM:SS (ignore Z and milliseconds)
	const match = isoString.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):?(\d{2})?/);
	if (!match) {
		// Fallback to Date parsing if format doesn't match
		return new Date(isoString);
	}

	const [, year, month, day, hour, minute, second] = match;
	// Create date using local time interpretation (no UTC conversion)
	return new Date(
		parseInt(year, 10),
		parseInt(month, 10) - 1, // Month is 0-indexed
		parseInt(day, 10),
		parseInt(hour, 10),
		parseInt(minute, 10),
		parseInt(second ?? "0", 10)
	);
}

/** Parse blister schedules from JSON columns (DEPRECATED: use parseIntakesJson instead) */
export function parseBlisters(row: { usageJson: string; everyJson: string; startJson: string }): Blister[] {
	try {
		const usage = JSON.parse(row.usageJson) as number[];
		const every = JSON.parse(row.everyJson) as number[];
		const start = JSON.parse(row.startJson) as string[];
		const len = Math.min(usage.length, every.length, start.length);
		const blisters: Blister[] = [];
		for (let i = 0; i < len; i++) {
			blisters.push({ usage: usage[i], every: every[i], start: start[i] });
		}
		return blisters;
	} catch {
		return [];
	}
}

/**
 * Parse intakes from the new unified intakesJson format.
 * Falls back to legacy parallel arrays if intakesJson is empty.
 * @param intakesJson - The new unified JSON string
 * @param legacyRow - Optional legacy row with usageJson, everyJson, startJson for fallback
 * @param medicationIntakeRemindersEnabled - Medication-level intakeRemindersEnabled (fallback for legacy)
 */
export function parseIntakesJson(
	intakesJson: string | null | undefined,
	legacyRow?: { usageJson: string; everyJson: string; startJson: string },
	medicationIntakeRemindersEnabled?: boolean
): Intake[] {
	// Try new format first
	if (intakesJson) {
		try {
			const parsed = JSON.parse(intakesJson);
			if (Array.isArray(parsed) && parsed.length > 0) {
				return parsed.map((intake: Record<string, unknown>) => normalizeIntake(intake));
			}
		} catch {
			// Fall through to legacy parsing
		}
	}

	// Fallback to legacy parallel arrays
	if (legacyRow) {
		const blisters = parseBlisters(legacyRow);
		return blisters.map((b) =>
			normalizeIntake(
				{
					usage: b.usage,
					every: b.every,
					start: b.start,
					intakeUnit: null,
					takenBy: null,
				},
				medicationIntakeRemindersEnabled ?? false
			)
		);
	}

	return [];
}

/**
 * Convert intakes to legacy blister format (for backward compatibility)
 */
export function intakesToBlisters(intakes: Intake[]): Blister[] {
	return intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start }));
}

/** Parse takenByJson to array of strings */
export function parseTakenByJson(takenByJson: string | null | undefined): string[] {
	if (!takenByJson) return [];
	try {
		const parsed = JSON.parse(takenByJson);
		return Array.isArray(parsed) ? parsed.filter((s: unknown) => typeof s === "string" && s.trim()) : [];
	} catch {
		return [];
	}
}

/**
 * Get all unique takenBy values from both medication-level and intake-level.
 * Used for filtering and sharing functionality.
 */
export function getAllTakenByForMedication(medicationTakenBy: string[], intakes: Intake[]): string[] {
	const allPeople = new Set<string>(medicationTakenBy);
	for (const intake of intakes) {
		if (intake.takenBy) {
			allPeople.add(intake.takenBy);
		}
	}
	return Array.from(allPeople);
}

/**
 * Check if a person takes this medication (either via medication-level or intake-level takenBy).
 */
export function personTakesMedication(person: string, medicationTakenBy: string[], intakes: Intake[]): boolean {
	if (person === "all") return medicationTakenBy.length > 0 || intakes.some((intake) => intake.takenBy !== null);
	if (medicationTakenBy.includes(person)) return true;
	return intakes.some((intake) => intake.takenBy === person);
}

// =============================================================================
// Stock calculation utilities
// =============================================================================

/** Calculate daily usage from blisters */
export function calculateDailyUsage(blisters: Blister[]): number {
	return blisters.reduce((sum, blister) => sum + blister.usage * getAverageOccurrencesPerDay(blister), 0);
}

/** Calculate depletion information for a medication */
export function calculateDepletionInfo(
	med: { count: number; blisters: Blister[] },
	language: Language
): { daysLeft: number | null; depletionDate: string | null } {
	const dailyUsage = calculateDailyUsage(med.blisters);
	if (dailyUsage <= 0) return { daysLeft: null, depletionDate: null };

	const daysLeft = Math.floor(med.count / dailyUsage);
	const depletionMs = Date.now() + daysLeft * 86_400_000;
	const depletionDate = new Date(depletionMs).toLocaleDateString(getDateLocale(language), {
		weekday: "short",
		day: "2-digit",
		month: "short",
	});

	return { daysLeft, depletionDate };
}

// =============================================================================
// Intake reminder utilities
// =============================================================================

export type UpcomingIntake = {
	medName: string;
	medicationId?: number;
	blisterIndex?: number;
	usage: number;
	intakeTime: Date;
	intakeTimeStr: string;
	takenBy: string | null; // Single person for this intake (null = no specific person)
	pillWeightMg: number | null;
	doseUnit?: string;
};

/**
 * Get all intakes for today (past and future) - used for repeat reminders.
 * Returns all intakes scheduled for today in user's timezone.
 * Now uses per-intake takenBy instead of medication-level.
 */
export function getTodaysIntakes(
	medName: string,
	intakes: Intake[],
	_medicationTakenBy: string[], // Medication-level takenBy as fallback
	pillWeightMg: number | null,
	locale: string,
	tz?: string,
	medicationId?: number,
	doseUnit?: string
): UpcomingIntake[] {
	const timezone = tz ?? getTimezone();
	const now = new Date();

	// Get start and end of today in user's timezone
	const todayStart = new Date(now.toLocaleString("en-US", { timeZone: timezone }));
	todayStart.setHours(0, 0, 0, 0);

	const todayEnd = new Date(now.toLocaleString("en-US", { timeZone: timezone }));
	todayEnd.setHours(23, 59, 59, 999);

	const result: UpcomingIntake[] = [];

	for (let blisterIdx = 0; blisterIdx < intakes.length; blisterIdx++) {
		const intake = intakes[blisterIdx];
		// Determine takenBy for this intake
		// If intake has its own takenBy, use it; otherwise null (no specific person)
		const effectiveTakenBy = intake.takenBy || null;

		forEachScheduledOccurrenceInRange(intake, todayStart.getTime(), todayEnd.getTime(), (occurrenceMs) => {
			const intakeDate = new Date(occurrenceMs);
			result.push({
				medName,
				medicationId,
				blisterIndex: blisterIdx,
				usage: intake.usage,
				intakeTime: intakeDate,
				intakeTimeStr: intakeDate.toLocaleTimeString(locale, {
					hour: "2-digit",
					minute: "2-digit",
					timeZone: timezone,
				}),
				takenBy: effectiveTakenBy,
				pillWeightMg,
				doseUnit,
			});
		});
	}

	return result.sort((left, right) => left.intakeTime.getTime() - right.intakeTime.getTime());
}

/**
 * Get upcoming intakes that fall within the reminder window.
 * Returns intakes that should be notified about right now.
 * Now uses per-intake takenBy instead of medication-level.
 */
export function getUpcomingIntakes(
	medName: string,
	intakes: Intake[],
	minutesBefore: number,
	_medicationTakenBy: string[], // Medication-level takenBy as fallback
	pillWeightMg: number | null,
	locale: string,
	tz?: string,
	nowOverride?: number,
	medicationId?: number,
	doseUnit?: string
): UpcomingIntake[] {
	const now = nowOverride ?? Date.now();
	const timezone = tz ?? getTimezone();

	// Get the current minute (truncated to minute boundary for precise matching)
	const currentMinuteStart = Math.floor(now / 60000) * 60000;
	const currentMinuteEnd = currentMinuteStart + 60000;

	const upcoming: UpcomingIntake[] = [];

	for (let blisterIdx = 0; blisterIdx < intakes.length; blisterIdx++) {
		const intake = intakes[blisterIdx];
		// Determine takenBy for this intake
		const effectiveTakenBy = intake.takenBy || null;

		const nextTime = getNextScheduledOccurrenceTime(intake, now, true);
		if (nextTime === null) continue;

		// Calculate when we should notify for this intake
		const notifyTime = nextTime - minutesBefore * 60 * 1000;

		// Match if:
		// 1. notifyTime falls within the current minute (normal case), OR
		// 2. notifyTime is in the past but intakeTime is still in the future (catch-up
		//    for missed advance reminder window — e.g. scheduler was down during the
		//    exact notification minute due to system sleep, restart, or heavy load)
		const isInCurrentMinute = notifyTime >= currentMinuteStart && notifyTime < currentMinuteEnd;
		const isMissedButStillUpcoming = notifyTime < currentMinuteStart && nextTime > now;

		if (isInCurrentMinute || isMissedButStillUpcoming) {
			const intakeDate = new Date(nextTime);
			upcoming.push({
				medName,
				medicationId,
				blisterIndex: blisterIdx,
				usage: intake.usage,
				intakeTime: intakeDate,
				intakeTimeStr: intakeDate.toLocaleTimeString(locale, {
					hour: "2-digit",
					minute: "2-digit",
					timeZone: timezone,
				}),
				takenBy: effectiveTakenBy,
				pillWeightMg,
				doseUnit,
			});
		}
	}

	return upcoming;
}

// =============================================================================
// State file utilities
// =============================================================================

export type ReminderState = {
	lastAutoEmailSent: string | null;
	lastAutoEmailDate: string | null;
	lastStockSchedulerCheckDate: string | null;
	notifiedMedications: string[];
	nextScheduledCheck: string | null;
	lastNotificationType: "stock" | "intake" | "prescription" | null;
	lastNotificationChannel: "email" | "push" | "both" | null;
};

export type IntakeReminderEntry = {
	firstSentAt: number; // Timestamp when first reminder was sent
	lastSentAt: number; // Timestamp when last reminder was sent
	sendCount: number; // How many times NAGGING reminder was sent (not counting advance)
	advanceSent?: boolean; // Whether the advance reminder (15 min before) was sent
};

export type IntakeReminderState = {
	reminders: Record<string, IntakeReminderEntry>; // key -> entry
};

/** Create default reminder state */
export function createDefaultReminderState(): ReminderState {
	return {
		lastAutoEmailSent: null,
		lastAutoEmailDate: null,
		lastStockSchedulerCheckDate: null,
		notifiedMedications: [],
		nextScheduledCheck: null,
		lastNotificationType: null,
		lastNotificationChannel: null,
	};
}

/** Create default intake reminder state */
export function createDefaultIntakeReminderState(): IntakeReminderState {
	return { reminders: {} };
}

/** Parse reminder state from JSON string */
export function parseReminderState(json: string): ReminderState {
	try {
		const saved = JSON.parse(json);
		return {
			lastAutoEmailSent: saved.lastAutoEmailSent ?? null,
			lastAutoEmailDate: saved.lastAutoEmailDate ?? null,
			lastStockSchedulerCheckDate: saved.lastStockSchedulerCheckDate ?? null,
			notifiedMedications: saved.notifiedMedications ?? [],
			nextScheduledCheck: saved.nextScheduledCheck ?? null,
			lastNotificationType: saved.lastNotificationType ?? null,
			lastNotificationChannel: saved.lastNotificationChannel ?? null,
		};
	} catch {
		return createDefaultReminderState();
	}
}

/** Parse intake reminder state from JSON string (backward compatible) */
export function parseIntakeReminderState(json: string): IntakeReminderState {
	try {
		const saved = JSON.parse(json);

		// Backward compatibility: convert old array format to new map format
		if (Array.isArray(saved.sentReminders)) {
			const reminders: Record<string, IntakeReminderEntry> = {};
			const now = Date.now();
			for (const key of saved.sentReminders) {
				reminders[key] = {
					firstSentAt: now,
					lastSentAt: now,
					sendCount: 1,
				};
			}
			return { reminders };
		}

		// New format
		return {
			reminders: saved.reminders ?? {},
		};
	} catch {
		return createDefaultIntakeReminderState();
	}
}

/** Clean up old intake reminder entries (older than given milliseconds) */
/** Clean up old intake reminder entries (using timezone-aware day check) */
export function cleanOldIntakeReminders(
	reminders: Record<string, IntakeReminderEntry>,
	tz: string
): Record<string, IntakeReminderEntry> {
	// Get start of today in user's timezone
	const now = new Date();
	const todayStart = new Date(now.toLocaleString("en-US", { timeZone: tz }));
	todayStart.setHours(0, 0, 0, 0);
	const todayStartMs = todayStart.getTime();

	// Keep only reminders from today onwards (based on dose timestamp in key)
	const cleaned: Record<string, IntakeReminderEntry> = {};
	for (const [key, entry] of Object.entries(reminders)) {
		const timestamp = parseInt(key.split(":").pop() || "0", 10);
		if (timestamp >= todayStartMs) {
			cleaned[key] = entry;
		}
	}
	return cleaned;
}
