// SPDX-FileCopyrightText: Night Theme Switcher Contributors
// SPDX-License-Identifier: GPL-3.0-or-later

import Geoclue from 'gi://Geoclue';
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Meta from 'gi://Meta';
import Shell from 'gi://Shell';

import { gettext as _ } from 'resource:///org/gnome/shell/extensions/extension.js';
import { layoutManager, messageTray, wm } from 'resource:///org/gnome/shell/ui/main.js';
import * as MessageTray from 'resource:///org/gnome/shell/ui/messageTray.js';

import * as debug from '../debug.js';

import { ColorScheme } from '../enums/ColorScheme.js'; // eslint-disable-line no-unused-vars
import { Time } from '../enums/Time.js';


/**
 * The Timer is responsible for signaling any time change to the other modules.
 *
 * They can connect to its 'time' property and query it for the current time.
 *
 * It will try to use the current location as a time source but will fall back
 * to a manual schedule if the location services are disabled or if the user
 * forced the manual schedule in the preferences.
 */
export class Timer extends GObject.Object {
    #settings;
    #colorSchemeSettings;
    #interfaceSettings;
    #locationSettings;
    #time;

    #cancellable = null;
    #previousKeybinding = null;
    #timeTimeoutId = null;
    #geoclue = null;
    #geoclueLocationConnectionId = null;
    #suntimesTimeoutId = null;
    #manuallySetTime = false;

    #settingsConnections = [];

    static {
        GObject.registerClass({
            Properties: {
                time: GObject.ParamSpec.string('time', 'Time', 'Time', GObject.ParamFlags.READWRITE, Time.UNKNOWN),
            },
        }, this);
    }

    constructor() {
        super();
        this.#settings = NTS.getSettings(`${NTS.metadata['settings-schema']}.time`);
        this.#colorSchemeSettings = NTS.getSettings(`${NTS.metadata['settings-schema']}.color-scheme`);
        this.#interfaceSettings = new Gio.Settings({ schema: 'org.gnome.desktop.interface' });
        this.#locationSettings = new Gio.Settings({ schema: 'org.gnome.system.location' });
    }

    enable() {
        debug.message('Enabling Timer...');
        this.#cancellable = new Gio.Cancellable();
        this.#connectSettings();
        this.#trackTime();
        if (this.#settings.get_boolean('manual-schedule')) {
            debug.message('Using the manual schedule.');
        } else {
            debug.message('Using location.');
            this.#trackLocation();
            this.#trackSuntimes();
        }
        this.#addKeybinding();
        this.#changeTime(this.#computeTime());
        debug.message('Timer enabled.');
    }

    disable() {
        debug.message('Disabling Timer...');
        this.#removeKeybinding();
        this.#untrackSuntimes();
        this.#untrackLocation();
        this.#untrackTime();
        this.#disconnectSettings();
        this.#cancellable.cancel();
        debug.message('Timer disabled.');
    }

    /**
     * @param {ColorScheme} colorScheme Color scheme to sync the time to.
     */
    syncTimeToColorScheme(colorScheme) {
        this.#changeTime(this.#colorSchemeToTime(colorScheme), true);
    }


    get time() {
        return this.#time || Time.UNKNOWN;
    }

    #changeTime(time, manual = false) {
        if (time === this.#time) {
            if (!manual && this.#manuallySetTime)
                this.#manuallySetTime = false;
            return;
        }

        if (!manual && time !== this.#time && this.#manuallySetTime)
            return;

        this.#time = time;
        this.#manuallySetTime = manual;

        debug.message(manual ? `Time manually set to ${time}.` : `Time changed to ${time}.`);

        const isMonitorFullscreen = layoutManager.monitors.some(monitor => monitor.inFullscreen);
        if (this.#settings.get_boolean('fullscreen-transition') || !isMonitorFullscreen)
            layoutManager.screenTransition.run();

        this.notify('time');
    }


    #connectSettings() {
        debug.message('Connecting Timer to settings...');
        this.#settingsConnections.push({
            settings: this.#locationSettings,
            id: this.#locationSettings.connect('changed::enabled', this.#onLocationStateChanged.bind(this)),
        });
        this.#settingsConnections.push({
            settings: this.#settings,
            id: this.#settings.connect('changed::manual-schedule', this.#onManualScheduleStateChanged.bind(this)),
        });
        this.#settingsConnections.push({
            settings: this.#settings,
            id: this.#settings.connect('changed::nightthemeswitcher-ondemand-keybinding', this.#onOndemandKeybindingChanged.bind(this)),
        });
        // Only listen to the offset setting when not using a manual schedule
        if (!this.#settings.get_boolean('manual-schedule')) {
            this.#settingsConnections.push({
                settings: this.#settings,
                id: this.#settings.connect('changed::offset', this.#onOffsetChanged.bind(this)),
            });
        }
    }

    #disconnectSettings() {
        this.#settingsConnections.forEach(({ settings, id }) => settings.disconnect(id));
        this.#settingsConnections = [];
        debug.message('Disconnected Timer from settings.');
    }


    #trackTime() {
        debug.message('Watching for time change...');
        this.#timeTimeoutId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 1, () => {
            this.#changeTime(this.#computeTime());
            return GLib.SOURCE_CONTINUE;
        });
    }

    #untrackTime() {
        if (this.#timeTimeoutId) {
            GLib.Source.remove(this.#timeTimeoutId);
            this.#timeTimeoutId = null;
        }
        debug.message('Stopped watching for time change.');
    }


    #trackLocation() {
        debug.message('Connecting to GeoClue...');
        Geoclue.Simple.new(
            'org.gnome.Shell',
            Geoclue.AccuracyLevel.CITY,
            this.#cancellable,
            this.#onGeoclueReady.bind(this)
        );
    }

    #untrackLocation() {
        debug.message('Disconnecting from GeoClue...');
        if (this.#geoclue && this.#geoclueLocationConnectionId) {
            this.#geoclue.disconnect(this.#geoclueLocationConnectionId);
            this.#geoclueLocationConnectionId = null;
            this.#geoclue = null;
        }
        debug.message('Disconnected from GeoClue.');
    }


    #trackSuntimes() {
        debug.message('Regularly updating sun times...');
        this.#suntimesTimeoutId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 3600, () => {
            this.#updateSuntimes();
            return GLib.SOURCE_CONTINUE;
        });
    }

    #untrackSuntimes() {
        if (this.#suntimesTimeoutId) {
            GLib.Source.remove(this.#suntimesTimeoutId);
            this.#suntimesTimeoutId = null;
        }
        debug.message('Stopped regularly updating sun times.');
    }


    #addKeybinding() {
        this.#previousKeybinding = this.#settings.get_strv('nightthemeswitcher-ondemand-keybinding')[0];
        if (!this.#settings.get_strv('nightthemeswitcher-ondemand-keybinding')[0])
            return;
        debug.message('Adding keybinding...');
        wm.addKeybinding(
            'nightthemeswitcher-ondemand-keybinding',
            this.#settings,
            Meta.KeyBindingFlags.IGNORE_AUTOREPEAT,
            Shell.ActionMode.NORMAL | Shell.ActionMode.OVERVIEW,
            () => {
                const time = this.time === Time.NIGHT ? Time.DAY : Time.NIGHT;
                this.#changeTime(time, true);
            }
        );
        debug.message('Added keybinding.');
    }

    #removeKeybinding() {
        if (this.#previousKeybinding) {
            debug.message('Removing keybinding...');
            wm.removeKeybinding('nightthemeswitcher-ondemand-keybinding');
            debug.message('Removed keybinding.');
        }
    }


    #colorSchemeToTime(colorScheme) {
        return colorScheme === this.#colorSchemeSettings.get_string('night') ? Time.NIGHT : Time.DAY;
    }


    #onLocationStateChanged() {
        this.disable();
        this.enable();
    }

    #onManualScheduleStateChanged() {
        this.disable();
        this.enable();
    }

    #onOffsetChanged() {
        this.#updateSuntimes();
    }

    #onOndemandKeybindingChanged() {
        this.#removeKeybinding();
        this.#addKeybinding();
    }

    #onGeoclueReady(_geoclue, result) {
        try {
            this.#geoclue = Geoclue.Simple.new_finish(result);
            this.#geoclueLocationConnectionId = this.#geoclue.connect('notify::location', this.#onLocationChanged.bind(this));
            debug.message('Connected to GeoClue.');
            this.#onLocationChanged();
        } catch (e) {
            const [latitude, longitude] = this.#settings.get_value('location').deepUnpack();
            if (latitude >= -90 && latitude <= 90 && longitude >= -180 && longitude <= 180) {
                console.error(`[${NTS.metadata.name}] Unable to retrieve the location, using the last known location instead.\n${e}`);
                this.#updateSuntimes();
            } else {
                console.error(`[${NTS.metadata.name}] Unable to retrieve the location, using the manual schedule times instead.\n${e}`);

                const source = new MessageTray.Source({
                    title: NTS.metadata.name,
                    icon: Gio.icon_new_for_string(GLib.build_filenamev([NTS.metadata.path, 'icons', 'nightthemeswitcher-symbolic.svg'])),
                });
                messageTray.add(source);

                const notification = new MessageTray.Notification(
                    {
                        source,
                        title: _('Unknown Location'),
                        body: _('A manual schedule will be used to switch the dark mode.'),
                        'icon-name': 'location-services-disabled-symbolic',
                    }
                );
                notification.addAction(_('Edit Manual Schedule'), () => NTS.openPreferences());

                notification.connect('activated', () => NTS.openPreferences());

                source.addNotification(notification);

                this.#settings.set_boolean('manual-schedule', true);
            }
        }
    }

    #onLocationChanged(_geoclue, _location) {
        debug.message('Location has changed.');
        const { latitude, longitude } = this.#geoclue.get_location();
        this.#settings.set_value('location', new GLib.Variant('(dd)', [latitude, longitude]));
        debug.message(`Current location: (${latitude};${longitude})`);
        this.#updateSuntimes();
    }


    #computeTime() {
        const sunrise = this.#settings.get_double('sunrise');
        const sunset = this.#settings.get_double('sunset');
        const datetime = GLib.DateTime.new_now_local();
        const hour = datetime.get_hour() + datetime.get_minute() / 60 + datetime.get_second() / 3600;

        // Regular schedule
        if (sunrise < sunset)
            return hour >= sunrise && hour < sunset ? Time.DAY : Time.NIGHT;
        // Sunset happens on the day after
        else if (sunrise > sunset)
            return hour >= sunrise || hour < sunset ? Time.DAY : Time.NIGHT;
        // Sunset and Sunrise times are identical; preserve current theme
        else
            return this.#time || this.#colorSchemeToTime(this.#interfaceSettings.get_string('color-scheme'));
    }

    #updateSuntimes() {
        const [latitude, longitude] = this.#settings.get_value('location').deepUnpack();

        if (latitude < -90 && latitude > 90 && longitude < -180 && longitude > 180)
            return;

        debug.message('Updating sun times...');

        const rad = degrees => degrees * Math.PI / 180;
        const deg = radians => radians * 180 / Math.PI;

        // Calculations from https://www.esrl.noaa.gov/gmd/grad/solcalc/calcdetails.html
        const dtNow = GLib.DateTime.new_now_local();
        const dtZero = GLib.DateTime.new_utc(1900, 1, 1, 0, 0, 0);

        const timeSpan = dtNow.difference(dtZero);

        const date = timeSpan / 1000 / 1000 / 60 / 60 / 24 + 2;
        const tzOffset = dtNow.get_utc_offset() / 1000 / 1000 / 60 / 60;

        const julianDay = date + 2415018.5 - tzOffset / 24;
        const julianCentury = (julianDay - 2451545) / 36525;
        const geomMeanLongSun = (280.46646 + julianCentury * (36000.76983 + julianCentury * 0.0003032)) % 360;
        const geomMeanAnomSun = 357.52911 + julianCentury * (35999.05029 - 0.0001537 * julianCentury);
        const eccentEarthOrbit = 0.016708634 - julianCentury * (0.000042037 + 0.0000001267 * julianCentury);
        const sunEqOfCtr = Math.sin(rad(geomMeanAnomSun)) * (1.914602 - julianCentury * (0.004817 + 0.000014 * julianCentury)) + Math.sin(rad(2 * geomMeanAnomSun)) * (0.019993 - 0.000101 * julianCentury) + Math.sin(rad(3 * geomMeanAnomSun)) * 0.000289;
        const sunTrueLong = geomMeanLongSun + sunEqOfCtr;
        const sunAppLong = sunTrueLong - 0.00569 - 0.00478 * Math.sin(rad(125.04 - 1934.136 * julianCentury));
        const meanObliqEcliptic = 23 + (26 + ((21.448 - julianCentury * (46.815 + julianCentury * (0.00059 - julianCentury * 0.001813)))) / 60) / 60;
        const obliqCorr = meanObliqEcliptic + 0.00256 * Math.cos(rad(125.04 - 1934.136 * julianCentury));
        const sunDeclin = deg(Math.asin(Math.sin(rad(obliqCorr)) * Math.sin(rad(sunAppLong))));
        const varY = Math.tan(rad(obliqCorr / 2)) * Math.tan(rad(obliqCorr / 2));
        const eqOfTime = 4 * deg(varY * Math.sin(2 * rad(geomMeanLongSun)) - 2 * eccentEarthOrbit * Math.sin(rad(geomMeanAnomSun)) + 4 * eccentEarthOrbit * varY * Math.sin(rad(geomMeanAnomSun)) * Math.cos(2 * rad(geomMeanLongSun)) - 0.5 * varY * varY * Math.sin(4 * rad(geomMeanLongSun)) - 1.25 * eccentEarthOrbit * eccentEarthOrbit * Math.sin(2 * rad(geomMeanAnomSun)));
        const haSunrise = deg(Math.acos(Math.cos(rad(90.833)) / (Math.cos(rad(latitude)) * Math.cos(rad(sunDeclin))) - Math.tan(rad(latitude)) * Math.tan(rad(sunDeclin))));
        const solarNoon = (720 - 4 * longitude - eqOfTime + tzOffset * 60) / 1440;

        const timeSunrise = solarNoon - haSunrise * 4 / 1440;
        const timeSunset = solarNoon + haSunrise * 4 / 1440;

        const modulo = (n, m) => ((n % m) + m) % m;

        const offset = this.#settings.get_double('offset');
        const sunrise = modulo(timeSunrise * 24 + offset, 24);
        const sunset = modulo(timeSunset * 24 - offset, 24);

        this.#settings.set_double('sunrise', sunrise);
        this.#settings.set_double('sunset', sunset);

        debug.message(`New sun times: (sunrise: ${sunrise}; sunset: ${sunset})`);
    }
}
