/* 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 DevToolsUtils = require("devtools/shared/DevToolsUtils");
const promise = require("promise");
const dbginfo = new WeakMap();

// These functions implement search within the debugger. Since
// search in the debugger is different from other components,
// we can't use search.js CodeMirror addon. This is a slightly
// modified version of that addon. Depends on searchcursor.js.

function SearchState() {
  this.posFrom = this.posTo = this.query = null;
}

function getSearchState(cm) {
  return cm.state.search || (cm.state.search = new SearchState());
}

function getSearchCursor(cm, query, pos) {
  // If the query string is all lowercase, do a case insensitive search.
  return cm.getSearchCursor(query, pos,
    typeof query == "string" && query == query.toLowerCase());
}

/**
 * If there's a saved search, selects the next results.
 * Otherwise, creates a new search and selects the first
 * result.
 */
function doSearch(ctx, rev, query) {
  let { cm } = ctx;
  let state = getSearchState(cm);

  if (state.query) {
    searchNext(ctx, rev);
    return;
  }

  cm.operation(function () {
    if (state.query) {
      return;
    }

    state.query = query;
    state.posFrom = state.posTo = { line: 0, ch: 0 };
    searchNext(ctx, rev);
  });
}

/**
 * Selects the next result of a saved search.
 */
function searchNext(ctx, rev) {
  let { cm, ed } = ctx;
  cm.operation(function () {
    let state = getSearchState(cm);
    let cursor = getSearchCursor(cm, state.query,
                                 rev ? state.posFrom : state.posTo);

    if (!cursor.find(rev)) {
      cursor = getSearchCursor(cm, state.query, rev ?
        { line: cm.lastLine(), ch: null } : { line: cm.firstLine(), ch: 0 });
      if (!cursor.find(rev)) {
        return;
      }
    }

    ed.alignLine(cursor.from().line, "center");
    cm.setSelection(cursor.from(), cursor.to());
    state.posFrom = cursor.from();
    state.posTo = cursor.to();
  });
}

/**
 * Clears the currently saved search.
 */
function clearSearch(cm) {
  let state = getSearchState(cm);

  if (!state.query) {
    return;
  }

  state.query = null;
}

// Exported functions

/**
 * This function is called whenever Editor is extended with functions
 * from this module. See Editor.extend for more info.
 */
function initialize(ctx) {
  let { ed } = ctx;

  dbginfo.set(ed, {
    breakpoints: {},
    debugLocation: null
  });
}

/**
 * True if editor has a visual breakpoint at that line, false
 * otherwise.
 */
function hasBreakpoint(ctx, line) {
  let { cm } = ctx;
  // In some rare occasions CodeMirror might not be properly initialized yet, so
  // return an exceptional value in that case.
  if (cm.lineInfo(line) === null) {
    return null;
  }
  let markers = cm.lineInfo(line).wrapClass;

  return markers != null &&
         markers.includes("breakpoint");
}

/**
 * Adds a visual breakpoint for a specified line. Third
 * parameter 'cond' can hold any object.
 *
 * After adding a breakpoint, this function makes Editor to
 * emit a breakpointAdded event.
 */
function addBreakpoint(ctx, line, cond) {
  function _addBreakpoint() {
    let { ed, cm } = ctx;
    let meta = dbginfo.get(ed);
    let info = cm.lineInfo(line);

    // The line does not exist in the editor. This is harmless, the
    // architecture calling this assumes the editor will handle this
    // gracefully, and make sure breakpoints exist when they need to.
    if (!info) {
      return;
    }

    ed.addLineClass(line, "breakpoint");
    meta.breakpoints[line] = { condition: cond };

    // TODO(jwl): why is `info` null when breaking on page reload?
    info.handle.on("delete", function onDelete() {
      info.handle.off("delete", onDelete);
      meta.breakpoints[info.line] = null;
    });

    if (cond) {
      setBreakpointCondition(ctx, line);
    }
    ed.emit("breakpointAdded", line);
    deferred.resolve();
  }

  if (hasBreakpoint(ctx, line)) {
    return null;
  }

  let deferred = promise.defer();
  // If lineInfo() returns null, wait a tick to give the editor a chance to
  // initialize properly.
  if (ctx.cm.lineInfo(line) === null) {
    DevToolsUtils.executeSoon(() => _addBreakpoint());
  } else {
    _addBreakpoint();
  }
  return deferred.promise;
}

/**
 * Helps reset the debugger's breakpoint state
 * - removes the breakpoints in the editor
 * - cleares the debugger's breakpoint state
 *
 * Note, does not *actually* remove a source's breakpoints.
 * The canonical state is kept in the app state.
 *
 */
function removeBreakpoints(ctx) {
  let { ed, cm } = ctx;

  let meta = dbginfo.get(ed);
  if (meta.breakpoints != null) {
    meta.breakpoints = {};
  }

  cm.doc.iter((line) => {
    // The hasBreakpoint is a slow operation: checks the line type, whether cm
    // is initialized and creates several new objects. Inlining the line's
    // wrapClass property check directly.
    if (line.wrapClass == null || !line.wrapClass.includes("breakpoint")) {
      return;
    }
    removeBreakpoint(ctx, line);
  });
}

/**
 * Removes a visual breakpoint from a specified line and
 * makes Editor emit a breakpointRemoved event.
 */
function removeBreakpoint(ctx, line) {
  if (!hasBreakpoint(ctx, line)) {
    return;
  }

  let { ed, cm } = ctx;
  let meta = dbginfo.get(ed);
  let info = cm.lineInfo(line);

  meta.breakpoints[info.line] = null;
  ed.removeLineClass(info.line, "breakpoint");
  ed.removeLineClass(info.line, "conditional");
  ed.emit("breakpointRemoved", line);
}

function moveBreakpoint(ctx, fromLine, toLine) {
  let { ed } = ctx;

  ed.removeBreakpoint(fromLine);
  ed.addBreakpoint(toLine);
}

function setBreakpointCondition(ctx, line) {
  let { ed, cm } = ctx;
  let info = cm.lineInfo(line);

  // The line does not exist in the editor. This is harmless, the
  // architecture calling this assumes the editor will handle this
  // gracefully, and make sure breakpoints exist when they need to.
  if (!info) {
    return;
  }

  ed.addLineClass(line, "conditional");
}

function removeBreakpointCondition(ctx, line) {
  let { ed } = ctx;

  ed.removeLineClass(line, "conditional");
}

/**
 * Returns a list of all breakpoints in the current Editor.
 */
function getBreakpoints(ctx) {
  let { ed } = ctx;
  let meta = dbginfo.get(ed);

  return Object.keys(meta.breakpoints).reduce((acc, line) => {
    if (meta.breakpoints[line] != null) {
      acc.push({ line: line, condition: meta.breakpoints[line].condition });
    }
    return acc;
  }, []);
}

/**
 * Saves a debug location information and adds a visual anchor to
 * the breakpoints gutter. This is used by the debugger UI to
 * display the line on which the Debugger is currently paused.
 */
function setDebugLocation(ctx, line) {
  let { ed } = ctx;
  let meta = dbginfo.get(ed);

  clearDebugLocation(ctx);

  meta.debugLocation = line;
  ed.addLineClass(line, "debug-line");
}

/**
 * Returns a line number that corresponds to the current debug
 * location.
 */
function getDebugLocation(ctx) {
  let { ed } = ctx;
  let meta = dbginfo.get(ed);

  return meta.debugLocation;
}

/**
 * Clears the debug location. Clearing the debug location
 * also removes a visual anchor from the breakpoints gutter.
 */
function clearDebugLocation(ctx) {
  let { ed } = ctx;
  let meta = dbginfo.get(ed);

  if (meta.debugLocation != null) {
    ed.removeLineClass(meta.debugLocation, "debug-line");
    meta.debugLocation = null;
  }
}

/**
 * Starts a new search.
 */
function find(ctx, query) {
  clearSearch(ctx.cm);
  doSearch(ctx, false, query);
}

/**
 * Finds the next item based on the currently saved search.
 */
function findNext(ctx, query) {
  doSearch(ctx, false, query);
}

/**
 * Finds the previous item based on the currently saved search.
 */
function findPrev(ctx, query) {
  doSearch(ctx, true, query);
}

// Export functions

[
  initialize, hasBreakpoint, addBreakpoint, removeBreakpoint, moveBreakpoint,
  setBreakpointCondition, removeBreakpointCondition, getBreakpoints, removeBreakpoints,
  setDebugLocation, getDebugLocation, clearDebugLocation, find, findNext,
  findPrev
].forEach(func => {
  module.exports[func.name] = func;
});
