import { sanitizeUrl } from '@braintree/sanitize-url';
import dayjs from 'dayjs';
import dayjsIsoWeek from 'dayjs/plugin/isoWeek.js';
import dayjsCustomParseFormat from 'dayjs/plugin/customParseFormat.js';
import dayjsAdvancedFormat from 'dayjs/plugin/advancedFormat.js';
import { log } from '../../logger.js';
import { getConfig } from '../../diagram-api/diagramAPI.js';
import utils from '../../utils.js';

import {
  setAccTitle,
  getAccTitle,
  getAccDescription,
  setAccDescription,
  clear as commonClear,
  setDiagramTitle,
  getDiagramTitle,
} from '../common/commonDb.js';

dayjs.extend(dayjsIsoWeek);
dayjs.extend(dayjsCustomParseFormat);
dayjs.extend(dayjsAdvancedFormat);

const WEEKEND_START_DAY = { friday: 5, saturday: 6 };
let dateFormat = '';
let axisFormat = '';
let tickInterval = undefined;
let todayMarker = '';
let includes = [];
let excludes = [];
let links = new Map();
let sections = [];
let tasks = [];
let currentSection = '';
let displayMode = '';
const tags = ['active', 'done', 'crit', 'milestone'];
let funs = [];
let inclusiveEndDates = false;
let topAxis = false;
let weekday = 'sunday';
let weekend = 'saturday';

// The serial order of the task in the script
let lastOrder = 0;

export const clear = function () {
  sections = [];
  tasks = [];
  currentSection = '';
  funs = [];
  taskCnt = 0;
  lastTask = undefined;
  lastTaskID = undefined;
  rawTasks = [];
  dateFormat = '';
  axisFormat = '';
  displayMode = '';
  tickInterval = undefined;
  todayMarker = '';
  includes = [];
  excludes = [];
  inclusiveEndDates = false;
  topAxis = false;
  lastOrder = 0;
  links = new Map();
  commonClear();
  weekday = 'sunday';
  weekend = 'saturday';
};

export const setAxisFormat = function (txt) {
  axisFormat = txt;
};

export const getAxisFormat = function () {
  return axisFormat;
};

export const setTickInterval = function (txt) {
  tickInterval = txt;
};

export const getTickInterval = function () {
  return tickInterval;
};

export const setTodayMarker = function (txt) {
  todayMarker = txt;
};

export const getTodayMarker = function () {
  return todayMarker;
};

export const setDateFormat = function (txt) {
  dateFormat = txt;
};

export const enableInclusiveEndDates = function () {
  inclusiveEndDates = true;
};

export const endDatesAreInclusive = function () {
  return inclusiveEndDates;
};

export const enableTopAxis = function () {
  topAxis = true;
};

export const topAxisEnabled = function () {
  return topAxis;
};

export const setDisplayMode = function (txt) {
  displayMode = txt;
};

export const getDisplayMode = function () {
  return displayMode;
};

export const getDateFormat = function () {
  return dateFormat;
};

export const setIncludes = function (txt) {
  includes = txt.toLowerCase().split(/[\s,]+/);
};

export const getIncludes = function () {
  return includes;
};
export const setExcludes = function (txt) {
  excludes = txt.toLowerCase().split(/[\s,]+/);
};

export const getExcludes = function () {
  return excludes;
};

export const getLinks = function () {
  return links;
};

export const addSection = function (txt) {
  currentSection = txt;
  sections.push(txt);
};

export const getSections = function () {
  return sections;
};

export const getTasks = function () {
  let allItemsProcessed = compileTasks();
  const maxDepth = 10;
  let iterationCount = 0;
  while (!allItemsProcessed && iterationCount < maxDepth) {
    allItemsProcessed = compileTasks();
    iterationCount++;
  }

  tasks = rawTasks;

  return tasks;
};

export const isInvalidDate = function (date, dateFormat, excludes, includes) {
  if (includes.includes(date.format(dateFormat.trim()))) {
    return false;
  }
  if (
    excludes.includes('weekends') &&
    (date.isoWeekday() === WEEKEND_START_DAY[weekend] ||
      date.isoWeekday() === WEEKEND_START_DAY[weekend] + 1)
  ) {
    return true;
  }
  if (excludes.includes(date.format('dddd').toLowerCase())) {
    return true;
  }
  return excludes.includes(date.format(dateFormat.trim()));
};

export const setWeekday = function (txt) {
  weekday = txt;
};

export const getWeekday = function () {
  return weekday;
};

export const setWeekend = function (startDay) {
  weekend = startDay;
};

/**
 * TODO: fully document what this function does and what types it accepts
 *
 * @param {object} task - The task to check.
 * @param {string | Date} task.startTime - Might be a `Date` or a `string`.
 * TODO: is this always a Date?
 * @param {string | Date} task.endTime - Might be a `Date` or a `string`.
 * TODO: is this always a Date?
 * @param {string} dateFormat - Dayjs date format string.
 * @param {*} excludes
 * @param {*} includes
 */
const checkTaskDates = function (task, dateFormat, excludes, includes) {
  if (!excludes.length || task.manualEndTime) {
    return;
  }
  let startTime;
  if (task.startTime instanceof Date) {
    startTime = dayjs(task.startTime);
  } else {
    startTime = dayjs(task.startTime, dateFormat, true);
  }
  startTime = startTime.add(1, 'd');

  let originalEndTime;
  if (task.endTime instanceof Date) {
    originalEndTime = dayjs(task.endTime);
  } else {
    originalEndTime = dayjs(task.endTime, dateFormat, true);
  }
  const [fixedEndTime, renderEndTime] = fixTaskDates(
    startTime,
    originalEndTime,
    dateFormat,
    excludes,
    includes
  );
  task.endTime = fixedEndTime.toDate();
  task.renderEndTime = renderEndTime;
};

/**
 * TODO: what does this function do?
 *
 * @param {dayjs.Dayjs} startTime - The start time.
 * @param {dayjs.Dayjs} endTime - The original end time (will return a different end time if it's invalid).
 * @param {string} dateFormat - Dayjs date format string.
 * @param {*} excludes
 * @param {*} includes
 * @returns {[endTime: dayjs.Dayjs, renderEndTime: Date | null]} The new `endTime`, and the end time to render.
 * `renderEndTime` may be `null` if `startTime` is newer than `endTime`.
 */
const fixTaskDates = function (startTime, endTime, dateFormat, excludes, includes) {
  let invalid = false;
  let renderEndTime = null;
  while (startTime <= endTime) {
    if (!invalid) {
      renderEndTime = endTime.toDate();
    }
    invalid = isInvalidDate(startTime, dateFormat, excludes, includes);
    if (invalid) {
      endTime = endTime.add(1, 'd');
    }
    startTime = startTime.add(1, 'd');
  }
  return [endTime, renderEndTime];
};

const getStartDate = function (prevTime, dateFormat, str) {
  str = str.trim();

  // Test for after
  const afterRePattern = /^after\s+(?<ids>[\d\w- ]+)/;
  const afterStatement = afterRePattern.exec(str);

  if (afterStatement !== null) {
    // check all after ids and take the latest
    let latestTask = null;
    for (const id of afterStatement.groups.ids.split(' ')) {
      let task = findTaskById(id);
      if (task !== undefined && (!latestTask || task.endTime > latestTask.endTime)) {
        latestTask = task;
      }
    }

    if (latestTask) {
      return latestTask.endTime;
    }
    const today = new Date();
    today.setHours(0, 0, 0, 0);
    return today;
  }

  // Check for actual date set
  let mDate = dayjs(str, dateFormat.trim(), true);
  if (mDate.isValid()) {
    return mDate.toDate();
  } else {
    log.debug('Invalid date:' + str);
    log.debug('With date format:' + dateFormat.trim());
    const d = new Date(str);
    if (
      d === undefined ||
      isNaN(d.getTime()) ||
      // WebKit browsers can mis-parse invalid dates to be ridiculously
      // huge numbers, e.g. new Date('202304') gets parsed as January 1, 202304.
      // This can cause virtually infinite loops while rendering, so for the
      // purposes of Gantt charts we'll just treat any date beyond 10,000 AD/BC as
      // invalid.
      d.getFullYear() < -10000 ||
      d.getFullYear() > 10000
    ) {
      throw new Error('Invalid date:' + str);
    }
    return d;
  }
};

/**
 * Parse a string into the args for `dayjs.add()`.
 *
 * The string have to be compound by a value and a shorthand duration unit. For example `5d`
 * represents 5 days.
 *
 * Please be aware that 1 day may be 23 or 25 hours, if the user lives in an area
 * that has daylight savings time (or even 23.5/24.5 hours in Lord Howe Island!)
 *
 * Shorthand unit supported are:
 *
 * - `y` for years
 * - `M` for months
 * - `w` for weeks
 * - `d` for days
 * - `h` for hours
 * - `s` for seconds
 * - `ms` for milliseconds
 *
 * @param {string} str - A string representing the duration.
 * @returns {[value: number, unit: dayjs.ManipulateType]} Arguments to pass to `dayjs.add()`
 */
const parseDuration = function (str) {
  // cspell:disable-next-line
  const statement = /^(\d+(?:\.\d+)?)([Mdhmswy]|ms)$/.exec(str.trim());
  if (statement !== null) {
    return [Number.parseFloat(statement[1]), statement[2]];
  }
  // NaN means an invalid duration
  return [NaN, 'ms'];
};

const getEndDate = function (prevTime, dateFormat, str, inclusive = false) {
  str = str.trim();

  // test for until
  const untilRePattern = /^until\s+(?<ids>[\d\w- ]+)/;
  const untilStatement = untilRePattern.exec(str);

  if (untilStatement !== null) {
    // check all until ids and take the earliest
    let earliestTask = null;
    for (const id of untilStatement.groups.ids.split(' ')) {
      let task = findTaskById(id);
      if (task !== undefined && (!earliestTask || task.startTime < earliestTask.startTime)) {
        earliestTask = task;
      }
    }

    if (earliestTask) {
      return earliestTask.startTime;
    }
    const today = new Date();
    today.setHours(0, 0, 0, 0);
    return today;
  }

  // check for actual date
  let parsedDate = dayjs(str, dateFormat.trim(), true);
  if (parsedDate.isValid()) {
    if (inclusive) {
      parsedDate = parsedDate.add(1, 'd');
    }
    return parsedDate.toDate();
  }

  let endTime = dayjs(prevTime);
  const [durationValue, durationUnit] = parseDuration(str);
  if (!Number.isNaN(durationValue)) {
    const newEndTime = endTime.add(durationValue, durationUnit);
    if (newEndTime.isValid()) {
      endTime = newEndTime;
    }
  }
  return endTime.toDate();
};

let taskCnt = 0;
const parseId = function (idStr) {
  if (idStr === undefined) {
    taskCnt = taskCnt + 1;
    return 'task' + taskCnt;
  }
  return idStr;
};
// id, startDate, endDate
// id, startDate, length
// id, after x, endDate
// id, after x, length
// startDate, endDate
// startDate, length
// after x, endDate
// after x, length
// endDate
// length

const compileData = function (prevTask, dataStr) {
  let ds;

  if (dataStr.substr(0, 1) === ':') {
    ds = dataStr.substr(1, dataStr.length);
  } else {
    ds = dataStr;
  }

  const data = ds.split(',');

  const task = {};

  // Get tags like active, done, crit and milestone
  getTaskTags(data, task, tags);

  for (let i = 0; i < data.length; i++) {
    data[i] = data[i].trim();
  }

  let endTimeData = '';
  switch (data.length) {
    case 1:
      task.id = parseId();
      task.startTime = prevTask.endTime;
      endTimeData = data[0];
      break;
    case 2:
      task.id = parseId();
      task.startTime = getStartDate(undefined, dateFormat, data[0]);
      endTimeData = data[1];
      break;
    case 3:
      task.id = parseId(data[0]);
      task.startTime = getStartDate(undefined, dateFormat, data[1]);
      endTimeData = data[2];
      break;
    default:
  }

  if (endTimeData) {
    task.endTime = getEndDate(task.startTime, dateFormat, endTimeData, inclusiveEndDates);
    task.manualEndTime = dayjs(endTimeData, 'YYYY-MM-DD', true).isValid();
    checkTaskDates(task, dateFormat, excludes, includes);
  }

  return task;
};

const parseData = function (prevTaskId, dataStr) {
  let ds;
  if (dataStr.substr(0, 1) === ':') {
    ds = dataStr.substr(1, dataStr.length);
  } else {
    ds = dataStr;
  }

  const data = ds.split(',');

  const task = {};

  // Get tags like active, done, crit and milestone
  getTaskTags(data, task, tags);

  for (let i = 0; i < data.length; i++) {
    data[i] = data[i].trim();
  }

  switch (data.length) {
    case 1:
      task.id = parseId();
      task.startTime = {
        type: 'prevTaskEnd',
        id: prevTaskId,
      };
      task.endTime = {
        data: data[0],
      };
      break;
    case 2:
      task.id = parseId();
      task.startTime = {
        type: 'getStartDate',
        startData: data[0],
      };
      task.endTime = {
        data: data[1],
      };
      break;
    case 3:
      task.id = parseId(data[0]);
      task.startTime = {
        type: 'getStartDate',
        startData: data[1],
      };
      task.endTime = {
        data: data[2],
      };
      break;
    default:
  }

  return task;
};

let lastTask;
let lastTaskID;
let rawTasks = [];
const taskDb = {};
export const addTask = function (descr, data) {
  const rawTask = {
    section: currentSection,
    type: currentSection,
    processed: false,
    manualEndTime: false,
    renderEndTime: null,
    raw: { data: data },
    task: descr,
    classes: [],
  };
  const taskInfo = parseData(lastTaskID, data);
  rawTask.raw.startTime = taskInfo.startTime;
  rawTask.raw.endTime = taskInfo.endTime;
  rawTask.id = taskInfo.id;
  rawTask.prevTaskId = lastTaskID;
  rawTask.active = taskInfo.active;
  rawTask.done = taskInfo.done;
  rawTask.crit = taskInfo.crit;
  rawTask.milestone = taskInfo.milestone;
  rawTask.order = lastOrder;

  lastOrder++;

  const pos = rawTasks.push(rawTask);

  lastTaskID = rawTask.id;
  // Store cross ref
  taskDb[rawTask.id] = pos - 1;
};

export const findTaskById = function (id) {
  const pos = taskDb[id];
  return rawTasks[pos];
};

export const addTaskOrg = function (descr, data) {
  const newTask = {
    section: currentSection,
    type: currentSection,
    description: descr,
    task: descr,
    classes: [],
  };
  const taskInfo = compileData(lastTask, data);
  newTask.startTime = taskInfo.startTime;
  newTask.endTime = taskInfo.endTime;
  newTask.id = taskInfo.id;
  newTask.active = taskInfo.active;
  newTask.done = taskInfo.done;
  newTask.crit = taskInfo.crit;
  newTask.milestone = taskInfo.milestone;
  lastTask = newTask;
  tasks.push(newTask);
};

const compileTasks = function () {
  const compileTask = function (pos) {
    const task = rawTasks[pos];
    let startTime = '';
    switch (rawTasks[pos].raw.startTime.type) {
      case 'prevTaskEnd': {
        const prevTask = findTaskById(task.prevTaskId);
        task.startTime = prevTask.endTime;
        break;
      }
      case 'getStartDate':
        startTime = getStartDate(undefined, dateFormat, rawTasks[pos].raw.startTime.startData);
        if (startTime) {
          rawTasks[pos].startTime = startTime;
        }
        break;
    }

    if (rawTasks[pos].startTime) {
      rawTasks[pos].endTime = getEndDate(
        rawTasks[pos].startTime,
        dateFormat,
        rawTasks[pos].raw.endTime.data,
        inclusiveEndDates
      );
      if (rawTasks[pos].endTime) {
        rawTasks[pos].processed = true;
        rawTasks[pos].manualEndTime = dayjs(
          rawTasks[pos].raw.endTime.data,
          'YYYY-MM-DD',
          true
        ).isValid();
        checkTaskDates(rawTasks[pos], dateFormat, excludes, includes);
      }
    }

    return rawTasks[pos].processed;
  };

  let allProcessed = true;
  for (const [i, rawTask] of rawTasks.entries()) {
    compileTask(i);

    allProcessed = allProcessed && rawTask.processed;
  }
  return allProcessed;
};

/**
 * Called by parser when a link is found. Adds the URL to the vertex data.
 *
 * @param ids Comma separated list of ids
 * @param _linkStr URL to create a link for
 */
export const setLink = function (ids, _linkStr) {
  let linkStr = _linkStr;
  if (getConfig().securityLevel !== 'loose') {
    linkStr = sanitizeUrl(_linkStr);
  }
  ids.split(',').forEach(function (id) {
    let rawTask = findTaskById(id);
    if (rawTask !== undefined) {
      pushFun(id, () => {
        window.open(linkStr, '_self');
      });
      links.set(id, linkStr);
    }
  });
  setClass(ids, 'clickable');
};

/**
 * Called by parser when a special node is found, e.g. a clickable element.
 *
 * @param ids Comma separated list of ids
 * @param className Class to add
 */
export const setClass = function (ids, className) {
  ids.split(',').forEach(function (id) {
    let rawTask = findTaskById(id);
    if (rawTask !== undefined) {
      rawTask.classes.push(className);
    }
  });
};

const setClickFun = function (id, functionName, functionArgs) {
  if (getConfig().securityLevel !== 'loose') {
    return;
  }
  if (functionName === undefined) {
    return;
  }

  let argList = [];
  if (typeof functionArgs === 'string') {
    /* Splits functionArgs by ',', ignoring all ',' in double quoted strings */
    argList = functionArgs.split(/,(?=(?:(?:[^"]*"){2})*[^"]*$)/);
    for (let i = 0; i < argList.length; i++) {
      let item = argList[i].trim();
      /* Removes all double quotes at the start and end of an argument */
      /* This preserves all starting and ending whitespace inside */
      if (item.startsWith('"') && item.endsWith('"')) {
        item = item.substr(1, item.length - 2);
      }
      argList[i] = item;
    }
  }

  /* if no arguments passed into callback, default to passing in id */
  if (argList.length === 0) {
    argList.push(id);
  }

  let rawTask = findTaskById(id);
  if (rawTask !== undefined) {
    pushFun(id, () => {
      utils.runFunc(functionName, ...argList);
    });
  }
};

/**
 * The callbackFunction is executed in a click event bound to the task with the specified id or the
 * task's assigned text
 *
 * @param id The task's id
 * @param callbackFunction A function to be executed when clicked on the task or the task's text
 */
const pushFun = function (id, callbackFunction) {
  funs.push(
    function () {
      // const elem = d3.select(element).select(`[id="${id}"]`)
      const elem = document.querySelector(`[id="${id}"]`);
      if (elem !== null) {
        elem.addEventListener('click', function () {
          callbackFunction();
        });
      }
    },
    function () {
      // const elem = d3.select(element).select(`[id="${id}-text"]`)
      const elem = document.querySelector(`[id="${id}-text"]`);
      if (elem !== null) {
        elem.addEventListener('click', function () {
          callbackFunction();
        });
      }
    }
  );
};

/**
 * Called by parser when a click definition is found. Registers an event handler.
 *
 * @param ids Comma separated list of ids
 * @param functionName Function to be called on click
 * @param functionArgs Function args the function should be called with
 */
export const setClickEvent = function (ids, functionName, functionArgs) {
  ids.split(',').forEach(function (id) {
    setClickFun(id, functionName, functionArgs);
  });
  setClass(ids, 'clickable');
};

/**
 * Binds all functions previously added to fun (specified through click) to the element
 *
 * @param element
 */
export const bindFunctions = function (element) {
  funs.forEach(function (fun) {
    fun(element);
  });
};

export default {
  getConfig: () => getConfig().gantt,
  clear,
  setDateFormat,
  getDateFormat,
  enableInclusiveEndDates,
  endDatesAreInclusive,
  enableTopAxis,
  topAxisEnabled,
  setAxisFormat,
  getAxisFormat,
  setTickInterval,
  getTickInterval,
  setTodayMarker,
  getTodayMarker,
  setAccTitle,
  getAccTitle,
  setDiagramTitle,
  getDiagramTitle,
  setDisplayMode,
  getDisplayMode,
  setAccDescription,
  getAccDescription,
  addSection,
  getSections,
  getTasks,
  addTask,
  findTaskById,
  addTaskOrg,
  setIncludes,
  getIncludes,
  setExcludes,
  getExcludes,
  setClickEvent,
  setLink,
  getLinks,
  bindFunctions,
  parseDuration,
  isInvalidDate,
  setWeekday,
  getWeekday,
  setWeekend,
};

/**
 * @param data
 * @param task
 * @param tags
 */
function getTaskTags(data, task, tags) {
  let matchFound = true;
  while (matchFound) {
    matchFound = false;
    tags.forEach(function (t) {
      const pattern = '^\\s*' + t + '\\s*$';
      const regex = new RegExp(pattern);
      if (data[0].match(regex)) {
        task[t] = true;
        data.shift(1);
        matchFound = true;
      }
    });
  }
}
