'use strict';

import ICalEvent, {
    type ICalEventData,
    type ICalEventJSONData,
} from './event.ts';
import {
    addOrGetCustomAttributes,
    checkEnum,
    foldLines,
    generateCustomAttributes,
    isMomentDuration,
    toDurationString,
} from './tools.ts';
import { type ICalMomentDurationStub, type ICalTimezone } from './types.ts';

export enum ICalCalendarMethod {
    ADD = 'ADD',
    CANCEL = 'CANCEL',
    COUNTER = 'COUNTER',
    DECLINECOUNTER = 'DECLINECOUNTER',
    PUBLISH = 'PUBLISH',
    REFRESH = 'REFRESH',
    REPLY = 'REPLY',
    REQUEST = 'REQUEST',
}

export interface ICalCalendarData {
    color?: null | string;
    description?: null | string;
    events?: (ICalEvent | ICalEventData)[];
    method?: ICalCalendarMethod | null;
    name?: null | string;
    prodId?: ICalCalendarProdIdData | string;
    scale?: null | string;
    source?: null | string;
    timezone?: ICalTimezone | null | string;
    ttl?: ICalMomentDurationStub | null | number;
    url?: null | string;
    x?:
        | [string, string][]
        | Record<string, string>
        | { key: string; value: string }[];
}

export interface ICalCalendarJSONData {
    color: null | string;
    description: null | string;
    events: ICalEventJSONData[];
    method: ICalCalendarMethod | null;
    name: null | string;
    prodId: string;
    scale: null | string;
    source: null | string;
    timezone: null | string;
    ttl: null | number;
    url: null | string;
    x: { key: string; value: string }[];
}

export interface ICalCalendarProdIdData {
    company: string;
    language?: string;
    product: string;
}

interface ICalCalendarInternalData {
    color: null | string;
    description: null | string;
    events: ICalEvent[];
    method: ICalCalendarMethod | null;
    name: null | string;
    prodId: string;
    scale: null | string;
    source: null | string;
    timezone: ICalTimezone | null;
    ttl: null | number;
    url: null | string;
    x: [string, string][];
}

/**
 * Usually you get an {@link ICalCalendar} object like this:
 * ```javascript
 * import ical from 'ical-generator';
 * const calendar = ical();
 * ```
 *
 * But you can also use the constructor directly like this:
 * ```javascript
 * import {ICalCalendar} from 'ical-generator';
 * const calendar = new ICalCalendar();
 * ```
 */
export default class ICalCalendar {
    private readonly data: ICalCalendarInternalData;

    /**
     * You can pass options to set up your calendar or use setters to do this.
     *
     * ```javascript
     *  * import ical from 'ical-generator';
     *
     * // or use require:
     * // const { default: ical } = require('ical-generator');
     *
     *
     * const cal = ical({name: 'my first iCal'});
     *
     * // is the same as
     *
     * const cal = ical().name('my first iCal');
     *
     * // is the same as
     *
     * const cal = ical();
     * cal.name('sebbo.net');
     * ```
     *
     * `cal.toString()` would then produce the following string:
     * ```text
     * BEGIN:VCALENDAR
     * VERSION:2.0
     * PRODID:-//sebbo.net//ical-generator//EN
     * NAME:sebbo.net
     * X-WR-CALNAME:sebbo.net
     * END:VCALENDAR
     * ```
     *
     * @param data Calendar data
     */
    constructor(data: ICalCalendarData = {}) {
        this.data = {
            color: null,
            description: null,
            events: [],
            method: null,
            name: null,
            prodId: '//sebbo.net//ical-generator//EN',
            scale: null,
            source: null,
            timezone: null,
            ttl: null,
            url: null,
            x: [],
        };

        if (data.prodId !== undefined) this.prodId(data.prodId);
        if (data.method !== undefined) this.method(data.method);
        if (data.name !== undefined) this.name(data.name);
        if (data.description !== undefined) this.description(data.description);
        if (data.timezone !== undefined) this.timezone(data.timezone);
        if (data.source !== undefined) this.source(data.source);
        if (data.url !== undefined) this.url(data.url);
        if (data.scale !== undefined) this.scale(data.scale);
        if (data.ttl !== undefined) this.ttl(data.ttl);
        if (data.color !== undefined) this.color(data.color);
        if (data.events !== undefined) this.events(data.events);
        if (data.x !== undefined) this.x(data.x);
    }

    /**
     * Remove all events from the calendar without
     * touching any other data like name or prodId.
     *
     * @since 2.0.0-develop.1
     */
    clear(): this {
        this.data.events = [];
        return this;
    }

    /**
     * Get your feed's color
     * @returns {null|string}
     */
    color(): null | string;
    /**
     * Set your feed's color
     * (Only supported on Apple calendar clients)
     * @param {null|string} color \# followed by six character hex color code
     */
    color(color: null | string): this;
    color(color?: null | string): null | string | this {
        if (color === undefined) {
            return this.data.color;
        }

        // Only six character hex color codes seem to be supported
        if (typeof color === 'string' && !/^#[A-F0-9]{6}$/gi.test(color)) {
            throw new Error('`color` is malformed!');
        }

        this.data.color = color ? String(color).toUpperCase() : null;
        return this;
    }
    /**
     * Creates a new {@link ICalEvent} and returns it. Use options to prefill the event's attributes.
     * Calling this method without options will create an empty event.
     *
     * ```javascript
     * import ical from 'ical-generator';
     *
     * // or use require:
     * // const { default: ical } = require('ical-generator');
     *
     * const cal = ical();
     * const event = cal.createEvent({summary: 'My Event'});
     *
     * // overwrite event summary
     * event.summary('Your Event');
     * ```
     *
     * @since 0.2.0
     */
    createEvent(data: ICalEvent | ICalEventData): ICalEvent {
        const event =
            data instanceof ICalEvent ? data : new ICalEvent(data, this);
        this.data.events.push(event);
        return event;
    }
    /**
     * Get your feed's description
     * @since 0.2.7
     */
    description(): null | string;
    /**
     * Set your feed's description
     * @since 0.2.7
     */
    description(description: null | string): this;
    description(description?: null | string): null | string | this {
        if (description === undefined) {
            return this.data.description;
        }

        this.data.description = description ? String(description) : null;
        return this;
    }
    /**
     * Returns all events of this calendar.
     *
     * ```javascript
     * const cal = ical();
     *
     * cal.events([
     *     {
     *        start: new Date(),
     *        end: new Date(new Date().getTime() + 3600000),
     *        summary: 'Example Event',
     *        description: 'It works ;)',
     *        url: 'http://sebbo.net/'
     *     }
     * ]);
     *
     * cal.events(); // --> [ICalEvent]
     * ```
     *
     * @since 0.2.0
     */
    events(): ICalEvent[];
    /**
     * Add multiple events to your calendar.
     *
     * ```javascript
     * const cal = ical();
     *
     * cal.events([
     *     {
     *        start: new Date(),
     *        end: new Date(new Date().getTime() + 3600000),
     *        summary: 'Example Event',
     *        description: 'It works ;)',
     *        url: 'http://sebbo.net/'
     *     }
     * ]);
     *
     * cal.events(); // --> [ICalEvent]
     * ```
     *
     * @since 0.2.0
     */
    events(events: (ICalEvent | ICalEventData)[]): this;
    events(events?: (ICalEvent | ICalEventData)[]): ICalEvent[] | this {
        if (!events) {
            return this.data.events;
        }

        events.forEach((e: ICalEvent | ICalEventData) => this.createEvent(e));
        return this;
    }
    /**
     * Get the number of events added to your calendar
     */
    length(): number {
        return this.data.events.length;
    }

    /**
     * Get the feed method attribute.
     * See {@link ICalCalendarMethod} for possible results.
     *
     * @since 0.2.8
     */
    method(): ICalCalendarMethod | null;
    /**
     * Set the feed method attribute.
     * See {@link ICalCalendarMethod} for available options.
     *
     * #### Typescript Example
     * ```typescript
     * import {ICalCalendarMethod} from 'ical-generator';
     *
     * // METHOD:PUBLISH
     * calendar.method(ICalCalendarMethod.PUBLISH);
     * ```
     *
     * @since 0.2.8
     */
    method(method: ICalCalendarMethod | null): this;
    method(
        method?: ICalCalendarMethod | null,
    ): ICalCalendarMethod | null | this {
        if (method === undefined) {
            return this.data.method;
        }
        if (!method) {
            this.data.method = null;
            return this;
        }

        this.data.method = checkEnum(
            ICalCalendarMethod,
            method,
        ) as ICalCalendarMethod;
        return this;
    }

    /**
     * Get your feed's name
     * @since 0.2.0
     */
    name(): null | string;
    /**
     * Set your feed's name. Is used to fill `NAME`
     * and `X-WR-CALNAME` in your iCal file.
     *
     * ```typescript
     * import ical from 'ical-generator';
     *
     * const cal = ical();
     * cal.name('Next Arrivals');
     *
     * cal.toString();
     * ```
     *
     * ```text
     * BEGIN:VCALENDAR
     * VERSION:2.0
     * PRODID:-//sebbo.net//ical-generator//EN
     * NAME:Next Arrivals
     * X-WR-CALNAME:Next Arrivals
     * END:VCALENDAR
     * ```
     *
     * @since 0.2.0
     */
    name(name: null | string): this;
    name(name?: null | string): null | string | this {
        if (name === undefined) {
            return this.data.name;
        }

        this.data.name = name ? String(name) : null;
        return this;
    }
    /**
     * Get your feed's prodid. Will always return a string.
     * @since 0.2.0
     */
    prodId(): string;
    /**
     * Set your feed's prodid. `prodid` can be either a
     * string like `//sebbo.net//ical-generator//EN` or a
     * valid {@link ICalCalendarProdIdData} object. `language`
     * is optional and defaults to `EN`.
     *
     * ```javascript
     * cal.prodId({
     *     company: 'My Company',
     *     product: 'My Product',
     *     language: 'EN' // optional, defaults to EN
     * });
     * ```
     *
     * `cal.toString()` would then produce the following string:
     * ```text
     * PRODID:-//My Company//My Product//EN
     * ```
     *
     * @since 0.2.0
     */
    prodId(prodId: ICalCalendarProdIdData | string): this;
    prodId(prodId?: ICalCalendarProdIdData | string): string | this {
        if (!prodId) {
            return this.data.prodId;
        }

        if (typeof prodId === 'string') {
            this.data.prodId = prodId;
            return this;
        }

        if (typeof prodId !== 'object') {
            throw new Error('`prodid` needs to be a string or an object!');
        }

        if (!prodId.company) {
            throw new Error('`prodid.company` is a mandatory item!');
        }
        if (!prodId.product) {
            throw new Error('`prodid.product` is a mandatory item!');
        }

        const language = (prodId.language || 'EN').toUpperCase();
        this.data.prodId =
            '//' + prodId.company + '//' + prodId.product + '//' + language;
        return this;
    }
    /**
     * Get current value of the `CALSCALE` attribute. It will
     * return `null` if no value was set. The iCal standard
     * specifies this as `GREGORIAN` if no value is present.
     *
     * @since 1.8.0
     */
    scale(): null | string;
    /**
     * Use this method to set your feed's `CALSCALE` attribute. There is no
     * default value for this property and it will not appear in your iCal
     * file unless set. The iCal standard specifies this as `GREGORIAN` if
     * no value is present.
     *
     * ```javascript
     * cal.scale('gregorian');
     * ```
     *
     * @since 1.8.0
     */
    scale(scale: null | string): this;
    scale(scale?: null | string): null | string | this {
        if (scale === undefined) {
            return this.data.scale;
        }

        if (scale === null) {
            this.data.scale = null;
        } else {
            this.data.scale = scale.toUpperCase();
        }

        return this;
    }
    /**
     * Get current value of the `SOURCE` attribute.
     * @since 2.2.0-develop.1
     */
    source(): null | string;
    /**
     * Use this method to set your feed's `SOURCE` attribute.
     * This tells the client where to refresh your feed.
     *
     * ```javascript
     * cal.source('http://example.com/my/original_source.ical');
     * ```
     *
     * ```text
     * SOURCE;VALUE=URI:http://example.com/my/original_source.ical
     * ```
     *
     * @since 2.2.0-develop.1
     */
    source(source: null | string): this;
    source(source?: null | string): null | string | this {
        if (source === undefined) {
            return this.data.source;
        }

        this.data.source = source || null;
        return this;
    }
    /**
     * Get the current calendar timezone
     * @since 0.2.0
     */
    timezone(): null | string;
    /**
     * Use this method to set your feed's timezone. Is used
     * to fill `TIMEZONE-ID` and `X-WR-TIMEZONE` in your iCal export.
     * Please not that all date values are treaded differently, if
     * a timezone was set. See {@link formatDate} for details. If no
     * time zone is specified, all information is output as UTC.
     *
     * ```javascript
     * cal.timezone('America/New_York');
     * ```
     *
     * @see https://github.com/sebbo2002/ical-generator#-date-time--timezones
     * @since 0.2.0
     */
    timezone(timezone: null | string): this;
    /**
     * Sets the time zone to be used in this calendar file for all times of all
     * events. Please note that if the time zone is set, ical-generator assumes
     * that all times are already in the correct time zone. Alternatively, a
     * `moment-timezone` or a Luxon object can be passed with `setZone`,
     * ical-generator will then set the time zone itself.
     *
     * For the best support of time zones, a VTimezone entry in the calendar is
     * recommended, which informs the client about the corresponding time zones
     * (daylight saving time, deviation from UTC, etc.). `ical-generator` itself
     * does not have a time zone database, so an external generator is needed here.
     *
     * A VTimezone generator is a function that takes a time zone as a string and
     * returns a VTimezone component according to the ical standard. For example,
     * ical-timezones can be used for this:
     *
     * ```typescript
     * import ical from 'ical-generator';
     * import {getVtimezoneComponent} from '@touch4it/ical-timezones';
     *
     * const cal = ical();
     * cal.timezone({
     *     name: 'FOO',
     *     generator: getVtimezoneComponent
     * });
     * cal.createEvent({
     *     start: new Date(),
     *     timezone: 'Europe/London'
     * });
     * ```
     *
     * @see https://github.com/sebbo2002/ical-generator#-date-time--timezones
     * @since 2.0.0
     */
    timezone(timezone: ICalTimezone | null | string): this;
    timezone(timezone?: ICalTimezone | null | string): null | string | this {
        if (timezone === undefined) {
            return this.data.timezone?.name || null;
        }

        if (timezone === 'UTC') {
            this.data.timezone = null;
        } else if (typeof timezone === 'string') {
            this.data.timezone = { name: timezone };
        } else if (timezone === null) {
            this.data.timezone = null;
        } else {
            this.data.timezone = timezone;
        }

        return this;
    }

    /**
     * Return a shallow copy of the calendar's options for JSON stringification.
     * Third party objects like moment.js values or RRule objects are stringified
     * as well. Can be used for persistence.
     *
     * ```javascript
     * const cal = ical();
     * const json = JSON.stringify(cal);
     *
     * // later: restore calendar data
     * cal = ical(JSON.parse(json));
     * ```
     *
     * @since 0.2.4
     */
    toJSON(): ICalCalendarJSONData {
        return Object.assign({}, this.data, {
            events: this.data.events.map((event) => event.toJSON()),
            timezone: this.timezone(),
            x: this.x(),
        });
    }

    /**
     * Return generated calendar as a string.
     *
     * ```javascript
     * const cal = ical();
     * console.log(cal.toString()); // → BEGIN:VCALENDAR…
     * ```
     */
    toString(): string {
        let g = '';

        // VCALENDAR and VERSION
        g = 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\n';

        // PRODID
        g += 'PRODID:-' + this.data.prodId + '\r\n';

        // URL
        if (this.data.url) {
            g += 'URL:' + this.data.url + '\r\n';
        }

        // SOURCE
        if (this.data.source) {
            g += 'SOURCE;VALUE=URI:' + this.data.source + '\r\n';
        }

        // CALSCALE
        if (this.data.scale) {
            g += 'CALSCALE:' + this.data.scale + '\r\n';
        }

        // METHOD
        if (this.data.method) {
            g += 'METHOD:' + this.data.method + '\r\n';
        }

        // NAME
        if (this.data.name) {
            g += 'NAME:' + this.data.name + '\r\n';
            g += 'X-WR-CALNAME:' + this.data.name + '\r\n';
        }

        // Description
        if (this.data.description) {
            g += 'X-WR-CALDESC:' + this.data.description + '\r\n';
        }

        // Timezone
        if (this.data.timezone?.generator) {
            const timezones = [
                ...new Set([
                    this.timezone(),
                    ...this.data.events.map((event) => event.timezone()),
                ]),
            ].filter((tz) => tz !== null && !tz.startsWith('/')) as string[];

            timezones.forEach((tz) => {
                if (!this.data.timezone?.generator) {
                    return;
                }

                const s = this.data.timezone.generator(tz);
                if (!s) {
                    return;
                }

                g +=
                    s.replace(/\r\n/g, '\n').replace(/\n/g, '\r\n').trim() +
                    '\r\n';
            });
        }
        if (this.data.timezone?.name) {
            g += 'TIMEZONE-ID:' + this.data.timezone.name + '\r\n';
            g += 'X-WR-TIMEZONE:' + this.data.timezone.name + '\r\n';
        }

        // TTL
        if (this.data.ttl) {
            g +=
                'REFRESH-INTERVAL;VALUE=DURATION:' +
                toDurationString(this.data.ttl) +
                '\r\n';
            g += 'X-PUBLISHED-TTL:' + toDurationString(this.data.ttl) + '\r\n';
        }

        // Apple calendar color
        if (this.data.color) {
            g += 'X-APPLE-CALENDAR-COLOR:' + this.data.color + '\r\n';
        }

        // Events
        this.data.events.forEach((event) => (g += event.toString()));

        // CUSTOM X ATTRIBUTES
        g += generateCustomAttributes(this.data);

        g += 'END:VCALENDAR';

        return foldLines(g);
    }

    /**
     * Get the current ttl duration in seconds
     * @since 0.2.5
     */
    ttl(): null | number;
    /**
     * Use this method to set your feed's time to live
     * (in seconds). Is used to fill `REFRESH-INTERVAL` and
     * `X-PUBLISHED-TTL` in your iCal.
     *
     * ```javascript
     * const cal = ical().ttl(60 * 60 * 24); // 1 day
     * ```
     *
     * You can also pass a moment.js duration object. Zero, null
     * or negative numbers will reset the `ttl` attribute.
     *
     * @since 0.2.5
     */
    ttl(ttl: ICalMomentDurationStub | null | number): this;
    ttl(ttl?: ICalMomentDurationStub | null | number): null | number | this {
        if (ttl === undefined) {
            return this.data.ttl;
        }

        if (isMomentDuration(ttl)) {
            this.data.ttl = ttl.asSeconds();
        } else if (ttl && ttl > 0) {
            this.data.ttl = ttl;
        } else {
            this.data.ttl = null;
        }

        return this;
    }

    /**
     * Get your feed's URL
     * @since 0.2.5
     */
    url(): null | string;
    /**
     * Set your feed's URL
     *
     * ```javascript
     * calendar.url('http://example.com/my/feed.ical');
     * ```
     *
     * @since 0.2.5
     */
    url(url: null | string): this;
    url(url?: null | string): null | string | this {
        if (url === undefined) {
            return this.data.url;
        }

        this.data.url = url || null;
        return this;
    }
    /**
     * Set X-* attributes. Woun't filter double attributes,
     * which are also added by another method (e.g. busystatus),
     * so these attributes may be inserted twice.
     *
     * ```javascript
     * calendar.x([
     *     {
     *         key: "X-MY-CUSTOM-ATTR",
     *         value: "1337!"
     *     }
     * ]);
     *
     * calendar.x([
     *     ["X-MY-CUSTOM-ATTR", "1337!"]
     * ]);
     *
     * calendar.x({
     *     "X-MY-CUSTOM-ATTR": "1337!"
     * });
     * ```
     *
     * ```text
     * BEGIN:VCALENDAR
     * VERSION:2.0
     * PRODID:-//sebbo.net//ical-generator//EN
     * X-MY-CUSTOM-ATTR:1337!
     * END:VCALENDAR
     * ```
     *
     * @since 1.9.0
     */
    x(
        keyOrArray:
            | [string, string][]
            | Record<string, string>
            | { key: string; value: string }[],
    ): this;
    /**
     * Set a X-* attribute. Woun't filter double attributes,
     * which are also added by another method (e.g. busystatus),
     * so these attributes may be inserted twice.
     *
     * ```javascript
     * calendar.x("X-MY-CUSTOM-ATTR", "1337!");
     * ```
     *
     * ```text
     * BEGIN:VCALENDAR
     * VERSION:2.0
     * PRODID:-//sebbo.net//ical-generator//EN
     * X-MY-CUSTOM-ATTR:1337!
     * END:VCALENDAR
     * ```
     *
     * @since 1.9.0
     */
    x(keyOrArray: string, value: string): this;
    /**
     * Get all custom X-* attributes.
     * @since 1.9.0
     */
    x(): { key: string; value: string }[];
    x(
        keyOrArray?:
            | [string, string][]
            | Record<string, string>
            | string
            | { key: string; value: string }[],
        value?: string,
    ): this | void | { key: string; value: string }[] {
        if (keyOrArray === undefined) {
            return addOrGetCustomAttributes(this.data);
        }

        if (typeof keyOrArray === 'string' && typeof value === 'string') {
            addOrGetCustomAttributes(this.data, keyOrArray, value);
        } else if (typeof keyOrArray === 'object') {
            addOrGetCustomAttributes(this.data, keyOrArray);
        } else {
            throw new Error('Either key or value is not a string!');
        }

        return this;
    }
}
