// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.

{
  class nsZenMods extends ZenPreloadedFeature {
    // private properties start
    #kZenStylesheetModHeader = '/* Zen Mods - Generated by ZenMods.';
    #kZenStylesheetModHeaderBody = `* DO NOT EDIT THIS FILE DIRECTLY!
* Your changes will be overwritten.
* Instead, go to the preferences and edit the mods there.
*/
`;
    #kZenStylesheetModFooter = `
/* End of Zen Mods */
`;
    #getCurrentDateTime = () =>
      new Intl.DateTimeFormat('en-US', {
        dateStyle: 'full',
        timeStyle: 'full',
      }).format(new Date().getTime());

    constructor() {
      super();
    }

    // Stylesheet service
    #_modsBackend = null;

    get #modsBackend() {
      if (!this.#_modsBackend) {
        this.#_modsBackend = Cc['@mozilla.org/zen/mods-backend;1'].getService(Ci.nsIZenModsBackend);
      }
      return this.#_modsBackend;
    }

    get #styleSheetPath() {
      return PathUtils.join(PathUtils.profileDir, 'chrome', 'zen-themes.css');
    }

    async #handleDisableMods() {
      await this.#rebuildModsStylesheet();
    }

    #getStylesheetURIForMod(mod) {
      return Services.io.newFileURI(
        new FileUtils.File(PathUtils.join(this.getModFolder(mod.id), 'chrome.css'))
      );
    }

    async #readStylesheet() {
      const path = this.modsRootPath;
      if (!(await IOUtils.exists(path))) {
        return '';
      }
      return await IOUtils.readUTF8(this.#styleSheetPath);
    }

    async #insertStylesheet() {
      try {
        const content = await this.#readStylesheet();
        this.#modsBackend.rebuildModsStyles(content);
      } catch (e) {
        console.warn('[ZenMods]: Error rebuilding mods styles:', e);
      }
    }

    async #rebuildModsStylesheet() {
      const shouldBeEnabled = !Services.prefs.getBoolPref('zen.themes.disable-all', false);
      if (shouldBeEnabled) {
        const mods = await this.#getEnabledMods();

        await this.#writeStylesheet(mods);

        const modsWithPreferences = await Promise.all(
          mods.map(async (mod) => {
            const preferences = await this.getModPreferences(mod);

            return {
              name: mod.name,
              enabled: mod.enabled,
              preferences,
            };
          })
        );

        this.#setDefaults(modsWithPreferences);
        this.#writeToDom(modsWithPreferences);
      }

      await this.#insertStylesheet();
    }

    async #getEnabledMods() {
      const modsObject = await this.getMods();
      const mods = Object.values(modsObject).filter(
        (mod) => mod.enabled === undefined || mod.enabled
      );

      const modList = mods.map(({ name }) => name).join(', ');

      const message =
        modList !== ''
          ? `[ZenMods]: Loading enabled Zen mods: ${modList}.`
          : '[ZenMods]: No enabled Zen mods.';

      console.log(message);

      return mods;
    }

    #setDefaults(modsWithPreferences) {
      for (const { preferences, enabled } of modsWithPreferences) {
        if (enabled !== undefined && !enabled) {
          continue;
        }

        for (const { type, property, defaultValue } of preferences) {
          if (defaultValue === undefined) {
            continue;
          }

          const getProperty =
            type === 'checkbox' ? Services.prefs.getBoolPref : Services.prefs.getStringPref;
          const setProperty =
            type === 'checkbox' ? Services.prefs.setBoolPref : Services.prefs.setStringPref;

          try {
            getProperty(property);
          } catch {
            console.debug(
              `[ZenMods]: Setting default value for ${property} to ${defaultValue} (${typeof defaultValue})`
            );

            if (
              typeof defaultValue !== 'boolean' &&
              typeof defaultValue !== 'string' &&
              typeof defaultValue !== 'number'
            ) {
              console.warn(
                `[ZenMods]: Warning, invalid data type received (${typeof defaultValue}), skipping.`
              );
              continue;
            }

            setProperty(
              property,
              typeof defaultValue === 'boolean' ? defaultValue : defaultValue.toString()
            );
          }
        }
      }
    }

    #writeToDom(modsWithPreferences) {
      for (const browser of ZenMultiWindowFeature.browsers) {
        for (const { enabled, preferences, name } of modsWithPreferences) {
          const sanitizedName = this.sanitizeModName(name);

          if (enabled !== undefined && !enabled) {
            const element = browser.document.getElementById(sanitizedName);

            if (element) {
              element.remove();
            }

            for (const { property } of preferences.filter(({ type }) => type !== 'checkbox')) {
              const sanitizedProperty = property?.replaceAll(/\./g, '-');

              browser.document
                .querySelector(':root')
                .style.removeProperty(`--${sanitizedProperty}`);
            }

            continue;
          }

          for (const { property, type } of preferences) {
            const value = Services.prefs.getStringPref(property, '');
            const sanitizedProperty = property?.replaceAll(/\./g, '-');

            switch (type) {
              case 'dropdown': {
                if (value !== '') {
                  let element = browser.document.getElementById(sanitizedName);

                  if (!element) {
                    element = browser.document.createElement('div');

                    element.style.display = 'none';
                    element.setAttribute('id', sanitizedName);

                    browser.document.body.appendChild(element);
                  }

                  element.setAttribute(sanitizedProperty, value);
                }
                break;
              }

              case 'string': {
                if (value === '') {
                  browser.document
                    .querySelector(':root')
                    .style.removeProperty(`--${sanitizedProperty}`);
                } else {
                  browser.document
                    .querySelector(':root')
                    .style.setProperty(`--${sanitizedProperty}`, value);
                }
                break;
              }

              default: {
              }
            }
          }
        }
      }
    }

    async #writeStylesheet(modList = []) {
      const mods = [];

      for (let mod of modList) {
        mod._chromeURL = this.#getStylesheetURIForMod(mod).spec;
        mods.push(mod);
      }

      let content = this.#kZenStylesheetModHeader;
      content += `\n* FILE GENERATED AT: ${this.#getCurrentDateTime()}\n`;
      content += this.#kZenStylesheetModHeaderBody;

      for (let mod of mods) {
        if (mod.enabled !== undefined && !mod.enabled) {
          continue;
        }

        content += `\n/* Name: ${mod.name} */\n`;
        content += `/* Description: ${mod.description} */\n`;
        content += `/* Author: @${mod.author} */\n`;

        if (mod._readmeURL) {
          content += `/* Readme: ${mod.readme} */\n`;
        }

        content += `@import url("${mod._chromeURL}");\n`;
      }

      content += this.#kZenStylesheetModFooter;

      const buffer = new TextEncoder().encode(content);

      await IOUtils.write(this.#styleSheetPath, buffer);
    }

    #compareVersions(version1, version2) {
      let result = false;

      if (typeof version1 !== 'object') {
        version1 = version1.toString().split('.');
      }

      if (typeof version2 !== 'object') {
        version2 = version2.toString().split('.');
      }

      for (let i = 0; i < Math.max(version1.length, version2.length); i++) {
        if (version1[i] == undefined) {
          version1[i] = 0;
        }
        if (version2[i] == undefined) {
          version2[i] = 0;
        }
        if (Number(version1[i]) < Number(version2[i])) {
          result = true;
          break;
        }
        if (version1[i] != version2[i]) {
          break;
        }
      }
      return result;
    }

    #composeModApiUrl(modId) {
      // keeping theme here as it would require changes to CI to change the name
      return `https://zen-browser.github.io/theme-store/themes/${modId}/theme.json`;
    }

    async #downloadUrlToFile(url, path, isStyleSheet = false, maxRetries = 3, retryDelayMs = 500) {
      let attempt = 0;

      while (attempt < maxRetries) {
        try {
          const response = await fetch(url);

          if (!response.ok) {
            throw new Error(`HTTP error! status: ${response.status} for url: ${url}`);
          }

          const data = await response.text();

          // convert the data into a Uint8Array
          const buffer = new TextEncoder().encode(data);
          await IOUtils.write(path, buffer);

          return; // to exit the loop
        } catch (e) {
          attempt++;
          if (attempt >= maxRetries) {
            console.error('[ZenMods]: Error downloading file after retries', url, e);
          } else {
            console.warn(
              `[ZenMods]: Download failed (attempt ${attempt} of ${maxRetries}), retrying in ${retryDelayMs}ms...`,
              url,
              e
            );
            await new Promise((res) => setTimeout(res, retryDelayMs));
          }
        }
      }
    }

    // private properties end

    // public properties start

    throttle(mainFunction, delay) {
      let timerFlag = null;

      return (...args) => {
        if (timerFlag === null) {
          mainFunction(...args);
          timerFlag = setTimeout(() => {
            timerFlag = null;
          }, delay);
        }
      };
    }

    debounce(mainFunction, wait) {
      let timerFlag;

      return (...args) => {
        clearTimeout(timerFlag);
        timerFlag = setTimeout(() => {
          mainFunction(...args);
        }, wait);
      };
    }

    sanitizeModName(name) {
      // Do not change to "mod-" for backwards compatibility
      return `theme-${name?.replaceAll(/\s/g, '-')?.replaceAll(/[^A-Za-z_-]+/g, '')}`;
    }

    get updatePref() {
      return 'zen.mods.updated-value-observer';
    }

    get modsRootPath() {
      return PathUtils.join(PathUtils.profileDir, 'chrome', 'zen-themes');
    }

    get modsDataFile() {
      return PathUtils.join(PathUtils.profileDir, 'zen-themes.json');
    }

    getModFolder(modId) {
      return PathUtils.join(this.modsRootPath, modId);
    }

    async getMods() {
      if (!(await IOUtils.exists(this.modsDataFile))) {
        await IOUtils.writeJSON(this.modsDataFile, {});

        return {};
      }

      let mods = {};

      try {
        mods = await IOUtils.readJSON(this.modsDataFile);

        if (mods === null || typeof mods !== 'object') {
          throw new Error('Mods data file is invalid');
        }
      } catch {
        // If we have a corrupted file, reset it
        await IOUtils.writeJSON(this.modsDataFile, {});

        Services.wm
          .getMostRecentWindow('navigator:browser')
          .gZenUIManager.showToast('zen-themes-corrupted', {
            timeout: 8000,
          });
      }

      return mods;
    }

    async getModPreferences(mod) {
      const modPath = PathUtils.join(this.modsRootPath, mod.id, 'preferences.json');

      if (!(await IOUtils.exists(modPath)) || !mod.preferences) {
        return [];
      }

      try {
        const preferences = await IOUtils.readJSON(modPath);

        return preferences.filter(({ disabledOn = [] }) => {
          return !disabledOn.includes(gZenOperatingSystemCommonUtils.currentOperatingSystem);
        });
      } catch (e) {
        console.error(`[ZenMods]: Error reading mod preferences for ${mod.name}:`, e);
        return [];
      }
    }

    async init() {
      try {
        await SessionStore.promiseInitialized;

        if (
          Services.prefs.getBoolPref('zen.themes.disable-all', false) ||
          Services.appinfo.inSafeMode
        ) {
          console.log('[ZenMods]: Mods disabled by user or in safe mode.');
          return;
        }

        await this.getMods(); // Check for any errors in the themes data file
        const mods = await this.#getEnabledMods();

        const modsWithPreferences = await Promise.all(
          mods.map(async (mod) => {
            const preferences = await this.getModPreferences(mod);

            return {
              name: mod.name,
              enabled: mod.enabled,
              preferences,
            };
          })
        );

        this.#writeToDom(modsWithPreferences);

        await this.#insertStylesheet();

        this.#setNewMilestoneIfNeeded();
        if (this.#shouldAutoUpdate()) {
          requestIdleCallback(
            () => {
              if (!window.closed) {
                requestAnimationFrame(() => {
                  this.checkForModsUpdates();
                });
              }
            },
            { timeout: 1000 }
          );
        }
      } catch (e) {
        console.error('[ZenMods]: Error loading Zen Mods:', e);
      }

      Services.prefs.addObserver(this.updatePref, this.#rebuildModsStylesheet.bind(this), false);
      Services.prefs.addObserver(
        'zen.themes.disable-all',
        this.#handleDisableMods.bind(this),
        false
      );
    }

    #setNewMilestoneIfNeeded() {
      const previousMilestone = Services.prefs.getStringPref('zen.mods.milestone', '');
      if (previousMilestone != Services.appinfo.version) {
        Services.prefs.setStringPref('zen.mods.milestone', Services.appinfo.version);
        Services.prefs.clearUserPref('zen.mods.last-update');
      }
    }

    #shouldAutoUpdate() {
      const daysBeforeUpdate = Services.prefs.getIntPref('zen.mods.auto-update-days');
      const lastUpdatedSec = Services.prefs.getIntPref('zen.mods.last-update', -1);
      const nowSec = Math.floor(Date.now() / 1000);
      const daysSinceUpdate = (nowSec - lastUpdatedSec) / (60 * 60 * 24);

      return (
        (Services.prefs.getBoolPref('zen.mods.auto-update', true) &&
          daysSinceUpdate >= daysBeforeUpdate) ||
        lastUpdatedSec < 0
      );
    }

    async checkForModsUpdates() {
      const mods = await this.getMods();

      const updates = await Promise.all(
        Object.values(mods).map(async (currentMod) => {
          try {
            const possibleNewModVersion = await this.requestMod(currentMod.id);

            if (!possibleNewModVersion) {
              return null;
            }

            if (
              !this.#compareVersions(
                possibleNewModVersion.version,
                currentMod.version ?? '0.0.0'
              ) &&
              possibleNewModVersion.version != currentMod.version
            ) {
              console.log(
                `[ZenMods]: Mod update found for mod ${currentMod.name} (${currentMod.id}), current: ${currentMod.version}, new: ${possibleNewModVersion.version}`
              );

              possibleNewModVersion.enabled = currentMod.enabled;

              await this.removeMod(currentMod.id, false);

              mods[currentMod.id] = possibleNewModVersion;

              return possibleNewModVersion;
            }

            return null;
          } catch (e) {
            console.error('[ZenMods]: Error checking for mod updates', e);

            return null;
          }
        })
      );

      await this.updateMods(mods);
      Services.prefs.setIntPref('zen.mods.last-update', Math.floor(Date.now() / 1000));
      return updates.filter((update) => {
        return update !== null;
      });
    }

    async removeMod(modId, triggerUpdate = true) {
      const modPath = this.getModFolder(modId);

      console.log(`[ZenMods]: Removing mod ${modPath}`);

      await IOUtils.remove(modPath, { recursive: true, ignoreAbsent: true });

      const mods = await this.getMods();

      delete mods[modId];

      await IOUtils.writeJSON(this.modsDataFile, mods);

      if (triggerUpdate) {
        this.triggerModsUpdate();
      }
    }

    async enableMod(modId) {
      const mods = await this.getMods();
      const mod = mods[modId];

      console.log(`[ZenMods]: Enabling mod ${mod.name}`);

      mod.enabled = true;

      await IOUtils.writeJSON(this.modsDataFile, mods);
    }

    async disableMod(modId) {
      const mods = await this.getMods();
      const mod = mods[modId];

      console.log(`[ZenMods]: Disabling mod ${mod.name}`);

      mod.enabled = false;

      await IOUtils.writeJSON(this.modsDataFile, mods);
    }

    async updateMods(mods = undefined) {
      if (!mods) {
        mods = await this.getMods();
      }

      await IOUtils.writeJSON(this.modsDataFile, mods);
      await this.checkForModChanges();
    }

    triggerModsUpdate() {
      Services.prefs.setBoolPref(this.updatePref, !Services.prefs.getBoolPref(this.updatePref));
    }

    async installMod(mod) {
      try {
        const modPath = PathUtils.join(this.modsRootPath, mod.id);
        await IOUtils.makeDirectory(modPath, { ignoreExisting: true });

        await this.#downloadUrlToFile(mod.style, PathUtils.join(modPath, 'chrome.css'), true);
        await this.#downloadUrlToFile(mod.readme, PathUtils.join(modPath, 'readme.md'));

        if (mod.preferences) {
          await this.#downloadUrlToFile(
            mod.preferences,
            PathUtils.join(modPath, 'preferences.json')
          );
        }
      } catch (e) {
        console.error('[ZenMods]: Error installing mod', mod.id, e);
      }
    }

    async checkForModChanges() {
      const mods = await this.getMods();

      for (const [modId, mod] of Object.entries(mods)) {
        try {
          if (!mod) {
            continue;
          }

          if (!(await IOUtils.exists(this.getModFolder(modId)))) {
            await this.installMod(mod);
          }
        } catch (e) {
          console.error('[ZenMods]: Error checking for mod changes', e);
        }
      }

      this.triggerModsUpdate();
    }

    async requestMod(modId) {
      const url = this.#composeModApiUrl(modId);

      console.debug(`[ZenMods]: Fetching mod ${modId} info from ${url}`);

      const data = await fetch(url, {
        mode: 'no-cors',
      });

      if (data.ok) {
        try {
          const obj = await data.json();

          return obj;
        } catch (e) {
          console.error(`[ZenMods]: Error parsing mod ${modId} info:`, e);
        }
      } else {
        console.error(`[ZenMods]: Error fetching mod ${modId} info:`, data.status);
      }

      return null;
    }

    async isModInstalled(modId) {
      const mods = await this.getMods();
      return Boolean(mods?.[modId]);
    }

    // public properties end
  }

  window.gZenMods = new nsZenMods();

  gZenActorsManager.addJSWindowActor('ZenModsMarketplace', {
    parent: {
      esModuleURI: 'resource:///actors/ZenModsMarketplaceParent.sys.mjs',
    },
    child: {
      esModuleURI: 'resource:///actors/ZenModsMarketplaceChild.sys.mjs',
      events: {
        DOMContentLoaded: {},
      },
    },
    matches: [
      ...Services.prefs.getStringPref('zen.injections.match-urls').split(','),
      'about:preferences',
    ],
  });
}
