"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ExifDateTime = void 0;
const luxon_1 = require("luxon");
const DateTime_1 = require("./DateTime");
const Maybe_1 = require("./Maybe");
const Object_1 = require("./Object");
const String_1 = require("./String");
const TimeParsing_1 = require("./TimeParsing");
const Timezones_1 = require("./Timezones");
/**
 * Encapsulates encoding and decoding EXIF date and time strings,
 * along with timezone handling functionality.
 *
 * Key features:
 * - Parses datetime strings in various formats (EXIF strict/loose, ISO)
 * - Supports timezone inference, conversion, and matching between dates
 * - Preserves original string representations when available
 * - Provides conversion to/from multiple datetime formats (Luxon DateTime, JS
 *   Date, ISO strings)
 * - Supports serialization/deserialization via JSON (see
 *   {@link ExifDateTime.fromJSON})
 *
 * EXIF datetime strings don't typically include timezone information. This
 * class provides mechanisms to associate timezone data from other EXIF tags
 * (like GPS position or timezone offset), and distinguishes between explicitly
 * set and inferred timezone information.
 */
class ExifDateTime {
    year;
    month;
    day;
    hour;
    minute;
    second;
    millisecond;
    tzoffsetMinutes;
    rawValue;
    zoneName;
    inferredZone;
    static from(exifOrIso, defaultZone) {
        return exifOrIso instanceof ExifDateTime
            ? exifOrIso // already an ExifDateTime
            : (0, String_1.blank)(exifOrIso)
                ? undefined // in order of strictness:
                : (this.fromExifStrict(exifOrIso, defaultZone) ??
                    this.fromISO(exifOrIso, defaultZone) ??
                    this.fromExifLoose(exifOrIso, defaultZone));
    }
    static fromISO(iso, defaultZone) {
        if ((0, String_1.blank)(iso) || null != iso.match(/^\d+$/))
            return undefined;
        // Unfortunately, DateTime.fromISO() is happy to parse a date with no time,
        // so we have to do this ourselves:
        return this.#fromPatterns(iso, (0, TimeParsing_1.timeFormats)({
            formatPrefixes: ["y-MM-dd'T'", "y-MM-dd ", "y-M-d "],
            defaultZone,
        }));
    }
    /**
     * Try to parse a date-time string from EXIF. If there is not both a date
     * and a time component, returns `undefined`.
     *
     * @param text from EXIF metadata
     * @param defaultZone a "zone name" to use as a backstop, or default, if
     * `text` doesn't specify a zone. This may be IANA-formatted, like
     * "America/Los_Angeles", or an offset, like "UTC-3". See
     * `offsetMinutesToZoneName`.
     */
    static fromEXIF(text, defaultZone) {
        if ((0, String_1.blank)(text))
            return undefined;
        return (
        // .fromExifStrict() uses .fromISO() as a backstop
        this.fromExifStrict(text, defaultZone) ??
            this.fromExifLoose(text, defaultZone));
    }
    static #fromPatterns(text, fmts) {
        const result = (0, TimeParsing_1.parseDateTime)(text, fmts);
        return result == null
            ? undefined
            : ExifDateTime.fromDateTime(result.dt, {
                rawValue: text,
                unsetMilliseconds: result.unsetMilliseconds,
                inferredZone: result.inferredZone,
            });
    }
    /**
     * Parse the given date-time string, EXIF-formatted.
     *
     * @param text from EXIF metadata, in `y:M:d H:m:s` format (with optional
     * sub-seconds and/or timezone)
  
     * @param defaultZone a "zone name" to use as a backstop, or default, if
     * `text` doesn't specify a zone. This may be IANA-formatted, like
     * "America/Los_Angeles", or an offset, like "UTC-3". See
     * `offsetMinutesToZoneName`.
     */
    static fromExifStrict(text, defaultZone) {
        if ((0, String_1.blank)(text) || !(0, String_1.isString)(text))
            return undefined;
        return (this.#fromPatterns(text, (0, TimeParsing_1.timeFormats)({ formatPrefixes: ["y:MM:dd ", "y:M:d "], defaultZone })) ??
            // Not found yet? Maybe it's in ISO format? See
            // https://github.com/photostructure/exiftool-vendored.js/issues/71
            this.fromISO(text, defaultZone));
    }
    static *#looseExifFormats(defaultZone) {
        // The following are from actual datestamps seen in the wild (!!)
        const formats = [
            "MMM d y HH:mm:ss",
            "MMM d y, HH:mm:ss",
            // Thu Oct 13 00:12:27 2016:
            "ccc MMM d HH:mm:ss y",
        ];
        const zone = (0, String_1.notBlank)(defaultZone) ? defaultZone : Timezones_1.UnsetZone;
        for (const fmt of formats) {
            yield { fmt: fmt, zone, inferredZone: true };
        }
    }
    static fromExifLoose(text, defaultZone) {
        return (0, String_1.blank)(text) || !(0, String_1.isString)(text)
            ? undefined
            : this.#fromPatterns(text, this.#looseExifFormats(defaultZone));
    }
    static fromDateTime(dt, opts) {
        if (dt == null || !dt.isValid || dt.year === 0 || dt.year === 1) {
            return undefined;
        }
        return new ExifDateTime(dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second, dt.millisecond === 0 && true === opts?.unsetMilliseconds
            ? undefined
            : dt.millisecond, dt.offset === Timezones_1.UnsetZoneOffsetMinutes ? undefined : dt.offset, opts?.rawValue, dt.zoneName == null || dt.zone?.name === Timezones_1.UnsetZone.name
            ? undefined
            : dt.zoneName, opts?.inferredZone);
    }
    /**
     * Create an ExifDateTime from a number of milliseconds since the epoch
     * (meaning since 1 January 1970 00:00:00 UTC). Uses the default zone.
     *
     * @param millis - a number of milliseconds since 1970 UTC
     *
     * @param options.rawValue - the original parsed string input
     * @param options.zone - the zone to place the DateTime into. Defaults to 'local'.
     * @param options.locale - a locale to set on the resulting DateTime instance
     * @param options.outputCalendar - the output calendar to set on the resulting DateTime instance
     * @param options.numberingSystem - the numbering system to set on the resulting DateTime instance
     */
    static fromMillis(millis, options = {}) {
        if (options.zone == null ||
            [Timezones_1.UnsetZoneName, Timezones_1.UnsetZone].includes(options.zone)) {
            delete options.zone;
        }
        let dt = luxon_1.DateTime.fromMillis(millis, {
            ...(0, Object_1.omit)(options, "rawValue"),
        });
        if (options.zone == null) {
            dt = dt.setZone(Timezones_1.UnsetZone, { keepLocalTime: true });
        }
        // TODO: is there a way to provide an invalid millisecond value?
        const result = this.fromDateTime(dt, { rawValue: options.rawValue });
        if (result == null) {
            throw new Error(`Failed to create ExifDateTime from millis: ${millis}`);
        }
        return result;
    }
    static now(opts = {}) {
        return this.fromMillis(Date.now(), opts);
    }
    #dt;
    zone;
    constructor(year, month, day, hour, minute, second, millisecond, tzoffsetMinutes, rawValue, zoneName, inferredZone) {
        this.year = year;
        this.month = month;
        this.day = day;
        this.hour = hour;
        this.minute = minute;
        this.second = second;
        this.millisecond = millisecond;
        this.tzoffsetMinutes = tzoffsetMinutes;
        this.rawValue = rawValue;
        this.zoneName = zoneName;
        this.inferredZone = inferredZone;
        this.zone = (0, Timezones_1.getZoneName)({ zoneName, tzoffsetMinutes });
    }
    get millis() {
        return this.millisecond;
    }
    get hasZone() {
        return this.zone != null;
    }
    get unsetMilliseconds() {
        return this.millisecond == null;
    }
    setZone(zone, opts) {
        const dt = (0, TimeParsing_1.setZone)({
            zone,
            src: this.toDateTime(),
            srcHasZone: this.hasZone,
            opts,
        });
        return ExifDateTime.fromDateTime(dt, {
            rawValue: this.rawValue,
            unsetMilliseconds: this.millisecond == null,
            inferredZone: opts?.inferredZone ?? true,
        });
    }
    toDateTimeObject() {
        return {
            year: this.year,
            month: this.month,
            day: this.day,
            hour: this.hour,
            minute: this.minute,
            second: this.second,
            millisecond: this.millisecond,
        };
    }
    /**
     * CAUTION: This instance will inherit the system timezone if this instance
     * has an unset zone (as Luxon doesn't support "unset" timezones)
     */
    toDateTime(overrideZone) {
        // If an override zone is provided, don't use the cache or cache the result
        if (overrideZone != null) {
            return luxon_1.DateTime.fromObject(this.toDateTimeObject(), {
                zone: (0, Timezones_1.normalizeZone)(overrideZone) ?? overrideZone,
            });
        }
        // Cache the DateTime for performance
        return (this.#dt ??= luxon_1.DateTime.fromObject(this.toDateTimeObject(), {
            // Convert zone name string to Zone object so Luxon can recognize it
            zone: (0, Timezones_1.normalizeZone)(this.zone) ?? this.zone,
        }));
    }
    toEpochSeconds(overrideZone) {
        return this.toDateTime(overrideZone).toUnixInteger();
    }
    toDate() {
        return this.toDateTime().toJSDate();
    }
    toISOString(options = {}) {
        return (0, Maybe_1.denull)(this.toDateTime().toISO({
            suppressMilliseconds: options.suppressMilliseconds ?? this.millisecond == null,
            includeOffset: this.hasZone && options.includeOffset !== false,
        }));
    }
    toExifString() {
        return (0, DateTime_1.dateTimeToExif)(this.toDateTime(), {
            includeOffset: this.hasZone,
            includeMilliseconds: this.millisecond != null,
        });
    }
    toString() {
        return this.toISOString();
    }
    /**
     * @return the epoch milliseconds of this
     */
    toMillis() {
        return this.toDateTime().toMillis();
    }
    get isValid() {
        return this.toDateTime().isValid;
    }
    toJSON() {
        return {
            _ctor: "ExifDateTime", // < magick field used by the JSON parser
            year: this.year,
            month: this.month,
            day: this.day,
            hour: this.hour,
            minute: this.minute,
            second: this.second,
            millisecond: this.millisecond,
            tzoffsetMinutes: this.tzoffsetMinutes,
            rawValue: this.rawValue,
            zoneName: this.zoneName,
            inferredZone: this.inferredZone,
        };
    }
    /**
     * @return a new ExifDateTime from the given JSON. Note that this instance **may not be valid**.
     */
    static fromJSON(json) {
        return new ExifDateTime(json.year, json.month, json.day, json.hour, json.minute, json.second, json.millisecond, json.tzoffsetMinutes, json.rawValue, json.zoneName, json.inferredZone);
    }
    maybeMatchZone(target, maxDeltaMs = 14 * DateTime_1.MinuteMs) {
        const targetZone = target.zone;
        if (targetZone == null || !target.hasZone)
            return;
        return (this.setZone(targetZone, { keepLocalTime: false })?.ifClose(target, maxDeltaMs) ??
            this.setZone(targetZone, { keepLocalTime: true })?.ifClose(target, maxDeltaMs));
    }
    ifClose(target, maxDeltaMs = 14 * DateTime_1.MinuteMs) {
        const ts = this.toMillis();
        const targetTs = target.toMillis();
        return Math.abs(ts - targetTs) <= maxDeltaMs ? this : undefined;
    }
    plus(duration) {
        let dt = this.toDateTime().plus(duration);
        if (!this.hasZone) {
            dt = dt.setZone(Timezones_1.UnsetZone, { keepLocalTime: true });
        }
        return ExifDateTime.fromDateTime(dt, this);
    }
}
exports.ExifDateTime = ExifDateTime;
//# sourceMappingURL=ExifDateTime.js.map