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

"use strict";

const {
  UPDATE_SESSION_HISTORY,
  UPDATE_SESSION_HISTORY_ENTRY,
} = require("resource://devtools/client/application/src/constants.js");

function SessionHistory() {
  return {
    count: 0,
    current: 0,
    diagrams: [],
    entriesByKey: {},
  };
}

function sessionHistoryReducer(state = SessionHistory(), action) {
  switch (action.type) {
    case UPDATE_SESSION_HISTORY: {
      const { sessionHistory } = action;
      const entriesByKey = {};
      return Object.assign({}, state, {
        count: sessionHistory.count,
        current: sessionHistory.index,
        diagrams: createDiagrams(sessionHistory, entriesByKey),
        entriesByKey,
      });
    }
    case UPDATE_SESSION_HISTORY_ENTRY: {
      const { sessionHistoryEntry } = action;
      const entryKey = key(sessionHistoryEntry.ID);
      const entry = state.entriesByKey[entryKey];
      if (!entry) {
        return state;
      }
      return {
        ...state,
        entriesByKey: {
          ...state.entriesByKey,
          [entryKey]: {
            ...entry,
            // only title can be updated at the moment
            title: sessionHistoryEntry.title,
          },
        },
      };
    }
    default:
      return state;
  }
}

/**
 * Given a tree-like data structure, performs a pre-order (also known as tree
 * order) traversal of that tree. Since the elements can be anything, the
 * caller needs to supply a way to find how many children a specific node has
 * as well as a way to iterate children.
 *
 * @param {*} root Starting node of the traversal
 * @param {function(*): int} getChildCount Helper to find child count
 * @param {function(*, int): *} getChildAtIndex Helper to find child at index
 */
function* preOrderTraversal(root, getChildCount, getChildAtIndex) {
  const queue = [root];
  while (queue.length) {
    const entry = queue.shift();
    if (!entry) {
      continue;
    }

    for (let index = getChildCount(entry) - 1; index >= 0; --index) {
      queue.unshift(getChildAtIndex(entry, index));
    }

    yield entry;
  }
}

function key(k) {
  return k.toString();
}

class Diagram {
  #parent;
  #entry;
  #key;
  #rows = [];
  #lookup = new Map();

  static EMPTY = Symbol("Diagram.EMPTY");

  /**
   * Create a session history diagram from a particular entry.
   *
   * @param {object|null} parent Parent node
   * @param {object} entry Entry to create the diagram from
   */
  constructor(parent, entry) {
    this.#parent = parent;
    this.#entry = entry;
    this.#key = key(entry.docshellID);
  }

  get entry() {
    return this.#entry;
  }

  get key() {
    return this.#key;
  }

  get parent() {
    return this.#parent;
  }

  get rows() {
    return this.#rows;
  }

  lookup(id) {
    return this.#lookup.get(id);
  }

  /**
   * Append an entry to a row of the diagram.
   *
   * @param {object} entry New entry.
   * @param {int} index Index to append at.
   */
  addChild(entry, index) {
    const id = key(entry.docshellID);
    const row = this.#lookup.getOrInsertComputed(id, () => {
      const newRow = new Array(index).fill(Diagram.EMPTY);
      this.#rows.push({ id, newRow });
      return newRow;
    });

    row.push(entry);
  }
}

function createRows(sessionHistory, entriesByKey, start, end) {
  const lookup = new Map();

  const size = end - start;
  // We add a rootDiagram for convenience to be able to add top-level entries
  // to it. This diagram will never be rendered to the full diagram, and the
  // id will never be used in lookup.
  const rootDiagram = new Diagram(null, { docshellID: "fakeDocShellID" });
  const getChildCount = entry => entry.childCount;
  const getChildAt = (entry, index) => entry.GetChildAt(index);
  for (let index = start; index < end; index++) {
    const root = sessionHistory.getEntryAtIndex(index);
    rootDiagram.addChild(root, index - start);
    lookup.getOrInsertComputed(key(root.docshellID), () => {
      return new Diagram(rootDiagram, root);
    });

    for (const entry of preOrderTraversal(root, getChildCount, getChildAt)) {
      const parent = entry.parent;
      if (parent) {
        const diagram = lookup.get(key(parent.docshellID));
        diagram.addChild(entry, index - start);
        lookup.getOrInsertComputed(key(entry.docshellID), () => {
          return new Diagram(diagram, entry);
        });
      }
    }
  }

  return Array.from(
    preOrderTraversal(
      rootDiagram,
      entry => entry.rows.length,
      ({ rows }, index) => lookup.get(rows[index].id)
    ),
    diagram => {
      if (!diagram.parent) {
        return [];
      }

      const row = [];
      const entries = diagram.parent.lookup(diagram.key);
      for (let count = entries.length; count > size; count--) {
        entries.push(Diagram.EMPTY);
      }
      while (entries.length < size) {
        entries.push(Diagram.EMPTY);
      }
      let previous = Diagram.EMPTY;
      let newEntry = null;
      while (entries.length) {
        const entry = entries.shift();

        if (entry !== Diagram.EMPTY && previous.ID != entry.ID) {
          const url = new URL(`${entry.URI.spec}`);
          newEntry = {
            age: 1,
            key: key(entry.ID),
            sameDocNav:
              previous !== Diagram.EMPTY && entry.sharesDocumentWith(previous),
          };
          const parent = entry.parent?.navigationId;
          const extra = {};
          if (parent) {
            extra.parent = parent;
          }
          const numChildren = getChildCount(entry);
          if (numChildren) {
            const children = new Array(numChildren);
            for (let index = 0; index < numChildren; ++index) {
              children[index] = getChildAt(entry, index)?.navigationId;
            }
            extra.children = children;
          }

          entriesByKey[key(entry.ID)] = {
            url,
            title: entry.title,
            name: entry.name,
            id: entry.navigationId,
            key: entry.navigationKey,
            bfcache: entry.isInBFCache,
            ...extra,
          };
          row.push(newEntry);
        } else if (!newEntry) {
          newEntry = { age: 1 };
          row.push(newEntry);
        } else if (entry !== Diagram.EMPTY || previous === Diagram.EMPTY) {
          newEntry.age++;
        } else {
          newEntry = { age: 1 };
          row.push(newEntry);
        }
        previous = entry;
      }

      return row;
    }
  ).filter(row => row.length);
}

function* sameDocuments(sessionHistory) {
  let previous = {
    sharesDocumentWith() {
      return true;
    },
  };
  let start = 0;
  const size = sessionHistory.count;
  for (let index = 0; index < size; index++) {
    const entry = sessionHistory.getEntryAtIndex(index);
    const sameDocument = previous.sharesDocumentWith(entry);
    previous = entry;
    if (sameDocument) {
      continue;
    }

    yield { start, end: index };
    start = index;
  }

  yield { start, end: size };
}

function createDiagrams(sessionHistory, entriesByKey) {
  const diagrams = [];
  for (const { start, end } of sameDocuments(sessionHistory)) {
    const rows = createRows(sessionHistory, entriesByKey, start, end);
    diagrams.push({ rows, start, end });
  }
  return diagrams;
}

module.exports = {
  SessionHistory,
  sessionHistoryReducer,
};
