/* 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/. */

/** @import { OpenedConnection } from "resource://gre/modules/Sqlite.sys.mjs" */

import { Sqlite } from "resource://gre/modules/Sqlite.sys.mjs";

/**
 * @param {string} url
 *   The canonical URL of a tab note to look up
 */
const GET_NOTE_BY_URL = `
SELECT
  id,
  canonical_url,
  created,
  note_text
FROM tabnotes
WHERE
  canonical_url = :url
`;

/**
 * @param {string} url
 *   The canonical URL to associate the new tab note
 * @param {string} note
 *   The sanitized text for a tab note
 */
const CREATE_NOTE = `
INSERT INTO tabnotes
  (canonical_url, created, note_text)
VALUES
  (:url, unixepoch("now"), :note)
RETURNING
  id, canonical_url, created, note_text

`;

/**
 * @param {string} url
 *   The canonical URL for the existing tab note
 * @param {string} note
 *   The sanitized text for a tab note
 */
const UPDATE_NOTE = `
UPDATE
  tabnotes
SET
  note_text = :note
WHERE
  canonical_url = :url
RETURNING
  id, canonical_url, created, note_text
`;

/**
 * @param {string} url
 *   The canonical URL of a tab note to delete
 */
const DELETE_NOTE = `
DELETE FROM
  tabnotes
WHERE
  canonical_url = :url
RETURNING
  id, canonical_url, created, note_text
`;

const GET_NOTE_COUNT = `
SELECT COUNT(*) FROM tabnotes
`;

/**
 * Provides the CRUD interface for tab notes.
 */
export class TabNotesStorage {
  DATABASE_FILE_NAME = Object.freeze("tabnotes.sqlite");
  TELEMETRY_SOURCE = Object.freeze({
    TAB_CONTEXT_MENU: "context_menu",
    TAB_HOVER_PREVIEW_PANEL: "hover_menu",
  });

  /**
   * In-flight or completed init Promise. Concurrent callers to `init()` share
   * this Promise so that only one `Sqlite.openConnection()` is ever started.
   *
   * @type {Promise<void>|undefined}
   */
  #initPromise;

  /**
   * Single instance of SQLite connection to tabnotes.sqlite. Do not use this
   * directly because it could be undefined; instead, `await this.#connection`
   * to ensure that you always get an initialized connection.
   *
   * @type {OpenedConnection|undefined}
   */
  #databaseConnection;

  /**
   * Reference to the shutdown blocker function, used to remove the blocker
   * when deinit() is called.
   *
   * @see {Sqlite.shutdown}
   * @type {Function|undefined}
   */
  #shutdownBlocker;

  /** @type {Promise<OpenedConnection>} */
  get #connection() {
    return this.init().then(() => this.#databaseConnection);
  }

  /**
   * @param {object} [options={}]
   * @param {string} [options.basePath=PathUtils.profileDir]
   *   Base file path to a folder where the database file should live.
   *   Defaults to the current profile's root directory.
   * @returns {Promise<void>}
   *   After this Promise completes, TabNotes API functions will work.
   */
  init(options) {
    if (this.#initPromise) {
      return this.#initPromise;
    }

    const basePath = options?.basePath ?? PathUtils.profileDir;
    this.dbPath = PathUtils.join(basePath, this.DATABASE_FILE_NAME);
    this.#initPromise = Sqlite.openConnection({
      path: this.dbPath,
    }).then(async connection => {
      this.#databaseConnection = connection;
      this.#shutdownBlocker = () => this.deinit();
      Sqlite.shutdown.addBlocker(
        "Closing tabnotes database",
        this.#shutdownBlocker
      );

      await connection.execute("PRAGMA journal_mode = WAL");
      await connection.execute("PRAGMA wal_autocheckpoint = 16");

      let currentVersion = await connection.getSchemaVersion();

      if (currentVersion == 1) {
        // tabnotes schema is up to date
        return;
      }

      if (currentVersion == 0) {
        // version 0: create `tabnotes` table
        await connection.executeTransaction(async () => {
          await connection.execute(`
          CREATE TABLE IF NOT EXISTS "tabnotes" (
            id            INTEGER PRIMARY KEY,
            canonical_url TEXT NOT NULL,
            created       INTEGER NOT NULL,
            note_text     TEXT NOT NULL
          );`);
          await connection.setSchemaVersion(1);
        });
      }
    });

    return this.#initPromise;
  }

  /**
   * @returns {Promise<void>}
   */
  deinit() {
    if (this.#shutdownBlocker) {
      Sqlite.shutdown.removeBlocker(this.#shutdownBlocker);
      this.#shutdownBlocker = null;
    }
    if (this.#databaseConnection) {
      return this.#databaseConnection.close().then(() => {
        this.#databaseConnection = null;
        this.#initPromise = undefined;
      });
    }
    return Promise.resolve();
  }

  /**
   * @param {MozTabbrowserTab} tab
   * @returns {boolean}
   */
  isEligible(tab) {
    if (tab?.canonicalUrl && URL.canParse(tab.canonicalUrl)) {
      return true;
    }
    return false;
  }

  /**
   * Retrieve a note for a tab, if it exists.
   *
   * @param {MozTabbrowserTab} tab
   *   The tab to check for a note
   * @returns {Promise<TabNoteRecord|undefined>}
   */
  async get(tab) {
    if (!this.isEligible(tab)) {
      return undefined;
    }
    const connection = await this.#connection;
    const results = await connection.executeCached(GET_NOTE_BY_URL, {
      url: tab.canonicalUrl,
    });
    if (!results?.length) {
      return undefined;
    }
    const [result] = results;
    const record = this.#mapDbRowToRecord(result);
    return record;
  }

  /**
   * Set a note for a tab.
   *
   * @param {MozTabbrowserTab} tab
   *   The tab that the note should be associated with
   * @param {string} note
   *   The note itself
   * @param {object} [options]
   * @param {TabNoteTelemetrySource} [options.telemetrySource]
   *   The UI surface that requested to set a note.
   * @returns {Promise<TabNoteRecord>}
   *   The actual note that was set after sanitization
   * @throws {RangeError}
   *   if `tab` is not eligible for a tab note or `note` is empty
   */
  async set(tab, note, options = {}) {
    if (!this.isEligible(tab)) {
      throw new RangeError("Tab notes must be associated to an eligible tab");
    }
    if (!note) {
      throw new RangeError("Tab note text must be provided");
    }

    let existingNote = await this.get(tab);
    let sanitized = this.#sanitizeInput(note);

    if (existingNote && existingNote.text == sanitized) {
      return existingNote;
    }

    const connection = await this.#connection;
    return connection.executeTransaction(async () => {
      if (!existingNote) {
        const insertResult = await connection.executeCached(CREATE_NOTE, {
          url: tab.canonicalUrl,
          note: sanitized,
        });

        const insertedRecord = this.#mapDbRowToRecord(insertResult[0]);
        tab.dispatchEvent(
          new CustomEvent("TabNote:Created", {
            bubbles: true,
            detail: {
              note: insertedRecord,
              telemetrySource: options.telemetrySource,
            },
          })
        );
        return insertedRecord;
      }

      const updateResult = await connection.executeCached(UPDATE_NOTE, {
        url: tab.canonicalUrl,
        note: sanitized,
      });

      const updatedRecord = this.#mapDbRowToRecord(updateResult[0]);
      tab.dispatchEvent(
        new CustomEvent("TabNote:Edited", {
          bubbles: true,
          detail: {
            note: updatedRecord,
            telemetrySource: options.telemetrySource,
          },
        })
      );
      return updatedRecord;
    });
  }

  /**
   * Delete a note for a tab.
   *
   * @param {MozTabbrowserTab} tab
   *   The tab that has a note
   * @param {object} [options]
   * @param {TabNoteTelemetrySource} [options.telemetrySource]
   *   The UI surface that requested to delete a note.
   * @returns {Promise<boolean>}
   *   True if there was a note and it was deleted; false otherwise
   */
  async delete(tab, options = {}) {
    const connection = await this.#connection;
    /** @type {mozIStorageRow[]} */
    const deleteResult = await connection.executeCached(DELETE_NOTE, {
      url: tab.canonicalUrl,
    });

    if (deleteResult?.length > 0) {
      const deletedRecord = this.#mapDbRowToRecord(deleteResult[0]);
      tab.dispatchEvent(
        new CustomEvent("TabNote:Removed", {
          bubbles: true,
          detail: {
            note: deletedRecord,
            telemetrySource: options.telemetrySource,
          },
        })
      );
      return true;
    }

    return false;
  }

  /**
   * Check if a tab has a note.
   *
   * @param {MozTabbrowserTab} tab
   *   The tab to check for a tab note
   * @returns {Promise<boolean>}
   *   True if a note is associated with this URL; false otherwise
   */
  async has(tab) {
    const record = await this.get(tab);
    return record !== undefined;
  }

  /**
   * @returns {Promise<number>}
   *   The number of stored tab notes. Returns 0 if there was a problem
   *   retrieving the real count.
   */
  async count() {
    try {
      const connection = await this.#connection;
      /** @type {mozIStorageRow[]} */
      const countResult = await connection.executeCached(GET_NOTE_COUNT);
      if (countResult?.length == 1) {
        return countResult[0].getDouble(0);
      }
    } catch {}
    return 0;
  }

  /**
   * Clear all notes for all URLs.
   *
   * @returns {Promise<void>}
   */
  async reset() {
    const connection = await this.#connection;
    return connection.execute(`
      DELETE FROM "tabnotes"`);
  }

  /**
   * Given user-supplied note text, returns sanitized note text.
   *
   * @param {string} value
   * @returns {string}
   */
  #sanitizeInput(value) {
    return value.slice(0, 1000);
  }

  /**
   * @param {mozIStorageRow} row
   *   Row returned with the following data shape:
   *   [id: number, canonical_url: string, created: number, note_text: string]
   * @returns {TabNoteRecord}
   */
  #mapDbRowToRecord(row) {
    return {
      id: row.getDouble(0),
      canonicalUrl: row.getString(1),
      created: Temporal.Instant.fromEpochMilliseconds(row.getDouble(2) * 1000),
      text: row.getString(3),
    };
  }
}

// Singleton object accessible from all windows
export const TabNotes = new TabNotesStorage();
