import { allPromises, LoggerPort, NotFoundError } from '@humand-packages/common';
import { CommunityFeatureScope, CommunityFeatureService } from '@humand-packages/community-features';
import { DateTime } from 'luxon';
import { Service } from 'typedi';
import { PortsDI } from '../../../../../config/portsDI';
import { Instance } from '../../../../instances/business/models/instance';
import { InstancesService } from '../../../../instances/business/services/instancesService';
import { SiteService } from '../../../../regions/business/services/sitesService';
import { Incidence } from '../../../infrastructure/constants/incidences';
import { TimeTrackingIncidenceNotificationLogRepositoryPort } from '../../../infrastructure/ports/timeTracking/timeTrackingIncidenceNotificationLogRepositoryPort';
import { hasLocationIncidence } from '../../helpers/siteHelper';
import { AutoCloseNotificationBatch } from '../../models/timeTracking/autoCloseData';
import { TimeTrackingAutoCloseEntry } from '../../models/timeTracking/autoCloseEntry';
import { TimeTrackingIncidenceNotificationLogForCreation } from '../../models/timeTracking/timeTrackingIncidenceNotificationLog';
import { TimeTrackingEntry } from '../../models/timeTrackingEntry';
import TimeTrackingNotificationsPort from '../../ports/timeTrackingNotificationsPort';
import { TimeTrackingEntryType } from '../../types/entry';
import { isUserGeneratedSource, TimeTrackingEntrySource } from '../../types/source';
import { NotifiableIncidenceType } from '../../types/timeTracking/notifiableIncidenceType';
import { NotifyIncidenceParams } from '../../types/timeTracking/notifyIncidenceParams';
import TimeTrackingService from '../timeTracking';

const TIME_TRACKING_INCIDENCE_NOTIFICATIONS_OFF = 'TIME_TRACKING_INCIDENCE_NOTIFICATIONS_OFF';
const AUTO_CLOSE_DISPATCH_CONCURRENCY = 5;

@Service()
export class TimeTrackingIncidenceNotificationService {
    constructor(
        private readonly communityFeatureService: CommunityFeatureService,
        @PortsDI(TimeTrackingIncidenceNotificationLogRepositoryPort)
        private readonly logRepository: TimeTrackingIncidenceNotificationLogRepositoryPort,
        @PortsDI(TimeTrackingNotificationsPort)
        private readonly timeTrackingNotificationsPort: TimeTrackingNotificationsPort,
        @PortsDI(TimeTrackingService)
        private readonly timeTrackingService: TimeTrackingService,
        private readonly sitesService: SiteService,
        private readonly instancesService: InstancesService,
        private readonly logger: LoggerPort,
    ) {}

    /**
     * Single-entry entry point. Used by `createEntry`, `clockIn`, `clockOut` post-TX hooks.
     */
    public evaluateEntry = async (entry: TimeTrackingEntry, instance: Instance): Promise<void> => {
        await this.evaluateEntries([entry], entry.userId, instance);
    };

    /**
     * Multi-entry entry point. Used by `syncEntries` (bot-app offline sync, single user, possibly many dates)
     * and internally by `evaluateEntry`. Same-day guard applied to today's (instance-local) entries only;
     * each incidence type is then handled by its dedicated method:
     *   - LOCATION fires per-entry (entry-level idempotency via entryId)
     *   - LATE fires per-day (day-level idempotency via entryId IS NULL)
     */
    public evaluateEntries = async (entries: TimeTrackingEntry[], userId: number, instance: Instance): Promise<void> => {
        if (entries.length === 0) return;
        const timezone = instance.getTimezone();
        const todayLocal = DateTime.now().setZone(timezone).toISODate()!;

        const todayEntries = entries.filter((entry) => DateTime.fromJSDate(entry.time, { zone: timezone }).toISODate() === todayLocal);
        if (todayEntries.length === 0) return;

        await this.handleLocationIncidences(todayEntries, userId, instance, timezone, todayLocal);
        await this.handleLateIncidences(userId, instance, timezone, todayLocal);
    };

    /**
     * AUTO_CLOSE entry point. Best-effort: failures are swallowed so the auto-close TX is not undone.
     *
     * D-1 tolerance (today + yesterday) intentionally diverges from LATE/LOCATION's strict same-day
     * guard: the auto-close cron sweeps the last DEFAULT_AUTO_CLOSE_DAYS=3 days and typically runs
     * post-midnight, so "today only" would drop most legitimate notifications.
     */
    public evaluateAutoCloseEntries = async (batches: AutoCloseNotificationBatch[]): Promise<void> => {
        for (const batch of batches) {
            if (batch.autoCloseEntries.length === 0) continue;
            try {
                const instance = await this.instancesService.findByPk(batch.instanceId);
                await this.dispatchAutoCloseNotifications(instance, batch.autoCloseEntries, batch.summaryDateStringMap);
            } catch (err) {
                // Instance deleted between the auto-close commit and the post-tx dispatch is benign;
                // log at warn so it does not page on-call, and skip the rest of the batch.
                if (err instanceof NotFoundError) {
                    this.logger.warn({ instanceId: batch.instanceId }, 'Skipping AUTO_CLOSE notifications for missing instance');
                    continue;
                }
                this.logger.error({ err, instanceId: batch.instanceId }, 'Failed to dispatch AUTO_CLOSE incidence notifications');
            }
        }
    };

    private dispatchAutoCloseNotifications = async (
        instance: Instance,
        autoCloseEntries: TimeTrackingAutoCloseEntry[],
        summaryDateStringBySummaryId: Map<number, string>,
    ): Promise<void> => {
        const timezone = instance.getTimezone();
        const allowedDateStrings = this.buildAllowedAutoCloseDateStrings(timezone);

        const seen = new Set<string>();
        const dispatches: (() => Promise<void>)[] = [];
        for (const entry of autoCloseEntries) {
            if (!this.isDispatchableAutoCloseEntry(entry)) continue;
            const dateString = this.resolveAllowedDispatchDate(entry.summaryId, summaryDateStringBySummaryId, allowedDateStrings);
            if (!dateString) continue;
            const key = `${entry.userId}|${dateString}`;
            if (seen.has(key)) continue;
            seen.add(key);
            dispatches.push(() =>
                this.dispatch(
                    {
                        instanceId: instance.getId(),
                        userId: entry.userId,
                        dateString,
                        incidenceType: NotifiableIncidenceType.AUTO_CLOSE,
                        time: DateTime.fromJSDate(entry.time, { zone: timezone }).toFormat('HH:mm'),
                    },
                    null,
                ),
            );
        }
        await allPromises(dispatches, AUTO_CLOSE_DISPATCH_CONCURRENCY);
    };

    private buildAllowedAutoCloseDateStrings = (timezone: string): Set<string> => {
        const nowLocal = DateTime.now().setZone(timezone);
        return new Set([nowLocal.toISODate()!, nowLocal.minus({ days: 1 }).toISODate()!]);
    };

    private isDispatchableAutoCloseEntry = (entry: TimeTrackingAutoCloseEntry): entry is TimeTrackingAutoCloseEntry & { summaryId: number } => {
        return entry.type === TimeTrackingEntryType.END && entry.source === TimeTrackingEntrySource.AUTO_CLOSE && entry.summaryId !== undefined;
    };

    private resolveAllowedDispatchDate = (
        summaryId: number,
        summaryDateStringBySummaryId: Map<number, string>,
        allowedDateStrings: Set<string>,
    ): string | null => {
        const dateString = summaryDateStringBySummaryId.get(summaryId);
        if (!dateString || !allowedDateStrings.has(dateString)) return null;
        return dateString;
    };

    /**
     * LOCATION incidence — per-entry dispatch with entry-level idempotency keyed by entryId.
     * Fires once per offending entry: an entry whose location is outside its assigned site's tolerance.
     */
    private handleLocationIncidences = async (
        todayEntries: TimeTrackingEntry[],
        userId: number,
        instance: Instance,
        timezone: string,
        todayLocal: string,
    ): Promise<void> => {
        const siteIds = Array.from(new Set(todayEntries.map((e) => e.siteId).filter((v): v is number => !!v)));
        if (siteIds.length === 0) return;

        const sites = await this.sitesService.getByIds(siteIds, instance.getId());
        const siteById = new Map(sites.map((s) => [s.getId(), s]));

        for (const entry of todayEntries) {
            const site = entry.siteId ? siteById.get(entry.siteId) : undefined;
            if (!site || !hasLocationIncidence(entry, site)) continue;
            await this.dispatch(
                {
                    instanceId: instance.getId(),
                    userId,
                    dateString: todayLocal,
                    incidenceType: NotifiableIncidenceType.LOCATION,
                    time: DateTime.fromJSDate(entry.time, { zone: timezone }).toFormat('HH:mm'),
                },
                entry.id,
            );
        }
    };

    /**
     * LATE incidence — per-day dispatch with day-level idempotency keyed by entryId IS NULL.
     * Fires at most once per (user, day): if today's day-summary has the LATE incidence flagged and a
     * user-generated START entry exists, dispatch with the lateness in minutes.
     */
    private handleLateIncidences = async (userId: number, instance: Instance, timezone: string, todayLocal: string): Promise<void> => {
        const summaries = await this.timeTrackingService.getOrCreateDaySummariesByUser(
            userId,
            instance,
            todayLocal,
            todayLocal,
            /* includeEntries */ true,
        );
        for (const summary of summaries) {
            if (!summary.incidences.includes(Incidence.LATE)) continue;
            const hasUserGeneratedStart = summary.timeTrackingEntries.some(
                (entry) => entry.type === TimeTrackingEntryType.START && isUserGeneratedSource(entry.source),
            );
            if (!hasUserGeneratedStart) continue;
            const lateMinutes = summary.getLatenessInMinutes(timezone);
            if (lateMinutes <= 0) continue;
            await this.dispatch(
                {
                    instanceId: instance.getId(),
                    userId,
                    dateString: summary.dateString,
                    incidenceType: NotifiableIncidenceType.LATE,
                    lateMinutes,
                },
                null,
            );
        }
    };

    public dispatch = async (params: NotifyIncidenceParams, entryId: number | null): Promise<void> => {
        const features = await this.communityFeatureService.getAllCommunityFeatures(params.instanceId, CommunityFeatureScope.BOTH);
        if (features.some((f) => f.code === TIME_TRACKING_INCIDENCE_NOTIFICATIONS_OFF)) return;

        const { created } = await this.logRepository.createIfAbsent(
            new TimeTrackingIncidenceNotificationLogForCreation({
                instanceId: params.instanceId,
                userId: params.userId,
                dateString: params.dateString,
                incidenceType: params.incidenceType,
                entryId,
            }),
        );
        if (!created) return;

        await this.timeTrackingNotificationsPort.notifyIncidence(params).catch((err) =>
            this.logger.error('Failed to notify incidence', {
                err,
                userId: params.userId,
                dateString: params.dateString,
                incidenceType: params.incidenceType,
            }),
        );
    };
}
