/*
 * Copyright (C) 2022 StApps
 * This program is free software: you can redistribute it and/or modify it
 * under the terms of the GNU General Public License as published by the Free
 * Software Foundation, version 3.
 *
 * This program is distributed in the hope that it will be useful, but WITHOUT
 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
 * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
 * more details.
 *
 * You should have received a copy of the GNU General Public License along with
 * this program. If not, see <https://www.gnu.org/licenses/>.
 */
import {
  SCDateSeries,
  SCISO8601Date,
  SCISO8601Duration,
  SCThingTranslator,
  SCThingWithCategories,
  SCUuid,
} from '@openstapps/core';
import moment, {unitOfTime} from 'moment';
import {minBy, mapValues} from '@openstapps/collection-utils';

export interface ICalEvent {
  name?: string;
  uuid: SCUuid;
  categories?: string[];
  description?: string;
  cancelled?: boolean;
  recurrenceId?: SCISO8601Date;
  geo?: string;
  /**
   * The sequence index if the series had to be split into multiple rrules
   */
  recurrenceSequence?: number;
  recurrenceSequenceAmount?: number;
  rrule?: RRule;
  dates?: SCISO8601Date[];
  exceptionDates?: SCISO8601Date[];
  start: SCISO8601Date;
  sequence?: number;
  duration?: SCISO8601Duration;
  url?: string;
}

export type ICalKeyValuePair = `${Uppercase<string>}${':' | '='}${string}`;

export type ICalLike = ICalKeyValuePair[];

/**
 *
 */
function timeDistance(
  current: SCISO8601Date,
  next: SCISO8601Date | undefined,
  recurrence: unitOfTime.Diff,
): number | undefined {
  if (!next) {
    return undefined;
  }

  const diff = moment(next).diff(moment(current), recurrence, true);

  return Math.floor(diff) === diff ? diff : undefined;
}

export interface RRule {
  freq: unitOfTime.Diff; // 'SECONDLY' | 'HOURLY' | 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'YEARLY';
  interval: number;
  from: SCISO8601Date;
  until: SCISO8601Date;
}

type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>;

export interface MergedRRule {
  rrule?: RRule;
  exceptions?: SCISO8601Date[];
  date?: SCISO8601Date;
}

/**
 * Merge compatible RRules to a single RRule with exceptions
 */
export function mergeRRules(rules: Array<RRule | SCISO8601Date>, allowExceptions = true): MergedRRule[] {
  if (!allowExceptions) return rules.map(it => (typeof it === 'string' ? {date: it} : {rrule: it}));
  /*map(groupBy(rules, it => `${it.freq}@${it.interval}`), it => {

  });*/

  return rules.map(it => (typeof it === 'string' ? {date: it} : {rrule: it})) /* TODO */;
}

/**
 * Find RRules in a list of dates
 */
export function findRRules(dates: SCISO8601Date[]): Array<RRule | SCISO8601Date> {
  const sorted = dates.sort((a, b) => moment(a).unix() - moment(b).unix());

  const output: Optional<RRule, 'freq'>[] = [
    {
      from: sorted[0],
      until: sorted[0],
      interval: -1,
    },
  ];

  for (let i = 0; i < sorted.length; i++) {
    const current = sorted[i];
    const next = sorted[i + 1] as SCISO8601Date | undefined;
    const element = output.at(-1);

    const units: unitOfTime.Diff[] = element?.freq ? [element.freq] : ['day', 'week', 'month', 'year'];
    const freq = minBy(
      units.map(recurrence => ({
        recurrence: recurrence,
        dist: timeDistance(current, next, recurrence),
      })),
      it => it.dist,
    )?.recurrence;
    const interval = freq ? timeDistance(current, next, freq) : undefined;

    if (element?.interval === -1) {
      element.freq = freq;
      element.interval = interval ?? -1;
    }

    if (!freq || element?.freq !== freq || element.interval !== interval) {
      if (element) {
        element.until = current;
      }

      if (next) {
        output.push({
          from: next,
          until: next,
          interval: -1,
        });
      }
    } else {
      element.until = current;
    }
  }

  return output.map(it => (it.freq ? (it as RRule) : it.from));
}

/**
 *
 */
export function strikethrough(text: string): string {
  return `\u274C ${[...text].join('\u0336')}\u0336`;
}

/**
 *
 */
function getICalData(
  dateSeries: SCDateSeries,
  translator: SCThingTranslator,
): Pick<ICalEvent, 'name' | 'uuid' | 'categories' | 'description' | 'geo'> {
  const translated = translator.translatedAccess(dateSeries);

  return {
    name: translated.event?.name,
    uuid: dateSeries.uid,
    categories: ['stapps', ...((translated.event as SCThingWithCategories<string, never>)?.categories ?? [])],
    description: translated.event?.description ?? translated.description,
    geo: translated.inPlace?.name,
  };
}

export interface ToICalOptions {
  allowRRuleExceptions?: boolean;
  excludeCancelledEvents?: boolean;
}

/**
 *
 */
export function toICal(
  dateSeries: SCDateSeries,
  translator: SCThingTranslator,
  options: ToICalOptions = {},
): ICalEvent[] {
  const rrules = findRRules(
    options.excludeCancelledEvents && dateSeries.exceptions
      ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
        dateSeries.dates.filter(it => !dateSeries.exceptions!.includes(it))
      : dateSeries.dates,
  );

  return mergeRRules(rrules, options.allowRRuleExceptions).map((it, i, array) => ({
    ...getICalData(dateSeries, translator),
    dates: dateSeries.dates,
    rrule: it.rrule,
    recurrenceSequence: array.length > 1 ? i + 1 : undefined,
    recurrenceSequenceAmount: array.length > 1 ? array.length : undefined,
    exceptionDates: it.exceptions,
    start: it.rrule?.from ?? it.date ?? dateSeries.dates[0],
    sequence: 0,
    duration: dateSeries.duration,
  }));
}

/**
 *
 */
export function toICalUpdates(dateSeries: SCDateSeries, translator: SCThingTranslator): ICalEvent[] {
  return (
    dateSeries.exceptions?.map(exception => ({
      ...getICalData(dateSeries, translator),
      sequence: 1,
      recurrenceId: exception,
      cancelled: true,
      start: exception,
    })) ?? []
  );
}

/**
 * Convert an ISO8601 date to a string in the format YYYYMMDDTHHMMSSZ
 */
export function iso8601ToICalDateTime<T extends SCISO8601Date | undefined>(
  date: T,
): T extends SCISO8601Date ? string : undefined {
  return (date ? `${moment(date).utc().format('YYYYMMDDTHHmmss')}Z` : undefined) as never;
}

/**
 * Convert an ISO8601 date to a string in the format YYYYMMDD
 */
export function iso8601ToICalDate(date: SCISO8601Date): string {
  return `${moment(date).utc().format('YYYYMMDD')}`;
}

/**
 * Recursively stringify all linebreaks to \n strings
 */
function stringifyLinebreaks<T extends string | unknown[] | unknown>(value: T): T {
  if (typeof value === 'string') {
    return value.replaceAll(/\r?\n|\r/g, '\\n') as T;
  }
  if (Array.isArray(value)) {
    return value.map(stringifyLinebreaks) as T;
  }
  // noinspection SuspiciousTypeOfGuard
  if (value instanceof Object) {
    return mapValues<object, unknown>(value, stringifyLinebreaks) as T;
  }
  return value;
}

/**
 * Sanitize an ICal object to not contain line breaks and convert dates to iCal format
 */
export function normalizeICalDates(iCal: ICalEvent): ICalEvent {
  return {
    ...iCal,
    dates: iCal.dates?.filter(it => it !== iCal.start).map(iso8601ToICalDate),
    exceptionDates: iCal.exceptionDates?.map(iso8601ToICalDate),
    start: iso8601ToICalDateTime(iCal.start),
    recurrenceId: iso8601ToICalDateTime(iCal.recurrenceId),
  };
}

const REPEAT_FREQUENCIES: Partial<Record<unitOfTime.Diff, string>> = {
  day: 'DAILY',
  week: 'WEEKLY',
  month: 'MONTHLY',
  year: 'YEARLY',
};

/**
 *
 */
export function serializeICalLike(iCal: ICalLike): string {
  return iCal.map(stringifyLinebreaks).join('\r\n');
}

/**
 * Removes all strings that are either undefined or end with 'undefined'
 */
function withoutNullishStrings<T extends string>(array: Array<T | `${string}${undefined}` | undefined>): T[] {
  return array.filter(it => it && !it.endsWith('undefined')) as T[];
}

/**
 *
 */
export function serializeRRule(rrule?: RRule): string | undefined {
  return rrule
    ? `FREQ=${REPEAT_FREQUENCIES[rrule.freq ?? 's']};UNTIL=${iso8601ToICalDateTime(rrule.until)};INTERVAL=${
        rrule.interval
      }`
    : undefined;
}

/**
 * Convert an iCal event to a string
 */
export function serializeICalEvent(iCal: ICalEvent): ICalLike {
  const normalized = normalizeICalDates(iCal);

  return withoutNullishStrings<ICalKeyValuePair>([
    'BEGIN:VEVENT',
    `DTSTART:${normalized.start}`,
    `DURATION:${normalized.duration}`,
    `DTSTAMP:${moment().utc().format('YYYYMMDDTHHmmss')}Z`,
    `UID:${normalized.uuid}`,
    `RECURRENCE-ID:${normalized.recurrenceId}`,
    `CATEGORIES:${normalized.categories?.join(',')}`,
    `SUMMARY:${normalized.name}`,
    `DESCRIPTION:${normalized.description}`,
    `STATUS:${normalized.cancelled === true ? 'CANCELLED' : 'CONFIRMED'}`,
    `URL:${normalized.url}`,
    // `RDATE;VALUE=DATE:${normalized.dates.join(',')}`,
    (normalized.exceptionDates?.length ?? 0) > 0
      ? `EXDATE;VALUE=DATE:${normalized.exceptionDates?.join(',')}`
      : undefined,
    `RRULE:${serializeRRule(normalized.rrule)}`,
    'END:VEVENT',
  ]);
}

/**
 * Convert an iCal object to a string
 */
export function serializeICal(iCal: ICalEvent[]): string {
  return serializeICalLike([
    'BEGIN:VCALENDAR',
    'VERSION:2.0',
    'PRODID:-//StApps//NONSGML StApps Calendar//EN',
    'NAME:StApps',
    'X-WR-CALNAME:StApps',
    'X-WR-CALDESC:StApps Calendar',
    'X-WR-TIMEZONE:Europe/Berlin',
    'LOCATION;LANGUAGE=en:Germany',
    'CALSCALE:GREGORIAN',
    'COLOR:#FF0000',
    'METHOD:PUBLISH',
    ...iCal.flatMap(serializeICalEvent),
    'END:VCALENDAR',
  ]);
}

/**
 * Get transform date series for purpose of native calendar export
 */
export function getNativeCalendarExport(
  dateSeries: SCDateSeries[],
  translator: SCThingTranslator,
): ICalEvent[] {
  return dateSeries.flatMap(event =>
    toICal(event, translator, {
      allowRRuleExceptions: false,
      excludeCancelledEvents: true,
    }),
  );
}

/**
 * Get transform date series for purpose of iCal file export
 */
export function getICalExport(
  dateSeries: SCDateSeries[],
  translator: SCThingTranslator,
  includeCancelled: boolean,
): ICalEvent[] {
  return [
    ...dateSeries.flatMap(event =>
      toICal(event, translator, {
        allowRRuleExceptions: false,
        excludeCancelledEvents: !includeCancelled,
      }),
    ),
    ...(includeCancelled ? dateSeries.flatMap(event => toICalUpdates(event, translator)) : []),
  ];
}
