import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import {
  catchError,
  distinctUntilChanged,
  first,
  map,
  shareReplay,
  switchMap,
} from 'rxjs/operators';
import { getRelevantEventsForCalendarIntegrationFromIcal } from '../schedule/ical/get-relevant-events-from-ical';
import {
  BehaviorSubject,
  combineLatest,
  defer,
  forkJoin,
  from,
  merge,
  Observable,
  of,
  Subject,
  timer,
} from 'rxjs';
import { T } from '../../t.const';
import { SnackService } from '../../core/snack/snack.service';
import { getStartOfDayTimestamp } from '../../util/get-start-of-day-timestamp';
import { getEndOfDayTimestamp } from '../../util/get-end-of-day-timestamp';
import { CalendarIntegrationEvent } from './calendar-integration.model';
import { fastArrayCompare } from '../../util/fast-array-compare';
import {
  isCalendarIssueTask,
  selectAllCalendarTaskEventIds,
} from '../tasks/store/task.selectors';
import { loadFromRealLs, saveToRealLs } from '../../core/persistence/local-storage';
import { LS } from '../../core/persistence/storage-keys.const';
import { Store } from '@ngrx/store';
import {
  ScheduleCalendarMapEntry,
  ScheduleFromCalendarEvent,
} from '../schedule/schedule.model';
import { getDbDateStr } from '../../util/get-db-date-str';
import { selectCalendarProviders } from '../issue/store/issue-provider.selectors';
import {
  IssueProviderCalendar,
  IssueProviderPluginType,
  isPluginIssueProvider,
} from '../issue/issue.model';
import { CalendarProviderCfg } from '../issue/providers/calendar/calendar.model';
import { CORS_SKIP_EXTRA_HEADERS, IS_WEB_BROWSER } from '../../app.constants';
import { Log } from '../../core/log';
import { getErrorTxt } from '../../util/get-error-text';
import {
  getCalendarEventIdCandidates,
  matchesAnyCalendarEventId,
} from './get-calendar-event-id-candidates';
import { getEffectiveCheckInterval } from '../issue/providers/calendar/calendar.const';
import { PluginIssueProviderRegistryService } from '../../plugins/issue-provider/plugin-issue-provider-registry.service';
import { PluginHttpService } from '../../plugins/issue-provider/plugin-http.service';
import { selectEnabledIssueProviders } from '../issue/store/issue-provider.selectors';
import { PluginSearchResult } from '../../plugins/issue-provider/plugin-issue-provider.model';
import { HiddenCalendarEventsService } from './hidden-calendar-events.service';
import { TaskArchiveService } from '../archive/task-archive.service';
import { passesCalendarEventRegexFilter } from './calendar-event-regex-filter';
import { NotIcalResponseError } from '../schedule/ical/is-likely-ical';
import { sanitizeIcalUrlForDisplay } from '../issue/mapping-helper/get-issue-provider-tooltip';
import { isCalendarProviderDisabledOnCurrentPlatform } from '../issue/providers/calendar/is-calendar-provider-disabled-on-current-platform.util';

const ONE_MONTHS = 60 * 60 * 1000 * 24 * 31;
const ONE_WEEK = 60 * 60 * 1000 * 24 * 7;
const PLUGIN_CALENDAR_POLL_INTERVAL = 5 * 60 * 1000; // 5 minutes

@Injectable({
  providedIn: 'root',
})
export class CalendarIntegrationService {
  private _http = inject(HttpClient);
  private _snackService = inject(SnackService);
  private _store = inject(Store);
  private _pluginRegistry = inject(PluginIssueProviderRegistryService);
  private _pluginHttp = inject(PluginHttpService);
  private _hiddenEventsService = inject(HiddenCalendarEventsService);
  private _taskArchiveService = inject(TaskArchiveService);
  private _refreshTrigger$ = new Subject<void>();

  /**
   * Event ids of every calendar task the user has already handled — both live tasks
   * (`selectAllCalendarTaskEventIds`) and ones moved to the archive via "Finish Day",
   * which leave the live NgRx state. The archive is re-read whenever the set of live
   * calendar-task event ids actually changes (archiving a task removes its id from that
   * set → emits here), so a completed-and-archived calendar event stays hidden from the
   * schedule instead of re-surfacing as "not yet added" the next day (#7971).
   */
  private _allLinkedCalendarEventIds$: Observable<string[]> = this._store
    .select(selectAllCalendarTaskEventIds)
    .pipe(
      // Gate the archive read on a real value change: selectAllCalendarTaskEventIds emits
      // a new array reference on every task mutation (incl. the per-second time-tracking
      // tick), and store.select only dedups by reference. Without this guard the
      // full-archive load() below would run on each of those.
      distinctUntilChanged(fastArrayCompare),
      switchMap((activeIds) =>
        from(this._taskArchiveService.load()).pipe(
          map((archive) => {
            const archivedEventIds = archive.ids
              .map((id) => archive.entities[id])
              .filter(isCalendarIssueTask)
              .map((task) => task.issueId as string);
            return [...activeIds, ...archivedEventIds];
          }),
          catchError(() => of(activeIds)),
        ),
      ),
      distinctUntilChanged(fastArrayCompare),
      // Cold field shared by the cache path and every poll/refresh cycle; share so the
      // archive is loaded once per change instead of once per subscriber.
      shareReplay({ bufferSize: 1, refCount: true }),
    );

  calendarEvents$: Observable<ScheduleCalendarMapEntry[]> = combineLatest([
    this._store
      .select(selectCalendarProviders)
      .pipe(distinctUntilChanged(fastArrayCompare)),
    // `registrationChanges$` emits when a plugin (un)registers. Plugins load
    // asynchronously after bootstrap, while the issue-provider store is hydrated
    // early — so without this trigger `getUseAgendaView` is read once (before the
    // plugin registers, returning false), the store never re-emits, and the
    // plugin's calendar events stay absent until a re-subscription forces a
    // re-projection (e.g. navigating away and back). Re-run the filter on
    // registration so agenda-view plugin events surface without navigation.
    combineLatest([
      this._store.select(selectEnabledIssueProviders),
      this._pluginRegistry.registrationChanges$,
    ]).pipe(
      map(([providers]) =>
        providers.filter(
          (p): p is IssueProviderPluginType =>
            isPluginIssueProvider(p.issueProviderKey) &&
            this._pluginRegistry.getUseAgendaView(p.issueProviderKey),
        ),
      ),
      distinctUntilChanged(fastArrayCompare),
    ),
  ]).pipe(
    switchMap(([icalProviders, pluginCalProviders]) => {
      if (!icalProviders?.length && !pluginCalProviders?.length) {
        return of([]) as Observable<ScheduleCalendarMapEntry[]>;
      }
      const minInterval = this._getCombinedRefreshInterval(
        icalProviders,
        pluginCalProviders,
      );
      return merge(
        // Emit from the raw cache immediately on subscription and provider changes.
        this._getFilteredCalProviderFromCache$(icalProviders, pluginCalProviders).pipe(
          first(),
        ),
        merge(timer(0, minInterval), this._refreshTrigger$).pipe(
          switchMap(() => this._fetchAllCombined(icalProviders, pluginCalProviders)),
        ),
      );
    }),
    shareReplay({ bufferSize: 1, refCount: true }),
  );

  triggerRefresh(): void {
    this._refreshTrigger$.next();
  }

  private _fetchAllCombined(
    icalProviders: IssueProviderCalendar[],
    pluginCalProviders: IssueProviderPluginType[],
  ): Observable<ScheduleCalendarMapEntry[]> {
    const icalFetches = icalProviders.map((calProvider) => {
      if (!calProvider.isEnabled) {
        return of({
          itemsForProvider: [] as CalendarIntegrationEvent[],
          providerId: calProvider.id,
          didError: false,
        });
      }
      return this.requestEventsForSchedule$(calProvider, true).pipe(
        first(),
        map((itemsForProvider: CalendarIntegrationEvent[]) => ({
          itemsForProvider,
          providerId: calProvider.id,
          didError: false,
        })),
        catchError(() =>
          of({
            itemsForProvider: [] as CalendarIntegrationEvent[],
            providerId: calProvider.id,
            didError: true,
          }),
        ),
      );
    });

    const pluginFetches = pluginCalProviders.map((pluginProvider) =>
      from(this._fetchPluginCalendarEvents(pluginProvider)).pipe(
        map((itemsForProvider) => ({
          itemsForProvider,
          providerId: pluginProvider.id,
          didError: false,
        })),
        catchError((err) => {
          Log.warn('Failed to fetch plugin calendar events', err);
          return of({
            itemsForProvider: [] as CalendarIntegrationEvent[],
            providerId: pluginProvider.id,
            didError: true,
          });
        }),
      ),
    );

    const allFetches = [...icalFetches, ...pluginFetches];
    if (!allFetches.length) {
      return of([]);
    }

    return forkJoin(allFetches).pipe(
      switchMap((resultForProviders) => {
        const cachedByProviderId = this._groupCachedEventsByProvider(
          this._getCalProviderFromCache(),
        );

        // Build unfiltered entries first so the cache preserves raw fetch data.
        // Regex filters are intentionally excluded here — they are view-level concerns
        // and must not permanently remove events that could reappear if the filter changes.
        const unfilteredEntries: ScheduleCalendarMapEntry[] = resultForProviders.map(
          ({ itemsForProvider, providerId, didError }) => ({
            items: didError
              ? (cachedByProviderId.get(providerId) ?? [])
              : (itemsForProvider as ScheduleFromCalendarEvent[]),
          }),
        );
        saveToRealLs(LS.CAL_EVENTS_CACHE, unfilteredEntries);

        return this._getViewFilteredCalendarEntries$(
          unfilteredEntries,
          icalProviders,
          pluginCalProviders,
        );
      }),
    );
  }

  private _getFilteredCalProviderFromCache$(
    icalProviders: IssueProviderCalendar[],
    pluginCalProviders: IssueProviderPluginType[],
  ): Observable<ScheduleCalendarMapEntry[]> {
    return defer(() =>
      this._getViewFilteredCalendarEntries$(
        this._getCalProviderFromCache(),
        icalProviders,
        pluginCalProviders,
      ),
    );
  }

  private _getViewFilteredCalendarEntries$(
    entries: ScheduleCalendarMapEntry[],
    icalProviders: IssueProviderCalendar[],
    pluginCalProviders: IssueProviderPluginType[],
  ): Observable<ScheduleCalendarMapEntry[]> {
    const icalProviderMap = new Map(icalProviders.map((p) => [p.id, p]));
    const activeProviderIds = new Set([
      ...icalProviders.map((p) => p.id),
      ...pluginCalProviders.map((p) => p.id),
    ]);

    return combineLatest([
      this._allLinkedCalendarEventIds$,
      this.skippedEventIds$.pipe(distinctUntilChanged(fastArrayCompare)),
      this._hiddenEventsService.hiddenEventIds$.pipe(
        distinctUntilChanged(fastArrayCompare),
      ),
    ]).pipe(
      map(([allCalendarTaskEventIds, skippedEventIds, hiddenEventIds]) =>
        this._filterCalendarEntriesForView(
          entries,
          activeProviderIds,
          icalProviderMap,
          allCalendarTaskEventIds,
          skippedEventIds,
          hiddenEventIds,
        ),
      ),
    );
  }

  private _filterCalendarEntriesForView(
    entries: ScheduleCalendarMapEntry[],
    activeProviderIds: Set<string>,
    icalProviderMap: Map<string, IssueProviderCalendar>,
    allCalendarTaskEventIds: string[],
    skippedEventIds: string[],
    hiddenEventIds: string[],
  ): ScheduleCalendarMapEntry[] {
    return entries.map((entry) => ({
      ...entry,
      items: entry.items.filter((calEv) => {
        if (!activeProviderIds.has(calEv.calProviderId)) {
          return false;
        }
        const cfg = icalProviderMap.get(calEv.calProviderId);
        return (
          passesCalendarEventRegexFilter(
            calEv,
            cfg?.filterIncludeRegex,
            cfg?.filterExcludeRegex,
          ) &&
          !matchesAnyCalendarEventId(calEv, allCalendarTaskEventIds) &&
          !matchesAnyCalendarEventId(calEv, skippedEventIds) &&
          !matchesAnyCalendarEventId(calEv, hiddenEventIds)
        );
      }),
    }));
  }

  private async _fetchPluginCalendarEvents(
    pluginProvider: IssueProviderPluginType,
  ): Promise<CalendarIntegrationEvent[]> {
    const provider = this._pluginRegistry.getProvider(pluginProvider.issueProviderKey);
    if (!provider?.definition.getNewIssuesForBacklog) {
      return [];
    }

    const http = this._pluginHttp.createHttpHelper(
      () => Promise.resolve(provider.definition.getHeaders(pluginProvider.pluginConfig)),
      { allowPrivateNetwork: provider.allowPrivateNetwork },
    );
    const results: PluginSearchResult[] =
      await provider.definition.getNewIssuesForBacklog(pluginProvider.pluginConfig, http);

    return results
      .filter((r) => r.start != null)
      .map((r) => ({
        id: r.id,
        calProviderId: pluginProvider.id,
        title: r.title,
        description: r.description,
        start: r.start!,
        duration: r.duration ?? 0,
        isAllDay: r.isAllDay,
        issueProviderKey: pluginProvider.issueProviderKey,
        dueWithTime: r.dueWithTime,
      }));
  }

  private _getCombinedRefreshInterval(
    icalProviders: IssueProviderCalendar[],
    pluginCalProviders: IssueProviderPluginType[],
  ): number {
    const intervals: number[] = [];
    const enabledIcal = icalProviders.filter((p) => p.isEnabled && p.icalUrl);
    if (enabledIcal.length) {
      intervals.push(...enabledIcal.map((p) => getEffectiveCheckInterval(p)));
    }
    if (pluginCalProviders.length) {
      intervals.push(
        ...pluginCalProviders.map((p) => {
          const reg = this._pluginRegistry.getProvider(p.issueProviderKey);
          return reg?.pollIntervalMs ?? PLUGIN_CALENDAR_POLL_INTERVAL;
        }),
      );
    }
    return intervals.length ? Math.min(...intervals) : 2 * 60 * 60 * 1000;
  }

  public readonly skippedEventIds$ = new BehaviorSubject<string[]>([]);

  constructor() {
    if (localStorage.getItem(LS.CALENDER_EVENTS_LAST_SKIP_DAY) === getDbDateStr()) {
      try {
        const skippedEvIds = JSON.parse(
          localStorage.getItem(LS.CALENDER_EVENTS_SKIPPED_TODAY) as string,
        );
        this.skippedEventIds$.next(skippedEvIds || []);
      } catch (e) {
        Log.warn('Failed to parse skipped calendar event IDs from localStorage', e);
      }
    }
  }

  testConnection(cfg: CalendarProviderCfg): Promise<boolean> {
    //  simple http get request
    return this._http
      .get(cfg.icalUrl, {
        responseType: 'text',
        headers: {
          ...CORS_SKIP_EXTRA_HEADERS,
        },
      })
      .pipe(
        map((v) => !!v),
        catchError((err) => {
          Log.err(err);
          return of(false);
        }),
      )
      .toPromise()
      .then((result) => result ?? false);
  }

  skipCalendarEvent(calEv: CalendarIntegrationEvent): void {
    if (!calEv) {
      return;
    }

    const idsToAdd = getCalendarEventIdCandidates(calEv).filter(
      (id): id is string => typeof id === 'string' && id.length > 0,
    );
    if (!idsToAdd.length) {
      return;
    }

    const current = this.skippedEventIds$.getValue();
    const updated = [...current, ...idsToAdd.filter((id) => !current.includes(id))];
    this.skippedEventIds$.next(updated);
    localStorage.setItem(LS.CALENDER_EVENTS_SKIPPED_TODAY, JSON.stringify(updated));
    localStorage.setItem(LS.CALENDER_EVENTS_LAST_SKIP_DAY, getDbDateStr());
  }

  requestEvents$(
    calProvider: IssueProviderCalendar,
    start = getStartOfDayTimestamp(),
    end = getEndOfDayTimestamp(),
    isForwardError = false,
  ): Observable<CalendarIntegrationEvent[]> {
    // Allow calendars to be disabled where the app uses browser/WebView requests
    // that often fail due to remote calendar CORS or redirect behavior.
    if (isCalendarProviderDisabledOnCurrentPlatform(calProvider, IS_WEB_BROWSER)) {
      return of([]);
    }
    return this._http
      .get(calProvider.icalUrl, {
        responseType: 'text',
        headers: {
          ...CORS_SKIP_EXTRA_HEADERS,
        },
      })
      .pipe(
        switchMap((icalStrData) =>
          getRelevantEventsForCalendarIntegrationFromIcal(
            icalStrData,
            calProvider.id,
            start,
            end,
            calProvider.icalUrl,
          ),
        ),
        map((events) =>
          events.map((ev) => ({
            ...ev,
            isReferenceCalendar: !!calProvider.isReferenceCalendar,
            color: calProvider.color,
          })),
        ),
        catchError((err) => {
          // iCal feed URLs frequently embed a secret token (Google/Outlook
          // private feeds). HttpErrorResponse puts the full URL in `.url` and
          // `.message`, and the log history is exportable — so log only the
          // sanitized host plus the error name/status, never the raw error.
          Log.err('CAL_PROVIDER_REQUEST_ERROR', {
            icalHost: sanitizeIcalUrlForDisplay(calProvider.icalUrl),
            name: (err as Error)?.name,
            status: (err as { status?: number })?.status,
          });
          if (err instanceof NotIcalResponseError) {
            this._snackService.open({
              type: 'ERROR',
              msg: T.F.CALENDARS.S.CAL_PROVIDER_NOT_ICAL,
            });
          } else {
            // Replace the raw iCal URL (which may embed a secret token) with
            // the sanitized host so the user-visible snackbar can't leak it
            // via screenshot/screenshare.
            const rawErrTxt = getErrorTxt(err);
            const errTxt = calProvider.icalUrl
              ? rawErrTxt
                  .split(calProvider.icalUrl)
                  .join(sanitizeIcalUrlForDisplay(calProvider.icalUrl))
              : rawErrTxt;
            this._snackService.open({
              type: 'ERROR',
              msg: T.F.CALENDARS.S.CAL_PROVIDER_ERROR,
              translateParams: {
                errTxt,
              },
            });
          }
          if (isForwardError) {
            throw err;
          }
          return of([]);
        }),
      );
  }

  requestEventsForSchedule$(
    calProvider: IssueProviderCalendar,
    isForwardError = false,
  ): Observable<CalendarIntegrationEvent[]> {
    return this.requestEvents$(
      calProvider,
      Date.now() - ONE_WEEK,
      Date.now() + ONE_MONTHS,
      isForwardError,
    );
  }

  private _getCalProviderFromCache(): ScheduleCalendarMapEntry[] {
    const now = Date.now();
    const cached = loadFromRealLs(LS.CAL_EVENTS_CACHE);

    // Validate that cached data is an array
    if (!Array.isArray(cached)) {
      return [];
    }

    return (
      cached
        // filter out cached entries older than one week
        .map((provider) => ({
          ...provider,
          items: provider.items
            .filter((item) => item.start + item.duration >= now - ONE_WEEK)
            // Backfill issueProviderKey for events cached before it became required
            .map((item) =>
              item.issueProviderKey ? item : { ...item, issueProviderKey: 'ICAL' },
            ),
        }))
    );
  }

  private _groupCachedEventsByProvider(
    cachedEntries: ScheduleCalendarMapEntry[],
  ): Map<string, ScheduleFromCalendarEvent[]> {
    // Pre-group cached entries for quick lookups per provider when we need fallback data.
    const mapByProvider = new Map<string, ScheduleFromCalendarEvent[]>();

    cachedEntries.forEach((entry) => {
      entry.items.forEach((item) => {
        const existing = mapByProvider.get(item.calProviderId);
        if (existing) {
          existing.push(item);
        } else {
          mapByProvider.set(item.calProviderId, [item]);
        }
      });
    });

    return mapByProvider;
  }
}
