import { Timestamp } from '@actual-app/crdt';

import * as connection from '../platform/server/connection';
import { getIn } from '../shared/util';

import { withMutatorContext, getMutatorContext } from './mutators';
import { Message, sendMessages } from './sync';

// A marker always sits as the first entry to simplify logic
type MarkerMessage = { type: 'marker'; meta?: unknown };
type MessagesMessage = {
  type: 'messages';
  messages: Message[];
  meta?: unknown;
  oldData;
  undoTag;
};
let MESSAGE_HISTORY: Array<MarkerMessage | MessagesMessage> = [
  { type: 'marker' },
];
let CURSOR = 0;
let HISTORY_SIZE = 20;

export type UndoState = {
  messages: Message[];
  meta?: unknown;
  tables: string[];
  undoTag: string;
};

function trimHistory() {
  MESSAGE_HISTORY = MESSAGE_HISTORY.slice(0, CURSOR + 1);

  let markers = MESSAGE_HISTORY.filter(item => item.type === 'marker');
  if (markers.length > HISTORY_SIZE) {
    let slice = markers.slice(-HISTORY_SIZE);
    let cutoff = MESSAGE_HISTORY.indexOf(slice[0]);
    MESSAGE_HISTORY = MESSAGE_HISTORY.slice(cutoff);
    CURSOR = MESSAGE_HISTORY.length - 1;
  }
}

export function appendMessages(messages, oldData) {
  let context = getMutatorContext();

  if (context.undoListening && messages.length > 0) {
    trimHistory();

    let { undoTag } = context;

    MESSAGE_HISTORY.push({
      type: 'messages',
      messages,
      oldData,
      undoTag,
    });
    CURSOR++;
  }
}

export function clearUndo() {
  MESSAGE_HISTORY = [{ type: 'marker' }];
  CURSOR = 0;
}

export function withUndo<T>(
  func: () => Promise<T>,
  meta?: unknown,
): Promise<T> {
  let context = getMutatorContext();
  if (context.undoDisabled || context.undoListening) {
    return func();
  }

  MESSAGE_HISTORY = MESSAGE_HISTORY.slice(0, CURSOR + 1);

  let marker: MarkerMessage = { type: 'marker', meta };

  if (MESSAGE_HISTORY[MESSAGE_HISTORY.length - 1].type === 'marker') {
    MESSAGE_HISTORY[MESSAGE_HISTORY.length - 1] = marker;
  } else {
    MESSAGE_HISTORY.push(marker);
    CURSOR++;
  }

  return withMutatorContext(
    { undoListening: true, undoTag: context.undoTag },
    func,
  );
}

// for some reason `void` is not inferred properly without this overload
export function undoable<Args extends unknown[]>(
  func: (...args: Args) => Promise<void>,
): (...args: Args) => Promise<void>;
export function undoable<
  Args extends unknown[],
  Return extends Promise<unknown>,
>(func: (...args: Args) => Return): (...args: Args) => Return;
export function undoable(func: (...args: unknown[]) => Promise<unknown>) {
  return (...args: unknown[]) => {
    return withUndo(() => {
      return func(...args);
    });
  };
}

async function applyUndoAction(messages, meta, undoTag) {
  await withMutatorContext({ undoListening: false }, () => {
    return sendMessages(
      messages.map(msg => ({ ...msg, timestamp: Timestamp.send() })),
    );
  });

  const tables = messages.reduce((acc, message) => {
    if (!acc.includes(message.dataset)) {
      acc.push(message.dataset);
    }
    return acc;
  }, []);

  connection.send('undo-event', {
    messages,
    tables,
    meta,
    undoTag,
  });
}

export async function undo() {
  let end = CURSOR;
  CURSOR = Math.max(CURSOR - 1, 0);

  // Walk back to the nearest marker
  while (CURSOR > 0 && MESSAGE_HISTORY[CURSOR].type !== 'marker') {
    CURSOR--;
  }

  let meta = MESSAGE_HISTORY[CURSOR].meta;
  let start = Math.max(CURSOR, 0);
  let entries = MESSAGE_HISTORY.slice(start, end + 1).filter(
    (entry): entry is MessagesMessage => entry.type === 'messages',
  );

  if (entries.length > 0) {
    let toApply = entries
      .reduce((acc, entry) => {
        return acc.concat(
          entry.messages
            .map(message => undoMessage(message, entry.oldData))
            .filter(x => x),
        );
      }, [])
      .reverse();

    await applyUndoAction(toApply, meta, entries[0].undoTag);
  }
}

function undoMessage(message, oldData) {
  let oldItem = getIn(oldData, [message.dataset, message.row]);
  if (oldItem) {
    let column = message.column;
    if (message.dataset === 'spreadsheet_cells') {
      // The spreadsheet messages use the `expr` column, but only as a
      // placeholder. We actually want to read the `cachedValue` prop
      // from the old item.
      column = 'cachedValue';
    }

    return { ...message, value: oldItem[column] };
  } else {
    if (message.dataset === 'spreadsheet_cells') {
      if (message.column === 'expr') {
        return { ...message, value: null };
      }
      return message;
    } else if (
      // The mapping fields aren't ever deleted... this should be
      // harmless since all they are is meta information. Maybe we
      // should fix this though.
      message.dataset !== 'category_mapping' &&
      message.dataset !== 'payee_mapping'
    ) {
      if (
        message.dataset === 'zero_budget_months' ||
        message.dataset === 'zero_budgets' ||
        message.dataset === 'reflect_budgets'
      ) {
        // Only these fields are reversable
        if (['buffered', 'amount', 'carryover'].includes(message.column)) {
          return { ...message, value: 0 };
        }
        return null;
      } else if (message.dataset === 'notes') {
        return { ...message, value: null };
      }

      return { ...message, column: 'tombstone', value: 1 };
    }
  }
  return null;
}

export async function redo() {
  let meta =
    MESSAGE_HISTORY[CURSOR].type === 'marker'
      ? MESSAGE_HISTORY[CURSOR].meta
      : null;

  let start = CURSOR;
  CURSOR = Math.min(CURSOR + 1, MESSAGE_HISTORY.length - 1);

  // Walk forward to the nearest marker
  while (
    CURSOR < MESSAGE_HISTORY.length - 1 &&
    MESSAGE_HISTORY[CURSOR].type !== 'marker'
  ) {
    CURSOR++;
  }

  let end = CURSOR;
  let entries = MESSAGE_HISTORY.slice(start + 1, end + 1).filter(
    (entry): entry is MessagesMessage => entry.type === 'messages',
  );

  if (entries.length > 0) {
    let toApply = entries.reduce((acc, entry) => {
      return acc
        .concat(entry.messages)
        .concat(redoResurrections(entry.messages, entry.oldData));
    }, []);

    await applyUndoAction(toApply, meta, entries[entries.length - 1].undoTag);
  }
}

function redoResurrections(messages, oldData): Message[] {
  let resurrect = new Set<string>();

  messages.forEach(message => {
    // If any of the ids didn't exist before, we need to "resurrect"
    // them by resetting their tombstones to 0
    let oldItem = getIn(oldData, [message.dataset, message.row]);
    if (
      !oldItem &&
      ![
        'zero_budget_months',
        'zero_budgets',
        'reflect_budgets',
        'notes',
        'category_mapping',
        'payee_mapping',
      ].includes(message.dataset)
    ) {
      resurrect.add(message.dataset + '.' + message.row);
    }
  });

  return [...resurrect].map(desc => {
    let [table, row] = desc.split('.');
    return {
      dataset: table,
      row,
      column: 'tombstone',
      value: 0,
      timestamp: Timestamp.send(),
    };
  });
}