import cytoscape from 'https://cdnjs.cloudflare.com/ajax/libs/cytoscape/3.15.2/cytoscape.esm.min.js';
import Fuse from 'https://cdn.jsdelivr.net/npm/fuse.js@6.4.0/dist/fuse.esm.js';

function graphArea () {
  const div = document.createElement('div');
  div.style.width = '100%';
  div.style.height = '500px';
  div.style.position = 'relative';
  div.style.top = '0px';
  div.style.left = '0px';
  return div
}

function noteElement (note, currentNote = false) {
  return {
    data: {
      id: note.id,
      title: note.title,
      label: note.id,
      color: currentNote ? 'white' : undefined
    }
  }
}

function linkElement (type, source, target) {
  console.assert(['backlink', 'direct', 'sequence'].includes(type));
  const id = type.slice(0, 1) + `${source}-${target}`;
  const style = type === 'backlink' ? 'dashed' : 'solid';
  const color = type === 'sequence' ? 'red' : 'black';
  return {
    data: { id, source, target, arrow: 'triangle', style, color }
  }
}

function * neighborElements (query, id) {
  const note = query.note(id);
  yield noteElement(note, true);

  for (const backlink of note.backlinks()) {
    yield noteElement(backlink.src);
    yield linkElement('backlink', backlink.src.id, id);
  }
  for (const link of note.links()) {
    yield noteElement(link.dest);
    yield linkElement('direct', id, link.dest.id);
  }
  for (const parent of note.parents()) {
    yield noteElement(parent.note);
    yield linkElement('sequence', parent.note.id, id);
  }
  for (const child of note.children()) {
    yield noteElement(child.note);
    yield linkElement('sequence', id, child.note.id);
  }
  for (const descendant of query.descendants(String(id))) {
    yield noteElement(descendant.note);
    yield noteElement(query.note(descendant.parentID));
    yield linkElement('sequence', descendant.parentID, descendant.note.id);
  }
}

function createCytoscape (container, elements) {
  return cytoscape({
    directed: true,
    multigraph: true,
    container: container,
    elements: elements,
    style: [
      {
        selector: 'node',
        style: {
          label: 'data(label)',
          height: 'label',
          width: 'label',
          padding: '8px',
          'text-halign': 'center',
          'text-valign': 'center'
        }
      },
      {
        selector: 'node[color]',
        style: {
          'background-color': 'black',
          color: 'data(color)',
          label: 'data(label)',
          height: 'label',
          width: 'label',
          padding: '8px',
          'text-halign': 'center',
          'text-valign': 'center'
        }
      },
      {
        selector: 'edge',
        style: {
          width: 2,
          'curve-style': 'bezier',
          'line-color': 'data(color)',
          'line-style': 'data(style)'
        }
      },
      {
        selector: 'edge[arrow]',
        style: {
          'target-arrow-color': 'data(color)',
          'target-arrow-shape': 'data(arrow)'
        }
      }
    ]
  })
}

function noteInfoDiv () {
  const div = document.createElement('div');
  div.style.bottom = 0;
  div.style.right = 0;
  div.style.padding = '20px';
  div.style.position = 'fixed';
  div.style.zIndex = 1;
  return div
}

function hoverHandlers (container) {
  const h3 = document.createElement('h3');
  const infoDiv = noteInfoDiv();
  infoDiv.appendChild(h3);
  container.appendChild(infoDiv);

  const show = event => {
    h3.textContent = event.target.data().title;
  };
  const hide = event => {
    h3.textContent = '';
  };
  return [show, hide]
}

function init (query) {
  let container = graphArea();
  let infoContainer = document.createElement('div');

  function resetGraph () {
    container.remove();
    infoContainer.remove();

    const id = Number(window.location.hash.slice(1));
    if (!Number.isInteger(id)) return

    const elements = Array.from(neighborElements(query, id));
    if (elements.length < 2) return

    container = graphArea();
    infoContainer = document.createElement('div');
    document.body.appendChild(infoContainer);
    document.body.appendChild(container);

    const cy = createCytoscape(container, elements);
    cy.layout({ name: 'cose' }).run();
    cy.reset();
    cy.center();

    const [show, hide] = hoverHandlers(infoContainer);
    cy.on('select', 'node', show);
    cy.on('unselect', 'node', hide);
  }

  resetGraph();
  window.addEventListener('hashchange', resetGraph);
}

function characterClass (character) {
  const code = character.charCodeAt();
  return code >= 47 && code < 58 ? 'd'
    : code >= 97 && code < 123 ? 'a' : null
}

function isValidAlias (alias) {
  if (alias === null) return true
  if (typeof alias !== 'string') return false
  if (!alias) return false
  if (characterClass(alias.slice(0, 1)) !== 'd') return false
  for (const c of alias) {
    const charClass = characterClass(c);
    if (charClass !== 'd' && charClass !== 'a') {
      return false
    }
  }
  return true
}

function isNumber (string) {
  if (!string) return false
  for (const c of string) {
    if (characterClass(c) !== 'd') return false
  }
  return true
}

function aliasParent (alias) {
  if (!isValidAlias(alias)) return null
  if (alias === null) return null

  const last = characterClass(alias.slice(-1));
  const pattern = last === 'd' ? /^(.*?)[0-9]+$/ : /^(.*?)[a-z]+$/;
  return alias.replace(pattern, '$1') || null
}

function isSequence (prev, next) {
  return aliasParent(next) === prev
}

class Database {
  // Schema
  // {
  //   aliases: {
  //     <alias:str>: { id: <int>, children: [<str>], parent: <str> }
  //   },
  //   notes: [
  //     {
  //       title: <str>,
  //       aliases: [<str>],
  //       links: [{ src: <int>, dest: <int>, annotation: <str> }],
  //       backlinks: [<link>]
  //     }
  //   ]
  // }

  constructor () {
    this.data = { aliases: {}, notes: [] };
  }

  add (record) {
    return record.addTo(this) || record
  }
}

class IntegrityError extends Error {}
class DomainError extends IntegrityError {}
class ReferenceError extends IntegrityError {}

function check (condition, message) {
  if (!condition) throw new DomainError(message)
}

class Note {
  constructor (id, title) {
    check(Number.isInteger(id), 'invalid Note.id');
    check(typeof title === 'string', 'invalid Note.title');
    check(title, 'empty Note.title');

    this.id = id;
    this.title = title;
  }

  addTo (db) {
    // Overwrite existing entry in notes.
    db.data.notes[this.id] = {
      title: this.title,
      aliases: [],
      links: [],
      backlinks: []
    };
  }

  equals (note) {
    return this.id === note.id && this.title === note.title
  }
}

class Alias {
  constructor (id, alias) {
    check(Number.isInteger(id), 'non-integer Alias.id');
    check(typeof alias === 'string', 'non-string Alias.alias');
    check(isValidAlias(alias), 'malformed Alias.alias');
    check(alias, 'empty alias');
    if (isNumber(alias)) {
      check(String(id) === alias, 'invalid Alias.alias');
    }

    this.id = id;
    this.alias = alias;
  }

  addTo (db) {
    // Overwrite existing entry in aliases.
    // Note with ID must exist.
    const note = db.data.notes[this.id];
    if (!note) return new ReferenceError('Alias.id')

    db.data.aliases[this.alias] = {
      id: this.id,
      children: [],
      parent: null
    };
    note.aliases.push(this.alias);
  }
}

class Sequence {
  constructor (prev, next) {
    check(isValidAlias(prev), 'malformed Sequence.prev');
    check(isValidAlias(next), 'malformed Sequence.next');
    check(isSequence(prev, next),
      'Sequence.prev and Sequence.next not in sequence');
    check(prev != null, 'null Sequence.prev');
    check(next != null, 'null Sequence.next');

    this.prev = prev;
    this.next = next;
  }

  addTo (db) {
    let prev = db.data.aliases[this.prev];
    const next = db.data.aliases[this.next];

    if (!next) return
    if (!prev) {
      try {
        if (!db.add(new Alias(Number(this.prev), this.prev))) {
          return null
        }
        prev = db.data.aliases[this.prev];
      } catch (e) {
        return null
      }
    }

    const prevNote = db.data.notes[prev.id];
    const nextNote = db.data.notes[next.id];
    if (!prevNote || !nextNote) return

    next.parent = this.prev;
    prev.children.push(this.next);
  }
}

class Link {
  constructor (src, dest, annotation) {
    check(src instanceof Note, 'invalid src Note');
    check(dest instanceof Note, 'invalid dest Note');
    check(typeof annotation === 'string', 'non-string Link.annotation');

    this.src = src;
    this.dest = dest;
    this.annotation = annotation;
  }

  addTo (db) {
    const src = db.data.notes[this.src.id];
    const dest = db.data.notes[this.dest.id];
    if (!src) return new ReferenceError('Link.src')
    if (!dest) return new ReferenceError('Link.dest')

    // Existing entries get overwritten.
    const link = {
      src: this.src,
      dest: this.dest,
      annotation: this.annotation
    };
    src.links.push(link);
    if (link.annotation) {
      dest.backlinks.push(link);
    }
  }
}

class Query {
  constructor (db) {
    this.db = db;
  }

  note (id) {
    const record = this.db.data.notes[id];
    if (!record) return null

    const note = new Note(id, record.title);

    note.links = () => this.links(note);
    note.backlinks = () => this.backlinks(note);

    note.aliases = function * () {
      yield * record.aliases;
    };

    const self = this;
    note.parents = function * () {
      for (const alias of record.aliases) {
        const parent = self.parent(alias);
        if (parent) {
          yield parent;
        }
      }
    };

    note.children = function * () {
      for (const alias of record.aliases) {
        yield * self.children(alias);
      }
    };
    return note
  }

  * links (note) {
    const src = this.db.data.notes[note.id];
    if (src && src.links) {
      yield * src.links;
    }
  }

  * backlinks (note) {
    const dest = this.db.data.notes[note.id];
    if (dest && dest.backlinks) {
      yield * dest.backlinks;
    }
  }

  parent (alias) {
    const record = this.db.data.aliases[alias];
    if (!record || !record.parent) return null
    const parentRecord = this.db.data.aliases[record.parent];
    // TODO what if parentRecord.id === 0?
    if (!parentRecord || !parentRecord.id) return null
    return {
      note: this.note(parentRecord.id),
      alias: record.parent
    }
  }

  * children (alias) {
    const record = this.db.data.aliases[alias];
    if (record) {
      const children = record.children || [];
      for (const childAlias of children) {
        const childRecord = this.db.data.aliases[childAlias];
        if (!childRecord) continue
        const childID = childRecord.id;
        const child = this.note(childID);
        if (child) {
          yield {
            note: child,
            alias: childAlias,
            parentID: record.id,
            parentAlias: alias
          };
        }
      }
    }
  }

  * descendants (alias) {
    const record = this.db.data.aliases[alias];
    if (record) {
      for (const child of this.children(alias)) {
        yield child;
        yield * this.descendants(child.alias);
      }
    }
  }
}

var Model = /*#__PURE__*/Object.freeze({
  __proto__: null,
  Alias: Alias,
  aliasParent: aliasParent,
  Database: Database,
  DomainError: DomainError,
  isNumber: isNumber,
  isSequence: isSequence,
  Link: Link,
  Note: Note,
  Query: Query,
  ReferenceError: ReferenceError,
  Sequence: Sequence
});

function createFuse () {
  const options = {
    includeMatches: true,
    ignoreLocation: true,
    keys: ['textContent'],
    threshold: 0.45
  };
  const nodes = document.body.querySelectorAll('section.level1');
  const sections = Array.prototype.filter.call(nodes, function (node) {
    return Number.isInteger(Number(node.id))
  });
  return new Fuse(sections, options)
}

function getSearchResults (fuse) {
  const div = document.querySelector('input.search-bar');
  return fuse.search(div.value)
}

function displayResults (results) {
  const div = document.querySelector('div.search-results');
  div.textContent = '';
  for (const result of results) {
    const p = document.createElement('p');
    const h3 = document.createElement('h3');
    h3.innerHTML = `<a href="#${result.item.id}">${result.item.title}</a>`;
    p.appendChild(h3);

    let count = 3;
    for (const child of result.item.children) {
      const clone = child.cloneNode(true);
      if (count-- <= 0) break
      if (clone.tagName === 'H1' && clone.title === result.item.title) {
        continue
      }
      p.appendChild(clone);
    }

    div.appendChild(p);
    div.appendChild(document.createElement('hr'));
  }
}

function searchNotes (fuse) {
  const results = getSearchResults(fuse);
  displayResults(results);
}

function searchBar () {
  const form = document.createElement('form');
  form.action = 'javascript:void(0)';
  form.style.textAlign = 'center';

  const fuse = createFuse();

  const input = document.createElement('input');
  input.type = 'text';
  input.placeholder = 'Search notes...';
  input.classList.add('search-bar');
  input.style.width = '80%';
  input.addEventListener('change', () => searchNotes(fuse));

  form.appendChild(input);
  return form
}

function searchResults () {
  const div = document.createElement('div');
  div.classList.add('search-results');
  return div
}

function searchPage () {
  const page = document.createElement('section');
  page.id = 'search';
  page.title = 'Search';
  page.classList.add('level1');
  page.appendChild(searchBar());
  page.appendChild(document.createElement('br'));
  page.appendChild(searchResults());
  return page
}

function searchButton () {
  const a = document.createElement('a');
  a.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M23.809 21.646l-6.205-6.205c1.167-1.605 1.857-3.579 1.857-5.711 0-5.365-4.365-9.73-9.731-9.73-5.365 0-9.73 4.365-9.73 9.73 0 5.366 4.365 9.73 9.73 9.73 2.034 0 3.923-.627 5.487-1.698l6.238 6.238 2.354-2.354zm-20.955-11.916c0-3.792 3.085-6.877 6.877-6.877s6.877 3.085 6.877 6.877-3.085 6.877-6.877 6.877c-3.793 0-6.877-3.085-6.877-6.877z"/></svg>';
  a.href = '#search';
  a.title = 'Search notes';
  return a
}

function init$1 () {
  document.body.appendChild(searchPage());
  document.body.insertBefore(searchButton(), document.body.firstChild);
}

function hideSections () {
  const sections = document.getElementsByTagName('section');
  for (let i = 0; i < sections.length; i++) {
    const section = sections[i];
    if (section.classList.contains('level1')) {
      switch (true) {
        case Number.isInteger(Number(section.id)):
        case section.id.charAt(0) === '#':
        case section.id === 'references':
        case section.id.slice(0, 4) === 'ref-':
        case section.id === 'search':
        case section.id === 'tags':
          section.style.display = 'none';
          break
      }
    }
  }
}

function getSectionFromHash (hash) {
  const id = hash.substring(1);
  if (!id) { return null }
  const elem = document.getElementById(id);
  if (elem) {
    return elem.closest('section.level1')
  }
}

function sectionChanger () {
  let _previousHash = window.location.hash;
  return function () {
    const oldSection = getSectionFromHash(_previousHash);
    if (oldSection) {
      oldSection.style.display = 'none';
    }
    _previousHash = window.location.hash;
    const newSection = getSectionFromHash(_previousHash);
    if (newSection) {
      newSection.style.display = '';
      document.title = newSection.title || 'Slipbox';
    } else {
      window.location.hash = '#0';
    }
    window.scrollTo(0, 0);
  }
}

function notFoundSection () {
  const section = document.createElement('section');
  section.id = '0';
  section.classList.add('level1');
  const h1 = document.createElement('h1');
  h1.innerText = 'Note not found';
  section.appendChild(h1);
  return section
}

function init$2 () {
  document.body.appendChild(notFoundSection());
  hideSections();

  const changeSection = sectionChanger();
  changeSection();
  window.addEventListener('hashchange', changeSection);
}

function A (innerText, href, title) {
  const a = document.createElement('a');
  a.innerText = innerText;
  a.href = href;
  if (title != null) {
    a.title = title;
  }
  return a
}

function Li (children) {
  const li = document.createElement('li');
  for (const child of children) {
    li.appendChild(child);
  }
  return li
}

function aliasOwner (alias) {
  const found = alias.match(/^\d+/);
  return found ? found[0] : ''
}

function * backlinkLIs (query, id) {
  const note = query.note(id);
  if (note) {
    for (const backlink of note.backlinks()) {
      yield {
        note: backlink.src,
        li: Li([
          document.createTextNode(`[${backlink.src.id}] `),
          A(backlink.src.title, `#${backlink.src.id}`, backlink.annotation)
        ])
      };
    }
  }
}

function * directLinkLIs (query, id) {
  const note = query.note(id);
  if (note) {
    for (const link of note.links()) {
      const b = document.createElement('b');
      b.textContent = `[${link.dest.id}] `;
      yield {
        note: link.dest,
        li: Li([b, A(link.dest.title, '#' + link.dest.id, link.annotation)])
      };
    }
  }
}

function * parentLIs (query, id) {
  const note = query.note(id);
  if (note) {
    for (const parent of note.parents()) {
      yield {
        note: parent.note,
        li: Li([
          document.createTextNode(`[${parent.note.id}/${parent.alias}] `),
          A(parent.note.title, '#' + parent.note.id)
        ])
      };
    }
  }
}

function * childrenLIs (query, id) {
  const note = query.note(id);
  if (note) {
    for (const child of note.children()) {
      yield {
        note: child.note,
        li: Li([
          document.createTextNode(`[${child.note.id}/${child.alias}] `),
          A(child.note.title, '#' + child.note.id)
        ])
      };
    }
  }
}

function * descendantLIs (query, id) {
  const note = query.note(id);
  if (note) {
    for (const descendant of query.descendants(String(id))) {
      if (descendant.parentID === id) continue
      yield {
        note: descendant.note,
        li: Li([
          document.createTextNode(`[${descendant.note.id}/${descendant.alias}] `),
          A(descendant.note.title, '#' + descendant.note.id)
        ])
      };
    }
  }
}

function createRelatedUl (query, id) {
  const related = [
    ...backlinkLIs(query, id),
    ...directLinkLIs(query, id),
    ...parentLIs(query, id),
    ...childrenLIs(query, id),
    ...descendantLIs(query, id)
  ];
  related.sort((a, b) => {
    if (a.note.id < b.note.id) return -1
    if (a.note.id > b.note.id) return 1
    const atext = a.li.innerText;
    const btext = b.li.innerText;
    return atext < btext ? -1 : atext === btext ? 0 : 1
  });

  const ul = document.createElement('ul');
  for (const note of related) {
    ul.appendChild(note.li);
  }
  return ul
}

function createSeeAlsoDiv (query, id) {
  const div = document.createElement('div');
  div.id = 'slipbox-see-also';
  const ul = createRelatedUl(query, id);
  if (ul.children.length > 0) {
    const h3 = document.createElement('h3');
    h3.innerText = 'See also';
    div.appendChild(h3);
    div.appendChild(ul);
  }
  return div
}

function createAliasesP (query, id) {
  const p = document.createElement('p');
  p.id = 'slipbox-aliases';

  const note = query.note(id);
  if (!note) return p

  const aliases = Array.from(note.aliases()).map(alias => {
    const span = document.createElement('span');
    span.appendChild(document.createTextNode(' ['));
    span.appendChild(A(alias, '#' + aliasOwner(alias)));
    span.appendChild(document.createTextNode(']'));
    return span
  });
  if (aliases.length > 0) {
    const text = document.createElement('strong');
    text.innerText = 'Aliases:';
    p.appendChild(text);
    aliases.forEach(alias => p.appendChild(alias));
  }
  return p
}

function setAliasesP (query, id) {
  const p = document.getElementById('slipbox-aliases');
  if (p) {
    p.remove();
  }
  document.body.appendChild(createAliasesP(query, id));
}

function setSeeAlsoDiv (query, id) {
  const div = document.getElementById('slipbox-see-also');
  if (div) {
    div.remove();
  }
  document.body.appendChild(createSeeAlsoDiv(query, id));
}

function seeAlso (query, hash) {
  const section = getSectionFromHash(hash); // closest level1 ancestor
  const id = section ? Number(section.id) : 0;
  setAliasesP(query, id);
  setSeeAlsoDiv(query, id);
}

function init$3 (query) {
  const handler = () => seeAlso(query, window.location.hash);
  window.addEventListener('hashchange', handler);
  handler();
}

window.query = new Query(new Database());

window.Model = Model;

window.initSlipbox = function () {
  init$1();
  init$3(window.query);
  init$2();
  init(window.query);
};
