import { getDatabase, IInputNote, IOutputNote } from "./schema.js";
import type { Transaction } from "dexie";

import { logWebStoreError, uint8ArrayToBase64 } from "./utils.js";

export async function getOutputNotes(dbId: string, states: Uint8Array) {
  try {
    const db = getDatabase(dbId);
    let notes =
      states.length == 0
        ? await db.outputNotes.toArray()
        : await db.outputNotes
            .where("stateDiscriminant")
            .anyOf(states)
            .toArray();

    return await processOutputNotes(notes);
  } catch (err) {
    logWebStoreError(err, "Failed to get output notes");
  }
}

export async function getInputNotes(dbId: string, states: Uint8Array) {
  try {
    const db = getDatabase(dbId);
    let notes;

    if (states.length === 0) {
      notes = await db.inputNotes.toArray();
    } else {
      notes = await db.inputNotes
        .where("stateDiscriminant")
        .anyOf(states)
        .toArray();
    }

    return await processInputNotes(dbId, notes);
  } catch (err) {
    logWebStoreError(err, "Failed to get input notes");
  }
}

export async function getInputNotesFromIds(dbId: string, noteIds: string[]) {
  try {
    const db = getDatabase(dbId);
    let notes = await db.inputNotes.where("noteId").anyOf(noteIds).toArray();
    return await processInputNotes(dbId, notes);
  } catch (err) {
    logWebStoreError(err, "Failed to get input notes from IDs");
  }
}

export async function getInputNotesFromNullifiers(
  dbId: string,
  nullifiers: string[]
) {
  try {
    const db = getDatabase(dbId);
    let notes = await db.inputNotes
      .where("nullifier")
      .anyOf(nullifiers)
      .toArray();
    return await processInputNotes(dbId, notes);
  } catch (err) {
    logWebStoreError(err, "Failed to get input notes from nullifiers");
  }
}

export async function getOutputNotesFromNullifiers(
  dbId: string,
  nullifiers: string[]
) {
  try {
    const db = getDatabase(dbId);
    let notes = await db.outputNotes
      .where("nullifier")
      .anyOf(nullifiers)
      .toArray();
    return await processOutputNotes(notes);
  } catch (err) {
    logWebStoreError(err, "Failed to get output notes from nullifiers");
  }
}

export async function getInputNotesFromDetailsCommitments(
  dbId: string,
  detailsCommitments: string[]
) {
  try {
    const db = getDatabase(dbId);
    let notes = await db.inputNotes
      .where("detailsCommitment")
      .anyOf(detailsCommitments)
      .toArray();
    return await processInputNotes(dbId, notes);
  } catch (err) {
    logWebStoreError(err, "Failed to get input notes from details commitments");
  }
}

export async function getOutputNotesFromDetailsCommitments(
  dbId: string,
  detailsCommitments: string[]
) {
  try {
    const db = getDatabase(dbId);
    let notes = await db.outputNotes
      .where("detailsCommitment")
      .anyOf(detailsCommitments)
      .toArray();
    return await processOutputNotes(notes);
  } catch (err) {
    logWebStoreError(
      err,
      "Failed to get output notes from details commitments"
    );
  }
}

export async function getOutputNotesFromIds(dbId: string, noteIds: string[]) {
  try {
    const db = getDatabase(dbId);
    let notes = await db.outputNotes.where("noteId").anyOf(noteIds).toArray();
    return await processOutputNotes(notes);
  } catch (err) {
    logWebStoreError(err, "Failed to get output notes from IDs");
  }
}

export async function getUnspentInputNoteNullifiers(dbId: string) {
  try {
    const db = getDatabase(dbId);
    const notes = await db.inputNotes
      .where("stateDiscriminant")
      .anyOf([2, 4, 5])
      .toArray();
    return notes
      .map((note) => note.nullifier)
      .filter((nullifier): nullifier is string => nullifier != null);
  } catch (err) {
    logWebStoreError(err, "Failed to get unspent input note nullifiers");
  }
}

export async function getNoteScript(dbId: string, scriptRoot: string) {
  try {
    const db = getDatabase(dbId);
    const noteScript = await db.notesScripts
      .where("scriptRoot")
      .equals(scriptRoot)
      .first();
    return noteScript;
  } catch (err) {
    logWebStoreError(err, "Failed to get note script from root");
  }
}

export async function upsertInputNote(
  dbId: string,
  detailsCommitment: string,
  noteId: string | undefined,
  assets: Uint8Array,
  attachments: Uint8Array,
  serialNumber: Uint8Array,
  inputs: Uint8Array,
  scriptRoot: string,
  serializedNoteScript: Uint8Array,
  nullifier: string | undefined,
  serializedCreatedAt: string,
  stateDiscriminant: number,
  state: Uint8Array,
  consumedBlockHeight?: number | null,
  consumedTxOrder?: number | null,
  consumerAccountId?: string | null,
  tx?: Transaction
) {
  const db = getDatabase(dbId);
  const doWork = async (t: Transaction) => {
    try {
      const data = {
        detailsCommitment,
        // noteId/nullifier are only known once the note's metadata is available.
        noteId: noteId ?? undefined,
        assets,
        attachments,
        serialNumber,
        inputs,
        scriptRoot,
        nullifier: nullifier ?? undefined,
        state,
        stateDiscriminant,
        serializedCreatedAt,
        // These fields are null for non-consumed notes.
        // Convert null -> undefined so Dexie omits them from compound indexes.
        consumedBlockHeight: consumedBlockHeight ?? undefined,
        consumedTxOrder: consumedTxOrder ?? undefined,
        consumerAccountId: consumerAccountId ?? undefined,
      };

      await t.inputNotes.put(data);

      const noteScriptData = {
        scriptRoot,
        serializedNoteScript,
      };

      await t.notesScripts.put(noteScriptData);
      /* v8 ignore next 3 — requires a mid-transaction Dexie write failure, not modelable with fake-indexeddb */
    } catch (error) {
      logWebStoreError(error, `Error inserting note: ${detailsCommitment}`);
      throw error;
    }
  };
  if (tx) return doWork(tx);
  return db.dexie.transaction("rw", db.inputNotes, db.notesScripts, doWork);
}

// Uses the [consumedBlockHeight+consumedTxOrder+noteId] compound index for cursor-based
// iteration.  When a consumerAccountId is provided the cursor path is used exclusively —
// only notes that are fully indexed (all three fields present) are returned.  When no
// consumer is specified a two-pass fallback is used: first the indexed notes (with a tx
// order), then the unindexed notes (null tx order), appended after so they sort last
// within the same block as described by the ordering contract.
export async function getInputNoteByOffset(
  dbId: string,
  states: Uint8Array,
  consumerAccountId: string | undefined,
  blockStart: number | undefined,
  blockEnd: number | undefined,
  offset: number
) {
  try {
    const db = getDatabase(dbId);

    // The compound index sorts by consumedBlockHeight, consumedTxOrder, noteId.
    // Rows without these fields are excluded by the index.
    const indexed = await db.inputNotes
      .orderBy("[consumedBlockHeight+consumedTxOrder+noteId]")
      .filter((n: IInputNote) => {
        if (states.length > 0 && !states.includes(n.stateDiscriminant))
          return false;
        if (n.consumerAccountId !== consumerAccountId) return false;
        if (blockStart != null && n.consumedBlockHeight! < blockStart)
          return false;
        if (blockEnd != null && n.consumedBlockHeight! > blockEnd) return false;
        return true;
      })
      .toArray();

    // When no consumer is specified, also collect notes that lack a tx order
    // (they do not appear in the compound index at all) and append them after
    // the ordered notes so they sort last.
    let unordered: IInputNote[] = [];
    if (consumerAccountId == null) {
      unordered = await db.inputNotes
        .filter((n: IInputNote) => {
          if (n.consumedTxOrder != null) return false; // already in indexed set
          if (states.length > 0 && !states.includes(n.stateDiscriminant))
            return false;
          if (n.consumerAccountId !== consumerAccountId) return false;
          if (
            blockStart != null &&
            (n.consumedBlockHeight == null ||
              n.consumedBlockHeight < blockStart)
          )
            return false;
          if (
            blockEnd != null &&
            (n.consumedBlockHeight == null || n.consumedBlockHeight > blockEnd)
          )
            return false;
          return true;
        })
        .sortBy("noteId");
    }

    const all = [...indexed, ...unordered];
    if (offset >= all.length) return [];
    return await processInputNotes(dbId, [all[offset]]);
  } catch (err) {
    logWebStoreError(err, "Failed to get input note by offset");
  }
}

export async function upsertOutputNote(
  dbId: string,
  detailsCommitment: string,
  noteId: string,
  assets: Uint8Array,
  attachments: Uint8Array,
  recipientDigest: string,
  metadata: Uint8Array,
  nullifier: string | undefined,
  expectedHeight: number,
  stateDiscriminant: number,
  state: Uint8Array,
  tx?: Transaction
) {
  const db = getDatabase(dbId);
  const doWork = async (t: Transaction) => {
    try {
      const data = {
        detailsCommitment,
        noteId,
        assets,
        attachments,
        recipientDigest,
        metadata,
        nullifier: nullifier ? nullifier : undefined,
        expectedHeight,
        stateDiscriminant,
        state,
      };

      await t.outputNotes.put(data);
      /* v8 ignore next 3 — requires a mid-transaction Dexie write failure, not modelable with fake-indexeddb */
    } catch (error) {
      logWebStoreError(error, `Error inserting note: ${detailsCommitment}`);
      throw error;
    }
  };
  if (tx) return doWork(tx);
  return db.dexie.transaction("rw", db.outputNotes, db.notesScripts, doWork);
}

async function processInputNotes(dbId: string, notes: IInputNote[]) {
  const db = getDatabase(dbId);
  return await Promise.all(
    notes.map(async (note) => {
      const assetsBase64 = uint8ArrayToBase64(note.assets);

      const serialNumberBase64 = uint8ArrayToBase64(note.serialNumber);

      const inputsBase64 = uint8ArrayToBase64(note.inputs);

      let serializedNoteScriptBase64: string | undefined = undefined;
      if (note.scriptRoot) {
        let record = await db.notesScripts.get(note.scriptRoot);
        if (record) {
          serializedNoteScriptBase64 = uint8ArrayToBase64(
            record.serializedNoteScript
          );
        }
      }

      const stateBase64 = uint8ArrayToBase64(note.state);

      const attachmentsBase64 = uint8ArrayToBase64(note.attachments);

      return {
        assets: assetsBase64,
        serialNumber: serialNumberBase64,
        inputs: inputsBase64,
        createdAt: note.serializedCreatedAt,
        serializedNoteScript: serializedNoteScriptBase64,
        state: stateBase64,
        attachments: attachmentsBase64,
      };
    })
  );
}

async function processOutputNotes(notes: IOutputNote[]) {
  return await Promise.all(
    notes.map((note) => {
      const assetsBase64 = uint8ArrayToBase64(note.assets);

      const metadataBase64 = uint8ArrayToBase64(note.metadata);

      const stateBase64 = uint8ArrayToBase64(note.state);

      const attachmentsBase64 = uint8ArrayToBase64(note.attachments);

      return {
        assets: assetsBase64,
        recipientDigest: note.recipientDigest,
        metadata: metadataBase64,
        expectedHeight: note.expectedHeight,
        state: stateBase64,
        attachments: attachmentsBase64,
      };
    })
  );
}

export async function upsertNoteScript(
  dbId: string,
  scriptRoot: string,
  serializedNoteScript: Uint8Array
) {
  const db = getDatabase(dbId);
  return db.dexie.transaction(
    "rw",
    db.outputNotes,
    db.notesScripts,
    async (tx) => {
      try {
        const noteScriptData = {
          scriptRoot,
          serializedNoteScript,
        };

        await tx.notesScripts.put(noteScriptData);
        /* v8 ignore next 3 — requires a mid-transaction Dexie write failure, not modelable with fake-indexeddb */
      } catch (error) {
        logWebStoreError(error, `Error inserting note script: ${scriptRoot}`);
      }
    }
  );
}
