// 'use strict'
/* global m, _, SlimSelect */

import { byKeyToByLang, makeFmt } from './translate.js'

/**
 * @typedef {(value: String, extra_translations?: Object.<string, string>) => String} FMT - The translation function
 */

const INTERVAL_TO_HIDE_MESSAGE = 3000 // ms
const FILTER_DEBOUNCE_DELAY = 500 // ms
const LISTING_RELOAD_INTERVAL = 10000 // ms
const EMPTY_OPTION_STRING = '------'
// Limit for age of orders displayed on 'Expeditions' page. Only younger orders are displayed
const EXPEDITIONS_PAGE_ORDERS_MAX_AGE = 24 * 60 * 60 * 1000 // ms

const SurchargePriceType = {
  FIXED: 0,
  PER_CUBIC_METER: 1,
  PER_OTHER_UNIT: 2,
  PRICE_CONCRETE_PERCENTAGE: 3
}

// FIXME: wrap this in component
let globalConfig = { setup: {}, menu: [] }

const urlParams = new URLSearchParams(window.location.search)

const isInIframe = window.location !== window.parent.location
console.debug('isInIframe', isInIframe)

/**
 * Respects astproxy path prefix. Also shows a loader while we wait for the url to load / file to download.
 * @param {String} url - Url of the new tab
 */
const openUrl = url => {
  Layout.addLoader()
  url = window.location.origin + window.location.pathname + url

  const queryParams = new URLSearchParams(URL.parse(url).search)

  if (!queryParams.has('export_format')) {
    window.addEventListener('unload', Layout.removeLoader)
    window.location.href = url
  } else {
    // this entire branch is here because when we use the "simple" one above, the loader keeps spinning forever.
    // this is caused by the fact that the exported file to be downloaded "overwrites" the dom
    // and mithril stays in its last state - with the loader still present.
    // TODO: investigate less hacky solution - maybe with iframe or something
    fetch(url)
      .then(response => {
        const match = (response.headers.get('Content-Disposition') || '').match(/filename="?([^"]+)"?/)

        const filename = match?.[1] || URL.parse(response.url).pathname.split('/').pop() || 'download'

        return response.blob().then(blob => ({ blob, filename }))
      })
      .then(({ blob, filename }) => {
        Layout.removeLoader()
        m.redraw() // Manual re-draw needed as no auto-redraw event happens here

        const href = URL.createObjectURL(blob)
        const a = document.createElement('a')
        a.href = href
        a.download = filename
        document.body.appendChild(a)
        a.click()
        document.body.removeChild(a)
        URL.revokeObjectURL(href)
      })
  }
}

/** Copied from
 * https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_media_queries/Printing#print_an_external_page_without_opening_it
 * @param {String} url - Url of the webpage that will be printed
 */
const printUrl = url => {
  function setPrint () {
    const closePrint = () => {
      document.body.removeChild(this)
    }
    this.contentWindow.onbeforeunload = closePrint
    this.contentWindow.onafterprint = closePrint
    this.contentWindow.print()
  }

  const hideFrame = document.createElement('iframe')
  hideFrame.onload = setPrint
  hideFrame.style.display = 'none'
  hideFrame.src = url
  document.body.appendChild(hideFrame)
}

/**
 * JS counterpart of atxdispatch.func.normalize_text.
 * Main difference is numeric normalization, there is no easy way like unicodedata.numeric, so it does fix only superscripts.
 * @param {String} string - To be normalized
 * @returns {String} - Normalized
 */
const normalized = string =>
  String(string)
    // Normalize case
    .toLowerCase()
    // Remove diacritics
    .normalize('NFD')
    .split('')
    .filter(char => !/\p{M}/u.test(char))
    // Normalize number powers: m³ -> m3
    .map(char => ({ '⁰': '0', '¹': '1', '²': '2', '³': '3', '⁴': '4', '⁵': '5', '⁶': '6', '⁷': '7', '⁸': '8', '⁹': '9' }[char] || char))
    .join('')
    // Normalize whitespace
    .trim()
    .replace(/\s+/g, ' ')

const encodeToString = value => {
  if (value === undefined) {
    return '___UNDEFINED___'
  }
  if (value === null) {
    return '___NULL___'
  }
  if (value === true) {
    return '___TRUE___'
  }
  if (value === false) {
    return '___FALSE__'
  }
  if (typeof value === 'number') {
    const string = String(value)
    if (string.includes('.')) {
      return '___FLOAT___' + string
    }
    return '___INT___' + string
  }
  return value
}

const decodeFromString = string => {
  if (typeof string !== 'string') {
    return string
  }
  if (string === '___UNDEFINED___') {
    return undefined
  }
  if (string === '___NULL___') {
    return null
  }
  if (string === '___TRUE___') {
    return true
  }
  if (string === '___FALSE___') {
    return false
  }
  if (string.startsWith('___FLOAT___')) {
    return parseFloat(string.replace('___FLOAT___', ''))
  }
  if (string.startsWith('___INT___')) {
    return parseInt(string.replace('___INT___', ''))
  }
  return string
}

/** Allow for updating of mithril querystring
 *
 * @param {Object<String, any>} parameters - Parameters to update. Set null or undefined to remove the parameter.
 */
const setMithrilQueryString = parameters => {
  // Read from window.location.hash instead of m.route.get() because Mithril
  // updates its internal currentPath asynchronously (via setTimeout), so
  // m.route.get() may return stale data when called shortly after m.route.set().
  const [route, queryString] = window.location.hash.replace(/^#!/, '').split('?')

  const queryStringParams = m.parseQueryString(queryString)

  m.route.set(route, _({ ...queryStringParams, ...parameters }).omitBy(_.isNil).value())
}

/** Handle "unauthorized" errors from backend (which usually means user is not logged in) */
const loginRedirect = err => {
  if (err.code === 401) {
    m.route.set('/login')
  }
}

// Singleton: Model holding list of locked tables for current user
const userLocks = {
  tables: [],

  setData: data => {
    userLocks.tables = data.map(x => x.table_name)
  },

  isLocked: tableName => {
    return userLocks.tables.includes(tableName)
  }
}

/**
 * Test if value is integer (https://stackoverflow.com/a/14794066)
 * @param {any} value - Value to check
 * @return Boolean
 */
const isInteger = value => {
  if (isNaN(value)) {
    return false
  }
  const parsed = parseFloat(value)
  return (parsed | 0) === parsed
}

/** Returns formatted date in YYYY-MM-DD in the current timezone */
const stdDateFormat = /** Date */ inputDate => { // eslint-disable-line no-unused-vars
  if (!inputDate) {
    return null
  }
  const shiftedDate = new Date(inputDate.getTime() - (inputDate.getTimezoneOffset() * 60 * 1000))
  return shiftedDate.toISOString().split('T')[0]
}

/** Returns formatted date and time in YYYY-MM-DD HH:MM:SS in the current timezone */
const stdDateTimeFormat = /** Date */ inputDate => {
  if (!inputDate) {
    return null
  }
  const shiftedDate = new Date(inputDate.getTime() - (inputDate.getTimezoneOffset() * 60 * 1000))
  return shiftedDate.toISOString().replace(/[T.]/g, ' ').split(' ').slice(0, 2).join(' ')
}

/** Returns current date and time in YYYY-MM-DD HH:MM:SS */
const stdCurrentDateTimeFormat = () => stdDateTimeFormat(new Date())

/**
 * Wrap `m.request({method: 'GET', ...})` in error and spinner handling. Add .catch(_.noop) to suppress uncaught errors.
 * @param {string} url - Url to post to
 * @param {Object?} queryParams - Query parameters to include
 * @param {Object?} requestOptions - Additional params passed to m.request
 * @returns {Promise}
 */
const getRequest = (url, queryParams, requestOptions = {}) => {
  console.debug('getRequest', url, queryParams)

  Layout.addLoader()

  return m.request({ url, method: 'GET', params: queryParams, ...requestOptions })
    .finally(Layout.removeLoader)
    .then(
      data => {
        console.debug('getRequest success response', url, data)
        return data
      },
      error => {
        console.debug('getRequest error response', url, error.code, error.response)
        loginRedirect(error)
        throw error
      }
    )
}

/**
 * Wrap `m.request({method: 'POST', ...})` in error and spinner handling. Add .catch(_.noop) to suppress uncaught errors.
 * @param {string} url - Url to post to
 * @param {Object} data - Data to post
 * @param {string?} successUrl - Where to navigate to on success
 * @param {Object?} requestOptions - Additional params passed to m.request
 * @returns {Promise}
 */
const postRequest = (url, data, successUrl, requestOptions = {}) => {
  console.debug('postRequest', url, data)

  Layout.addLoader()

  return m.request({ url, method: 'POST', body: data, ...requestOptions })
    .finally(Layout.removeLoader)
    .then(
      data => {
        console.debug('postRequest success response', url, data)
        ServerMessages.set(data)

        if (!data) {
          throw new Error('Empty response')
        }

        if (successUrl) {
          m.route.set(successUrl)
        }

        return data
      },
      error => {
        console.debug('postRequest error response', url, error.code, error.response)
        ServerMessages.set(error.response)
        loginRedirect(error)
        throw error
      }
    )
}

/**
 * @param {String} route - Route to resolve
 * @returns {String}
 */
const resolveRoute = route => {
  // TODO: over time, try to reduce this list of non-standard cases (ideally to zero)
  const routeOverrides = {
    'StockMovement.list': '/material/:id/stock_movements',
    'Delivery.list': '/order/:id/deliveries',
    'Recipe.copy': '/recipe_open/:id/copy'
  }

  if (route in routeOverrides) {
    return routeOverrides[route]
  }

  const match = route.match(/^([^.]+)\.(detail|new)$/)
  if (match) {
    return `/${_.snakeCase(match[1])}_open` + (match[2] === 'detail' ? '/:id' : '')
  }

  console.warn(`Unknown route name: ${route}`)

  return route
}

/** Used for managing redirects that need data to be passed around
 *
 * @typedef Stackitem
 * @property {String} route - The route this item applies on
 * @property {Object} data - The data that will be used on {@link route}
 * @property {(value: any) => void} [setReturnValue] - Sets value under the correct key in the previous Item
 */
const RedirectStack = {
  /**
   * Returns the top of the stack
   * @returns {Stackitem | undefined}
   */
  current: () => _.last(RedirectStack._stack),

  /** Resets the redirection queue */
  clear: () => {
    RedirectStack._stack = []
    RedirectStack._navigatedByStack = false // Used to know that we went somewhere using RedirectionStack and not user interaction
  },

  /**
   * Saves data to the stack, then redirects
   * @param {Object} kwargs
   * @param {String} kwargs.redirectTo - Where to redirect
   * @param {Object} [kwargs.redirectData] - Data to pass to where we redirect to
   * @param {Boolean} [kwargs.noReturn] - If true, will not set up the data for returning
   * @param {Object} [kwargs.returnData] - Data to save for restoration
   * @param {String} [kwargs.returnKey] - Under what key to save the returned value
   */
  push: ({ redirectTo, redirectData, noReturn = false, returnData, returnKey }) => {
    if (!noReturn && !RedirectStack.current()) {
      RedirectStack._stack.push({
        route: m.route.get() || '/',
        data: {}
      })
    }

    const redirectItem = {
      route: redirectTo,
      data: redirectData || {}
    }

    if (!noReturn) {
      const returnItem = RedirectStack.current()
      returnItem.data = { ...returnItem.data, ...(returnData || {}) }

      if (returnKey) {
        redirectItem.setReturnValue = value => {
          returnItem.data[returnKey] = value
        }
      }
    }

    RedirectStack._stack.push(redirectItem)
    RedirectStack._navigatedByStack = true

    m.route.set(redirectItem.route)
  },

  /** Either goes back in history or to where RedirectStack points to */
  back: () => {
    RedirectStack._stack.pop()

    const returnRoute = RedirectStack.current()?.route

    if (returnRoute) {
      RedirectStack._navigatedByStack = true
      m.route.set(returnRoute)
    } else {
      window.history.back()
    }
  },

  /**
   * Shorthand for setting of data that will be restored after redirect, if requested whn navigating to current route
   * @param {Object} value - Data to be restored
   */
  setReturnValue: value => {
    RedirectStack.current()?.setReturnValue?.(value)
  },

  /** To be used by the Mithril's onmatch method when route changes */
  onmatch: (_args, _requestedPath, route) => {
    if (!RedirectStack._navigatedByStack) {
      // Check if the user did go to the same route as the second-to-top of the stack. Clear stack if not
      RedirectStack._stack.pop()

      if (RedirectStack.current()?.route !== route) {
        RedirectStack.clear()
      }
    }

    RedirectStack._navigatedByStack = false
  }
}

RedirectStack.clear() // init

/* Full screen msgbox. Attributes:
    title    (string in header)
    subtitle (string or m() displayed under header)
    fields   (as object, displayed are only values)
    buttons  (simple list)
*/
const Msgbox = {
  view: vnode => {
    if (vnode.attrs.subtitle instanceof String) {
      vnode.attrs.subtitle = m('h6.my-3.text-center', vnode.attrs.subtitle)
    }

    return m('',
      m('.mt-5', '\u00A0'), // Naive separator
      m('.w-50.mt-5.mx-auto.bg-light.p-4.border.border-dark.rounded', [
        m('h2.my-3.text-center', vnode.attrs.title),
        vnode.attrs.subtitle,
        vnode.attrs.fields,
        m('.text-center.mt-4', vnode.attrs.buttons)
      ])
    )
  }
}

/** Helper for bullet list for {@link Msgbox} */
const MsgboxSublist = {
  /** @param {{attrs: {items?: Array<String>}}} vnode */
  view: vnode => {
    return m('ol.my-3.pl-0.mx-auto',
      { style: { width: 'fit-content' } },
      vnode.attrs.items?.map(item => m('li', vnode.attrs.fmt(item)))
    )
  }
}

const ConfirmationPopup = {
  /**
   * @typedef {Object} ConfirmationPopup
   * @property {String} title
   * @property {String | Array<String>} subtitle_items
   * @property {Array<Object>} buttons
   *
   * @typedef {Object} Attrs
   * @property {FMT} fmt
   * @property {ConfirmationPopup} definition - Definition of the popup (see {@link oneToSubmit})
   * @property {{[name: String]: (_options: {url: String, extraData?: Object, previousResult: any}) => Promise<{breakChain?: Boolean, result?: any}>}} callbacks - Will be used for buttons of type callback (see {@link oneToSubmit})
   *
   * @param {{attrs: Attrs}} vnode
   */
  view: vnode => {
    const fmt = vnode.attrs.fmt
    const definition = vnode.attrs.definition

    return m(Msgbox, {
      title: fmt(definition.title),
      subtitle: m(MsgboxSublist, { fmt, items: definition.subtitle_items }),
      buttons: formToSubmitArea(fmt, definition.buttons, vnode.attrs.callbacks)
    })
  }
}

/** InfoBox displayed in user forms */
const InfoBox = {
  /**
   * @typedef Attrs
   * @property {String} [text] - Text to be displayed
   * @property {'success'|'info'|'warning'|'error'} level - Type of the message
   *
   * @param {{attrs: Attrs}} vnode
   */
  view: vnode => {
    let icon = ''
    let style = ''

    if (vnode.attrs.level === 'success') {
      style = 'success'
      icon = 'check'
    } else if (vnode.attrs.level === 'info') {
      style = 'dark'
      icon = 'sticky-note'
    } else if (vnode.attrs.level === 'warning') {
      style = 'warning'
      icon = 'exclamation-circle'
    } else if (vnode.attrs.level === 'error') {
      style = 'danger'
      icon = 'exclamation-triangle'
    }
    if (!vnode.attrs.text) return undefined

    const content = m(`.py-2.px-3.mx-5.my-2.bold.border.bg-light.rounded.border-${style}`, [
      m(`i.mr-2.text-${style}.fas.fa-${icon}`),
      vnode.attrs.text
    ])

    if (vnode.attrs.route) {
      return m(m.route.Link, {
        href: `/${vnode.attrs.route}`,
        class: 'text-reset text-decoration-none'
      }, content)
    }

    return content
  }
}

/* Unified header row for sub-part of long UI form. Text is defined in 'children' */
const FormSubHeader = {
  view: vnode => {
    return m('.pl-1.pb-0.pt-1.bg-light.border-bottom.my-1.bold', vnode.children)
  }
}

/** Unified header row UI forms */
const FormHeader = {
  view: /** {attrs: {text: String, fmt: FMT}} */ vnode => {
    return m('h3.mx-3.mt-4.border-bottom.mb-3', vnode.attrs.fmt(vnode.attrs.text))
  }
}

/** Spinner displayed when loading something */
const Loading = {
  /**
   * @typedef Attrs
   * @property {Number} [size] Size of the spinner in pixels
   */

  /** @param {{attrs: Attrs}} vnode */
  view: vnode => {
    return m('.my-1.mx-auto.w-50.text-center',
      m('img', { src: './static/spinner.gif', width: vnode.attrs.size })
    )
  }
}

/** Just like Loading, but not centered and not colliding with other elements */
const FloatingLoading = {
  view: vnode => {
    const offset = (vnode.attrs.size || 200) + 20

    return m('', {
      style: {
        position: 'fixed',
        top: `calc(100vh - ${offset}px)`,
        left: `calc(100vw - ${offset}px)`
      }
    },
    m('img', { src: './static/spinner.gif', width: vnode.attrs.size })
    )
  }
}

const StdButton = {
  /* Simple blue button. Attrs:
        onclick
        text
  */
  view: vnode => { return m('button.btn.btn-primary.px-4.mr-4', { onclick: vnode.attrs.onclick }, vnode.attrs.text) }
}

const FormRow = {
  /* We wrap form fields into neat rows */
  view: vnode => {
    return m('.row.mx-4.py-1',
      m('.col-sm-12.col-md-4.text-md-right', vnode.attrs.label),
      m('.col-sm-12.col-md-8', vnode.attrs.field)
    )
  }
}

/** Buttons under the standard forms */
const SubmitButtonsArea = {
  view: vnode => {
    return m(FormRow, { label: m(''), field: m('.d-flex', vnode.children) })
  }
}

/** Buttons under the list view, always visible */
const ListButtonsArea = {
  view: vnode => {
    return m('.border-top.bg-white.fixed-bottom.d-flex.align-items-center.justify-content-end.pr-2', { style: { 'z-index': 'unset' } }, vnode.children)
  }
}

/** Button with a callback for standard edit forms */
const CallbackButton = {
  /**
   * @typedef {Object} Attrs
   * @property {FMT} fmt
   * @property {String} text - Buttons text
   * @property {String} icon - Buttons icon
   * @property {Boolean} secondary - Whether to show this button as gray
   * @property {() => void} onClick - Will be called when the button is clicked
   *
   * @param {{attrs: Attrs}} vnode
   */
  view: vnode => {
    return m('button', {
      class: `btn btn-${vnode.attrs.secondary ? 'secondary' : 'primary'} px-4 mr-2`,
      onclick: vnode.attrs.onClick
    }, [
      m('', { class: `fas ${vnode.attrs.icon} mr-2` }),
      vnode.attrs.fmt(vnode.attrs.text)
    ])
  }
}

/** Plain file export button for printout forms */
const FileExportButton = {
  /**
   * @typedef {Object} Attrs
   * @property {FMT} fmt
   * @property {'csv'|'tsv'} exportType - Type of the button
   * @property {(exportType: String) => void} onClick - Will be called when the button is clicked
   *
   * @param {{attrs: Attrs}} vnode
   */
  view: vnode => {
    const exportType = vnode.attrs.exportType
    return m('button.dropdown-item.btn.btn-primary.px-4.mr-2', {
      onclick: () => vnode.attrs.onClick?.(exportType)
    }, vnode.attrs.fmt(`{export_to_${exportType}}`))
  }
}

/** Dropdown of "Export to CSV" and "Export to TSV" buttons */
const FileExportDropdown = {
  /**
   * @typedef {Object} Attrs
   * @property {FMT} fmt
   * @property {(exportType: String) => void} onClick - Will be called when the button is clicked
   *
   * @param {{attrs: Attrs}} vnode
   */
  view: vnode => {
    const fmt = vnode.attrs.fmt

    return m('.dropdown', [
      m('button.btn.btn-primary.dropdown-toggle.py-1.h-100', { 'data-toggle': 'dropdown' }, fmt('{export}')),
      m('.dropdown-menu', [
        m(FileExportButton, { fmt, exportType: 'csv', onClick: vnode.attrs.onClick }),
        m(FileExportButton, { fmt, exportType: 'tsv', onClick: vnode.attrs.onClick })
      ])
    ])
  }
}

const ButtonAdd = {
  /**
   * Simple button with a plus
   * @typedef {Object} Attrs
   * @property {() => void} onClick - Will be called when clicked
   * @property {Boolean} [small] - Size of the button
   */

  /** @param {{attrs: Attrs}} vnode */
  view: vnode => {
    const buttonClass = 'btn btn-primary fas fa-plus h-100 ' + (vnode.attrs.small ? 'px-3 py-1' : 'px-5 my-3 ml-3 py-2 mx-1')

    return m('', {
      class: buttonClass,
      style: { 'align-content': 'center' },
      onclick: vnode.attrs.onClick
    })
  }
}

const DropdownMenu = {
  /*  Attrs:
        name .. displayed on button
    */
  view: vnode => {
    return m('.dropdown',
      m('button.btn.btn-light.dropdown-toggle.mx-1', {
        // explicitly specifying type on a button in a <form> will prevent the form to be submitted by it
        type: 'button',
        'data-toggle': 'dropdown'
      }, vnode.attrs.name),
      m('.dropdown-menu', vnode.children)
    )
  }
}

/* red and green strip with server errors and messages. Also acts as singleton holding last response from the server. Usage:
     ServerMessages.clear() to clear message
     ServerMessages.set(json_data) with json returned from server to display it.
   HACK: yes, this global (singleton) is ugly. But I found no other simple way to do it.
*/
const ServerMessages = {
  response: null,

  clear: () => { ServerMessages.response = null },

  set: data => {
    if (data?.message || data?.error) {
      ServerMessages.response = data
      window.setTimeout(ServerMessages.onInterval, INTERVAL_TO_HIDE_MESSAGE)
    }
  },

  onInterval: () => {
    if (ServerMessages.response?.message) {
      ServerMessages.clear()
      m.redraw()
    }
  },

  view: vnode => {
    const fmt = vnode.attrs.fmt
    const closeButton = m('span.btn.btn-dark.text-light.py-1.px-2.small.fas.fa-times', {
      onclick: () => { ServerMessages.clear() }
    })
    if (ServerMessages.response) {
      const style = {
        position: 'absolute',
        top: 'calc(100vh - 80px)',
        left: '50px'
      }

      if (ServerMessages.response.error) {
        return m('.bg-danger.p-2.rounded', { style }, [
          closeButton,
          m('span.col-10', fmt(ServerMessages.response.error))
        ])
      }
      if (ServerMessages.response.message) {
        return m('.bg-success.p-2.rounded', { style }, [
          closeButton,
          m('span.col-10', fmt(ServerMessages.response.message))
        ])
      }
    }
  }
}

const MenuItem = {
  /**
   * @typedef {Object} Attrs
   * @property {FMT} fmt
   * @property {String} to - Route to go to
   * @property {String} [url] - Url to POST an empty body to
   * @property {String} name - Title of the button
   * @property {Boolean} [isChild] - Whether this item is a dropdown child
   * @property {Boolean} locked - Whether to show a lock next to the button
   * @property {String} [quickadd_path] - Route opened by an extra blue "+" sub-button rendered next to this item
   */

  /** @param {{attrs: Attrs}} vnode */
  view: vnode => {
    const mainLink = m(m.route.Link, {
      href: vnode.attrs.to,
      class: vnode.attrs.isChild ? 'dropdown-item' : ('btn btn-light' + (vnode.attrs.quickadd_path ? '' : ' mx-1')),
      onclick: () => {
        if (vnode.attrs.url) {
          postRequest(vnode.attrs.url, {}).catch(_.noop)
        } else {
          ServerMessages.clear() // clear error or message when a user clicks any menu button
        }
      }
    }, [
      vnode.attrs.fmt(vnode.attrs.name),
      vnode.attrs.locked ? m('.fas.fa-lock.ml-2') : undefined
    ])

    // A two-part button: the label links to the list, the blue "+" opens the related add form
    // (styled like the quick-add pluses in the expedition form).
    if (vnode.attrs.quickadd_path) {
      return m('.btn-group.mx-1', [
        m(m.route.Link, {
          href: vnode.attrs.quickadd_path,
          // only order's "+" (quickadd_path '/') gets wider px-5; pump-order keeps px-3
          class: 'btn btn-primary fas fa-plus d-flex align-items-center justify-content-center ' +
            (vnode.attrs.quickadd_path === '/' ? 'px-5' : 'px-3'),
          onclick: () => { ServerMessages.clear() }
        }),
        mainLink
      ])
    }

    return mainLink
  }
}

// TODO: find a better name
const makeMenu = (data, fmt) => {
  return data.map(item => {
    if (item.children) {
      return m(DropdownMenu, { name: fmt(item.name) },
        item.children.map(child => m(MenuItem, { fmt, isChild: true, ...child }))
      )
    }
    return m(MenuItem, { fmt, ...item })
  })
}

/** A complete layout of the page. Menu, footer and all that jazz. */
const Layout = {
  /**
   * @typedef {Object} Attrs
   *
   * @typedef {{attrs: Attrs, children: any[]}} Vnode
   */

  /** @type {Number} */
  loaderCount: 0,

  addLoader: () => {
    Layout.loaderCount++
    if (Layout.loaderCount > 1) {
      console.warn('loaderCount above one', Layout.loaderCount)
    }
  },

  removeLoader: () => {
    Layout.loaderCount--
    if (Layout.loaderCount < 0) {
      console.warn('loaderCount below zero', Layout.loaderCount)
      Layout.loaderCount = 0
    }
  },

  reset: () => {
    Layout.loaderCount = 0
  },

  /** @param {Vnode} vnode */
  view: vnode => {
    const fmt = vnode.attrs.fmt
    const menu = makeMenu(globalConfig.menu, fmt)

    const CompleteMenu = m('.mb-0.mx-0.p-0.sticky-top#menu-bar',
      m('.row.border-bottom.border-dark.m-0',
        m('.col-12.bg-secondary.pt-2.pb-2.d-flex.flex-wrap.pl-2', [
          // Left and right flanks both grow equally (flex: 1 1 0), so the menu buttons sit
          // centered between them. The left flank holds the logo and the right flank is empty -
          // since both flanks always balance, the buttons stay centered.
          m('.mr-2.d-flex.align-items-center', { style: { flex: '1 1 0' } },
            m('img', { src: 'static/asterix-logo.png', height: '35' })
          ),
          ...menu,
          m('', { style: { flex: '1 1 0' } })
        ])
      ),
      m(ServerMessages, { fmt })
    )

    return m('', { style: { width: 'fit-content', 'min-width': '100%' } }, [
      CompleteMenu,
      m('.pl-2.pr-3', vnode.children),
      m('.mt-4'),
      Layout.loaderCount ? m(FloatingLoading, { size: 100 }) : undefined
    ])
  }
}

/** Edit box for filtering context */
const FilterField = {
  /**
   * @typedef {Object} Attrs
   * @property {(value: String) => void} valueCallback - Will be called with the filter's value, should it change
   * @property {String} value - To be used to bind value into the field
   *
   * @typedef {Object} State - INTERNAL state. Do not access from outside
   * @property {String} localValue - Local buffer to prevent race conditions during typing
   * @property {Boolean} isTyping - Whether user is currently typing
   * @property {Number} typingTimeout - Timeout ID for typing detection
   */

  /** @param {{attrs: Attrs, state: State}} vnode */
  oninit: vnode => {
    vnode.state.localValue = vnode.attrs.value || ''
    vnode.state.isTyping = false
  },

  /** @param {{attrs: Attrs, state: State}} vnode */
  view: vnode => {
    // Update local value if external value changes (e.g., browser back button) and user is not typing
    if (vnode.attrs.value !== vnode.state.localValue && !vnode.state.isTyping) {
      vnode.state.localValue = vnode.attrs.value || ''
    }

    return m('.d-flex.align-items-center.mr-auto.ml-2', [
      m('.text-dark.mr-2.fas.fa-search'),
      m('input#filter_input', {
        value: vnode.state.localValue,
        oninput: /** Event */ e => {
          vnode.state.isTyping = true
          vnode.state.localValue = e.target.value
          const trimmedValue = e.target.value.trim()

          // Call callback on every input (debounced at list level)
          vnode.attrs.valueCallback(trimmedValue)
          e.redraw = false

          // Clear typing flag after delay
          clearTimeout(vnode.state.typingTimeout)
          vnode.state.typingTimeout = setTimeout(() => {
            vnode.state.isTyping = false
          }, 500)
        }
      })
    ])
  }
}

/** Generic multiline text input field */
const MultilineField = {
  /**
   * @typedef {Object} Attrs
   * @property {Boolean} required - Warn if value is empty
   * @property {String} placeholder - Will be used in the placeholder HTML attribute
   * @property {(value: String) => void} valueCallback - Will be called with the textarea's value, should it change
   *
   * @typedef {Object} State - INTERNAL state. Do not access from outside
   * @property {String} errorMessage - Used to store the value of the searchbox to store it in-between keystrokes
   * @property {Boolean} inputValid - Used to store the value of the searchbox to store it in-between keystrokes
   *
   * @typedef {{attrs: Attrs, state: State}} Vnode
   */

  /** @param {Vnode} vnode */
  oninit: vnode => {
    vnode.state.errorMessage = ''
    vnode.state.inputValid = true
  },

  /** TODO: move validation to backend
   * @param {Vnode} vnode
   * @param {Event} e
   */
  validate: (vnode, e) => {
    const fmt = vnode.attrs.fmt
    vnode.state.errorMessage = ''
    vnode.state.inputValid = true
    const value = (e.target.value === '') ? null : e.target.value

    if (vnode.attrs.required && !(value)) {
      vnode.state.inputValid = false
      vnode.state.errorMessage = fmt('{Value required}')
    }

    vnode.attrs.valueCallback?.(value)
  },

  /** @param {Vnode} vnode */
  view: vnode => {
    const warning = vnode.state.inputValid ? undefined : m('span.text-danger.ml-2', vnode.state.errorMessage)
    return m('', [
      m('span', [
        m(`textarea${vnode.attrs.stretch ? '.w-100' : ''}${vnode.state.inputValid ? '' : '.border-danger'}`, {
          value: vnode.attrs.value,
          placeholder: vnode.attrs.placeholder,
          onfocusout: e => { MultilineField.validate(vnode, e) }
        })
      ]),
      warning
    ])
  }
}

const InputField = {
  /* Generic input field with validations. Attrs:
        number     .. warn if value is not a number
        integer    .. warn if value is not an integer number
        password   .. as name says
        required   .. warn if value is empty
        size       .. size of input box
        stretch .. stretch input field to 100%
        name       .. name of form field. Necessary only for GET forms (printouts)
        disabled   .. if the field is disabled, default false. Copied to state, so can be changed later.
        autofocus
        placeholder
        hint
        datetime
        valueCallback
        enterCallback
  */
  oncreate: vnode => {
    vnode.state.input = vnode.dom.getElementsByTagName('input')[0]

    if (vnode.attrs.autofocus) {
      vnode.state.input.focus()
    }
  },

  oninit: vnode => {
    vnode.state.error_message = ''
    vnode.state.input_valid = true
  },

  validateEvent: (vnode, e) => {
    if (vnode.attrs.number) { // Field must support both decimal , and .
      e.target.value = e.target.value.replace(',', '.')
    }

    const value = e.target.value || null
    const originalValue = vnode.attrs.value || null

    InputField.validateValue(vnode, value)

    if (value !== originalValue) {
      vnode.attrs.valueCallback?.(value)
    }
  },

  validateValue: (vnode, value) => {
    const fmt = vnode.attrs.fmt
    vnode.state.error_message = ''
    vnode.state.input_valid = true

    if (vnode.attrs.number && isNaN(value)) {
      vnode.state.input_valid = false
      vnode.state.error_message = fmt('{Number required}')
    }

    if (vnode.attrs.integer && !isInteger(value)) {
      vnode.state.input_valid = false
      vnode.state.error_message = fmt('{Integer required}')
    }

    if (vnode.state.input_valid && vnode.attrs.required && !value) {
      vnode.state.input_valid = false
      vnode.state.error_message = fmt('{Value required}')
    }
  },

  view: vnode => {
    const fmt = vnode.attrs.fmt

    if (vnode.attrs.value) {
      InputField.validateValue(vnode, vnode.attrs.value)
    }

    // The model is only updated on focusout, so a redraw in between (e.g. a server response) would
    // overwrite what the user is typing. Keep showing the value the input already holds while it is
    // focused - Mithril skips writing a value the focused input already has, so the caret stays put.
    const focused = vnode.state.input && vnode.state.input === document.activeElement

    return m('', [
      m(`input${vnode.attrs.stretch ? '.w-100' : ''}${vnode.state.input_valid ? '' : '.border-danger'}`, {
        name: vnode.attrs.name,
        disabled: vnode.attrs.disabled,
        value: focused ? vnode.state.input.value : vnode.attrs.value,
        placeholder: fmt(vnode.attrs.placeholder),
        size: vnode.attrs.size,
        type: vnode.attrs.password ? 'password' : 'text',
        onfocusout: e => { InputField.validateEvent(vnode, e) },
        onkeyup: e => {
          e.redraw = false
          if (e.key === 'Enter') {
            InputField.validateEvent(vnode, e)
            vnode.attrs.enterCallback?.()
          }
        }
      }),
      vnode.attrs.datetime
        ? m(NowFiller, { fmt, valueCallback: vnode.attrs.valueCallback })
        : undefined,
      m(Hint, { title: vnode.attrs.hint }),
      vnode.state.input_valid ? null : m('span.text-danger.ml-2', vnode.state.error_message)
    ])
  }
}

/** Shows simple hint in the form of yellow question mark and popup text */
const Hint = {
  view: vnode => {
    if (vnode.attrs.title) {
      return m('.btn.btn-warning.ml-2.py-1.px-2.rounded-circle.fas.fa-question', { title: vnode.attrs.title })
    }
  }
}

/** Button that sends the current time to its callback */
const NowFiller = {
  /**
   * @typedef {Object} Attrs
   * @property {FMT} fmt
   * @property {String} title - Text of the button
   * @property {(value: String) => void} valueCallback - Will be called with the current datetime, formatted
   */

  /** @param {{attrs: Attrs}} vnode */
  view: vnode => {
    return m('button.btn.btn-primary.ml-2.py-1.px-2', {
      type: 'button',
      title: vnode.attrs.title,
      onclick: () => {
        vnode.attrs.valueCallback?.(stdCurrentDateTimeFormat())
      }
    }, vnode.attrs.fmt('{now}'))
  }
}

/** A simple checkbox wrapper */
const CheckBox = {
  /**
   * @typedef {Object} Attrs
   * @property {FMT} fmt
   * @property {String} id - Unique id, ised to link the label text for it to be clickable
   * @property {Boolean} value - The currently selected value
   * @property {String} label - Text next to the checkbox
   * @property {Boolean} autofocus - Will focus the input if true
   * @property {(value: String) => void} valueCallback - Will be called with the currently selected value
   */

  /** @param {{attrs: Attrs, dom: HTMLElement}} vnode */
  oncreate: vnode => {
    if (vnode.attrs.autofocus) {
      vnode.dom.getElementsByTagName('input')[0].focus()
    }
  },

  view: vnode => {
    const itemId = vnode.attrs.id.replace(/ /g, '-').toLowerCase()
    return m('', [
      m('input', {
        id: itemId,
        type: 'checkbox',
        checked: Boolean(vnode.attrs.value),
        onchange: e => {
          vnode.attrs.valueCallback?.(e.target.checked)
        }
      }),
      m('label.ml-2', { for: itemId }, vnode.attrs.fmt(vnode.attrs.label)),
      m(Hint, { title: vnode.attrs.fmt(vnode.attrs.hint) })
    ])
  }
}

/** A select box with autocomplete that can be filtered */
const SmartSelectBox = {
  /**
   * @typedef {Object} Attrs
   * @property {FMT} fmt
   * @property {Boolean} autofocus - Will focus the input if true
   * @property {any} value - The currently selected value
   * @property {String} [name] - Will be used as a key in <form> requests
   * @property {{id: String | null, title: String | null}} options - Description of the displayed select options
   * @property {(value: String) => void} valueCallback - Will be called with the currently selected value
   *
   * @typedef {Object} State
   * @property {Boolean} focusLostCount - Internal flag used to focus the field only once in its lifecycle
   */

  /** @param {{attrs: Attrs, state: State, dom: HTMLElement}} vnode */
  construct: vnode => {
    const fmt = vnode.attrs.fmt

    vnode.dom.slim?.destroy()

    new SlimSelect({ // eslint-disable-line no-new
      select: vnode.dom,
      settings: {
        searchText: ' ',
        searchPlaceholder: fmt('{search_placeholder}')
      },
      events: {
        searchFilter: (option, search) => {
          const normalizedText = normalized(option.text)

          for (const token of normalized(search).split(/\s+/)) {
            if (!normalizedText.includes(token)) {
              return false
            }
          }

          return true
        },
        afterChange: values => {
          vnode.attrs.valueCallback?.(decodeFromString(values[0]?.value))
          m.redraw() // This is not a normal event, so the re-draw needs to be manual
        },
        beforeClose: () => {
          vnode.state.focusLostCount = 0
        }
      },
      data: vnode.attrs.options.map(option => {
        return {
          text: option.title ? fmt(option.title) : EMPTY_OPTION_STRING,
          value: encodeToString(option.id),
          selected: option.id === vnode.attrs.value
        }
      })
    })

    const facadeDiv = vnode.dom.nextElementSibling
    const originalOnKeyDown = facadeDiv.onkeydown
    facadeDiv.onkeydown = /** KeyboardEvent */e => {
      // The original handler captures some browser control input for no reason. Override that behavior
      originalOnKeyDown(e)
      if (['ArrowUp', 'ArrowDown', 'Enter', ' ', 'Escape'].includes(e.key)) {
        return false
      }

      // Prevent inputting things like 0 when Ctrl+0 is pressed
      if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) {
        return
      }

      // Open the search if a printable character was pressed https://stackoverflow.com/a/66447494
      if (e.key.match(/^[\P{Cc}\P{Cn}\P{Cs}]$/gu)) {
        vnode.dom.slim.open()
      }
    }

    if (vnode.attrs.autofocus && vnode.state.focusLostCount > 0) {
      facadeDiv.focus()
      facadeDiv.addEventListener('focusout', () => {
        vnode.state.focusLostCount -= 1
      })
    }
  },

  oninit: vnode => {
    vnode.state.focusLostCount = 3 // The field gets unfocused twice (thanks to Mithril) before user can do anything
  },

  oncreate: vnode => SmartSelectBox.construct(vnode),

  onupdate: vnode => {
    if (vnode.dom.slim?.settings.isOpen) return
    SmartSelectBox.construct(vnode)
  },

  onremove: vnode => vnode.dom.slim?.destroy(),

  /** @param {{attrs: Attrs}} vnode */
  view: vnode => m('select.inline-flex', {
    name: vnode.attrs.name,
    style: { 'max-width': '500px' }
  })
}

/** More or less just a wrapper of a standard select box */
const SimpleSelectBox = {
  /**
   * @typedef {Object} Option
   * @property {any} id - The value of the option
   * @property {String} [title] - Value to be displayed. Omit to display {@link EMPTY_OPTION_STRING}
   * @property {Object} attrs - Additional attrs for the option
   *
   * @typedef {Object} Attrs
   * @property {FMT} fmt
   * @property {String | null} value - The currently selected value
   * @property {String} name - Required in <form>s; will be used as data key
   * @property {Array<Option>} options - The options to display
   * @property {Boolean} autofocus - Focus the element after its creation
   * @property {(value: String | null) => void} valueCallback - Will be called with the selectbox's value, should it change
   */

  /** @param {{attrs: Attrs, dom: HTMLSelectElement}} vnode */
  oncreate: vnode => {
    if (vnode.attrs.autofocus) {
      vnode.dom.focus()
    }
  },

  /** @param {{attrs: Attrs}} vnode */
  view: vnode => {
    const fmt = vnode.attrs.fmt

    const makeOption = option => m('option', {
      key: encodeToString(option.id),
      selected: option.id === vnode.attrs.value,
      value: encodeToString(option.id),
      ...option.attrs
    },
    option.title ? fmt(option.title) : EMPTY_OPTION_STRING
    )

    return m('select.mw-100', {
      key: encodeToString(vnode.attrs.value),
      name: vnode.attrs.name,
      value: encodeToString(vnode.attrs.value),
      onchange: e => {
        vnode.attrs.valueCallback?.(decodeFromString(e.target.value))
      }
    },
    vnode.attrs.options.map(option =>
      option.options
        ? m('optgroup', { key: encodeToString(vnode.attrs.value), label: fmt(option.title) }, option.options.map(makeOption))
        : makeOption(option)
    )
    )
  }
}

/** One line in the list view. Shows line in dark color, if {@link record.hidden} column is true */
const Line = {
  /**
   * @typedef {Object} Attrs
   * @property {FMT} fmt
   * @property {Object} record - Record to display
   * @property {Number} [nestedId] - ID of the object nested under. Will be used in actions url
   * @property {Array<Object>} columns - Column definitions
   * @property {list[Action]} [actions] - A list of additional actions to be displayed as buttons in "actions" column
   * @property {{[key: string]: (record: Object) => void }} actionButtonsCallbacks - Callbacks for {@link action}'s `callback_name`s
   */

  view: /** {attrs: Attrs} */ vnode => {
    const columns = []
    const record = vnode.attrs.record
    const hidden = record.hidden === true
    const tag = 'td.py-0.px-3' + (hidden ? '.italic.bg-secondary.text-white' : '')
    const fmt = vnode.attrs.fmt
    const callbacks = vnode.attrs.actionButtonsCallbacks

    const replaceUrlPlaceholders = url =>
      url.replace(/:(\w+)/g, (match, name) => {
        if (name === 'nestedId') return vnode.attrs.nestedId
        if (name === 'id') return record.id
        if (name === 'lang') return vnode.attrs.lang
        if (name in record) return record[name]
        return match
      })

    for (const column of vnode.attrs.columns) {
      const columnValue = record[column.k]

      const columnDisplayValue = (columnValue && typeof columnValue === 'object' && columnValue.element)
        ? m(columnValue.element, fmt(columnValue.text))
        // Display boolean variables in a sane way
        : fmt(_.get({ true: '{Yes}', false: '{No}' }, columnValue, columnValue))

      const columnItem = column.route
        ? m(m.route.Link, { href: replaceUrlPlaceholders(resolveRoute(column.route)) }, columnDisplayValue)
        : columnDisplayValue

      columns.push(m(`${tag}.text-${column.align || 'left'}`, columnItem))
    }

    const actionIcons = []

    if (vnode.attrs.actions) {
      for (const action of vnode.attrs.actions) {
        if (action.visible_if && !record[action.visible_if]) continue

        const commonProperties = {
          class: `m-1 bigger text-primary pointer fas ${action.icon || ''}`,
          title: fmt(action.name),
          style: { 'text-decoration': 'none' }
        }

        if (action.route) {
          actionIcons.push(m(m.route.Link, {
            href: replaceUrlPlaceholders(resolveRoute(action.route)),
            ...commonProperties
          }))
        } else if (action.href) {
          actionIcons.push(m('a', {
            href: replaceUrlPlaceholders(action.href),
            ...commonProperties
          }))
        } else if (action.callback_name && action.callback_name in callbacks) {
          actionIcons.push(m('a', {
            onclick: () => { callbacks[action.callback_name](record) },
            ...commonProperties
          }))
        } else {
          console.warn('No action set', action)
        }
      }
    }

    // Keyed off the list's action definitions, not off what this row ended up rendering: ListView adds the
    // "actions" header under the very same condition, so a row whose actions are all filtered out by
    // visible_if still needs the cell or it comes up one column short.
    if (vnode.attrs.actions?.length) {
      columns.push(m(tag, actionIcons))
    }

    return m('tr', columns)
  }
}

/** For listviews. Changes sort order on click. */
const ColumnHeader = {
  /**
   * @typedef {Object} Attrs
   * @property {String} displayName - Name of the column in UI
   * @property {String | null} orderBy - The db column this column orders by
   * @property {String} tableOrderBy - Which column the whole table is ordered by
   * @property {(orderBy: String) => void} onSortChange - Called when user clicks on a sortable header with new orderBy value
   */

  view: /** {attrs: Attrs} */ vnode => {
    if (!vnode.attrs.orderBy) {
      return m('th.p-1', vnode.attrs.displayName)
    }

    const arrowConfig = {
      up: ['fa-sort-up', `!${vnode.attrs.orderBy}`],
      down: ['fa-sort-down', ''],
      both: ['fa-sort', vnode.attrs.orderBy]
    }

    const [arrowClass, nextSortString] = (vnode.attrs.orderBy === _.trimStart(vnode.attrs.tableOrderBy, '!'))
      ? (vnode.attrs.tableOrderBy.startsWith('!') ? arrowConfig.down : arrowConfig.up)
      : arrowConfig.both

    return m('th.p-1', {
      onclick: () => { vnode.attrs.onSortChange(nextSortString) }
    },
    [
      vnode.attrs.displayName,
      m(`i.fas.pl-2.pointer.${arrowClass}`, { style: { display: 'inline' } })
    ])
  }
}

/** Universal list supporting list of items, open detail, delete item, search */
const ListView = {
  /**
   * @typedef {Object} Action - Additional actions to be displayed as buttons in "actions" column.
   *                            Only one of {@link url}, {@link href}, and {@link callback} should be set. They take precedence in this order
   * @property {String} name - Hint text displayed on hover
   * @property {String} route - Mithril route. :id and :lang will be substituted, if present, using the current record.id and language
   * @property {String} href - Non-Mithril url, for example, printouts. Same substitution as for {@link route}
   * @property {String} callback_name - Predefined callbacks. `hide`, `delete`. Will be called on click, as: callback(lineVnode, record)
   * @property {String} icon - icon definition in dot notation (e.r. ".fas.fa-eye")
   *
   * @typedef {Object} Column
   * @property {String} k - Key to the {@link Attrs.data}
   * @property {String} title - Display text of the column
   * @property {Boolean} sortable - Whether the data can be ordered by this column
   *
   * @typedef {Object} Attrs
   * @property {FMT} fmt
   * @property {String} modelName - Name of the model in the DB
   * @property {Number} [nestedId] - ID of the object nested under. Will be used in actions url
   * @property {Array[Object]} data - Loaded data
   * @property {String} orderBy - Column name to order by. Prefix "!" reverses the order
   * @property {Boolean} isLoading - Whether we are loading data -> Shows spinner
   * @property {list[Action]} actions - A list of actions
   * @property {Array[Column]} columns - Columns accessor keys and labels
   * @property {Pagination} [pagination] - Pagination section
   * @property {(orderBy: String) => void} onSortChange - Called when user clicks on a sortable header with new orderBy value
   * @property {{[key: string]: (record: Object) => void }} actionButtonsCallbacks - Callbacks for {@link action}'s `callback_name`s
   *
   * @typedef {{attrs: Attrs}} Vnode
   */

  onupdate: /** Vnode */ vnode => {
    const tHead = vnode.dom.querySelector('thead')

    if (!tHead) {
      // Do not observe anything if the table is not loaded yet
      return
    }
    new window.ResizeObserver((elements) => {
      // -1 because the {position: sticky} does not work on borders. It would cause a 1px tall gap.
      tHead.style.top = `${elements[0].contentRect.height - 1}px`
    })
      .observe(document.getElementById('menu-bar'))
  },

  view: /** Vnode */ vnode => {
    const fmt = vnode.attrs.fmt

    const lines = vnode.attrs.data.map(
      record => m(Line, {
        fmt,
        lang: vnode.attrs.lang,
        record,
        nestedId: vnode.attrs.nestedId,
        actions: vnode.attrs.actions,
        columns: vnode.attrs.columns,
        actionButtonsCallbacks: vnode.attrs.actionButtonsCallbacks
      })
    )

    const columnDefinitions = [...vnode.attrs.columns] // Make a shallow copy
    // Same condition Line uses for the matching cell. A locked (view-only) table still renders its
    // actions - the edit icon opens the read-only detail - so skipping only the header here left
    // every row one cell wider than the header, and the last column looked unlabelled.
    if (vnode.attrs.actions.length) {
      columnDefinitions.push({ k: 'actions', title: '{actions}', order_by: null })
    }

    const columns = columnDefinitions.map(
      column => m(ColumnHeader, {
        orderBy: column.order_by,
        displayName: fmt(column.title),
        tableOrderBy: vnode.attrs.orderBy,
        onSortChange: vnode.attrs.onSortChange
      })
    )

    return m('', {}, [
      vnode.attrs.isLoading
        ? m(Loading)
        : [
            m('table.pl-2.table.table-bordered.table-striped.table-hover.align-center.w-auto.table-min-width', [
              m('thead.thead-dark.sticky-header', m('tr', columns)),
              m('tbody', lines)
            ]),
            vnode.attrs.pagination
          ]
    ])
  }
}

/** Page supporting ListView, which must be passed as a first child */
const ListLayout = {
  /**
   * @typedef {Object} Attrs
   * @property {FMT} fmt
   * @property {Array<Object>} buttons - Buttons to be displayed in the footer
   * @property {Boolean} showHidden - The state of the hidden checkbox
   * @property {(value: Boolean) => void} [onShowHiddenChange] - Will be called with the Hide check-box's value, should it change
   * @property {String} filterString - Can be used to bind value into the field
   * @property {(value: String) => void} [onFilterChange] - Will be called with the filter's value, should it change
   * @property {Boolean} autoRefresh - The state of the refresh checkbox
   * @property {Number} refreshTimer - How long it has been since last refresh
   * @property {(value: Boolean) => void} [onReloadChange] - Will be called with the Auto-refresh check-box's value, should it change
   */

  /** @param {{attrs: Attrs, children: Array}} vnode */
  view: vnode => {
    const fmt = vnode.attrs.fmt

    const showHiddenCheckbox = vnode.attrs.onShowHiddenChange
      ? m('.p-0.small.m-3', m(CheckBox, {
        fmt,
        value: vnode.attrs.showHidden,
        label: '{Show hidden records}',
        id: 'show-hidden-items-checkbox',
        valueCallback: vnode.attrs.onShowHiddenChange
      }))
      : undefined

    const autoRefreshCheckbox = vnode.attrs.onReloadChange
      ? m('.p-0.small.m-3', m(CheckBox, {
        fmt,
        value: vnode.attrs.autoRefresh,
        label: `{auto_refresh_in} ${Math.round((LISTING_RELOAD_INTERVAL - vnode.attrs.refreshTimer) / 1000)}s`,
        id: 'reload-items-checkbox',
        valueCallback: vnode.attrs.onReloadChange
      }))
      : undefined

    const filterField = vnode.attrs.onFilterChange
      ? m(FilterField, {
        value: vnode.attrs.filterString,
        valueCallback: vnode.attrs.onFilterChange
      })
      : undefined

    return m(Layout,
      { fmt },
      [
        ...vnode.children,
        m('.my-5.py-2', ''), // Naive spacer - otherwise ListButtonsArea would cover the last two or so records
        m(ListButtonsArea, [
          filterField,
          autoRefreshCheckbox,
          showHiddenCheckbox,
          ...vnode.attrs.buttons
        ])
      ]
    )
  }
}

/** One row of surcharges */
const SurchargeRow = {
  /**
   * @typedef {Object} Attrs
   * @property {FMT} fmt
   * @property {Object} surcharge - The selected surcharge
   * @property {Array<Object>} allSurcharges - All the available surcharges
   * @property {Boolean} autofocus - Will focus the input if true
   * @property {() => void} deleteCallback - Will be called when the user clicks on the trashcan
   * @property {(surcharge: Object) => void} valueCallback - Will be called with the selected Surcharge id
   */

  /** @param {{attrs: Attrs}} vnode */
  view: vnode => {
    const fmt = vnode.attrs.fmt
    const surcharge = vnode.attrs.surcharge || {}
    const allSurcharges = [{}].concat(vnode.attrs.allSurcharges || [])

    // Information text, how price was calculated. Nothing to explain without module_prices:
    // the surcharge is then just a named item with an amount.
    let surchargeInfo
    const surchargePrice = surcharge._price_str || ''
    if (!globalConfig.setup.module_prices) {
      surchargeInfo = ''
    } else if (surcharge.price_type === SurchargePriceType.FIXED) {
      surchargeInfo = surchargePrice
    } else if (surcharge.price_type === SurchargePriceType.PER_CUBIC_METER) {
      surchargeInfo = fmt(`${surchargePrice} * {m3_amount}`)
    } else if (surcharge.price_type === SurchargePriceType.PER_OTHER_UNIT) {
      surchargeInfo = `${surchargePrice} * ${surcharge.amount || '?'} ${surcharge.unit_name} = ${surcharge._price_total_str || ''}`
    } else if (surcharge.price_type === SurchargePriceType.PRICE_CONCRETE_PERCENTAGE) {
      surchargeInfo = fmt(`${surcharge.percentage} % * {price_concrete}`)
    }

    const surchargeDetails = surcharge.name
      ? [
          m('.col-1.text-right.fas.fa-trash.text-danger', { onclick: vnode.attrs.deleteCallback }),
          m('.col-md-4.col-sm-6.px-1.italic', surchargeInfo)
        ]
      : []

    return m('.d-flex.mt-1.p-0.bg-light',
      [
        m('.col-sm-6.col-md-3.pr-1',
          m(SimpleSelectBox, {
            fmt,
            autofocus: vnode.attrs.autofocus,
            value: surcharge.id,
            options: allSurcharges.map(s => { return { id: s.id, title: (s.name || '') + (s._template_diff ? ` ${s._template_diff}` : '') } }),
            valueCallback: surchargeId => { vnode.attrs.valueCallback?.(_.find(allSurcharges, s => s.id === surchargeId) || {}) }
          })
        ),
        m('.col-md-2.col-sm-6.px-1',
          surcharge.price_type === 2
            ? m(InputField, {
              fmt,
              value: surcharge.amount,
              number: true,
              stretch: true,
              valueCallback: value => {
                surcharge.amount = value
                vnode.attrs.valueCallback?.(surcharge)
              }
            })
            : undefined
        ),
        m('.col-2.text-right.small.px-1', surcharge.unit_name ? `X\u00A0${surcharge.unit_name}` : undefined),
        ...surchargeDetails
      ]
    )
  }
}

/** Edit row for value modification in Expeditions form. Shows original value and editbox for entering a new one */
const ValueModifier = {
  /**
   * @typedef {Object} Attrs
   * @property {FMT} fmt
   * @property {String} title - Displayed on very left
   * @property {String} valueStr - Pre-formatted original value with symbol (rendered on backend)
   * @property {String} modifiedValueStr - Pre-formatted modified value with symbol (for read-only fields, rendered on backend)
   * @property {Boolean} bold - The text will be bold if true
   * @property {Boolean} readOnly - The modified value will be read only if true
   * @property {Boolean} autofocus - Will focus the input if true
   * @property {String | null} modifiedValue - Modified value (used in EditOrder: price was already modified)
   * @property {Array<{id: Number?, title: String?}>} options - Will produce a select input instead of normal one
   * @property {(value: String | null) => void} valueCallback - Will be called with the modified value, should it change
   */

  /** @param {{attrs: Attrs}} vnode */
  view: vnode => {
    const fmt = vnode.attrs.fmt
    const valueString = vnode.attrs.valueStr || ''

    let modifiedValue

    if (vnode.attrs.readOnly) {
      modifiedValue = vnode.attrs.modifiedValueStr || ''
    } else if (vnode.attrs.options) {
      modifiedValue = m(SimpleSelectBox, {
        value: vnode.attrs.modifiedValue,
        options: vnode.attrs.options,
        autofocus: vnode.attrs.autofocus,
        fmt,
        valueCallback: vnode.attrs.valueCallback
      })
    } else {
      modifiedValue = m(InputField, {
        value: vnode.attrs.modifiedValue,
        number: true,
        autofocus: vnode.attrs.autofocus,
        fmt,
        placeholder: fmt('{corrected_value_placeholder}'),
        valueCallback: value => {
          vnode.attrs.valueCallback?.((value === null) ? null : Number(value))
        }
      })
    }

    return m(`.border-bottom.mx-1.py-1.d-flex${vnode.attrs.bold ? '.bold' : ''}`,
      m('.col-3', fmt(vnode.attrs.title)),
      m('.col-3', valueString),
      m('.col-6', modifiedValue)
    )
  }
}

const FieldDefaults = {
  /**
   * @typedef {Object} Attrs
   * @property {FMT} fmt
   * @property {String | null} value - Original value
   * @property {Boolean} autofocus - Will focus the selectbox if true
   * @property {Array<{id: Number?, title: String?}>} options - options for the select
   * @property {(value: String | null) => void} valueCallback - Will be called with the modified value, should it change
   * @property {() => void} buttonPress - Will be called when the apply button is pressed
   */

  /** @param {{attrs: Attrs}} vnode */
  view: vnode => {
    const fmt = vnode.attrs.fmt

    return m('',
      m(SimpleSelectBox, {
        value: vnode.attrs.value || null,
        options: vnode.attrs.options,
        autofocus: vnode.attrs.autofocus,
        fmt,
        valueCallback: vnode.attrs.valueCallback
      }),
      m('.btn.btn-primary.ml-2.px-2.py-0', { onclick: vnode.attrs.buttonPress }, fmt('{Apply}'))
    )
  }
}

// TODO: find a better name
const oneToElement = (vnode, field, onChange, onEnter, applyFocus) => {
  const fmt = vnode.attrs.fmt

  if (field.group) {
    const nestedElements = []
    let focusApplied = false

    for (const nestedField of field.group) {
      const [element, focused] = oneToElement(vnode, nestedField, onChange, onEnter, applyFocus)

      if (element) {
        nestedElements.push(element)

        if (focused) {
          focusApplied = true
          applyFocus = false
        }
      }
    }

    return [m('', nestedElements), focusApplied]
  }

  // TODO: try to modify so that it switches on something like field.type and becomes a lot of if-else-if-else-ifs
  if (field._lang) {
    // Necessary to 'transfer' language to backend in printout forms
    //    (otherwise backend does not know language used in URL until page is reloaded via F5 or so)
    vnode.state.data.lang = vnode.attrs.lang
    return [undefined, false]
  }
  if (field._button) {
    return [
      m('button.btn.btn-primary.mr-2.py-1.px-2', {
        type: 'button',
        disabled: field.disabled,
        onclick: () => {
          vnode.state.data[field.k || '_action'] = field._action
          onChange?.(field.k)
        }
      }, fmt(field.button_label)),
      false
    ]
  }
  if (field._read_only && !field._value_modifier) {
    let classes = ''
    for (const clas of ['bold', 'italic', 'small']) {
      if (field['_' + clas]) {
        classes += '.' + clas
      }
    }
    return [m(classes, fmt(vnode.state.data[field.k], field._translation_args)), false]
  }
  if (field._infobox) {
    return [
      m(InfoBox, { level: field.level, text: fmt(field.text), route: field.route }),
      false
    ]
  }
  if (field._header) {
    return [m(FormSubHeader, fmt(field.title, field._translation_args)), false]
  }

  const valueCallback = value => {
    vnode.state.data[field.k] = value
    onChange?.(field.k, value)
  }

  if (field._field_defaults) {
    return [
      m(FieldDefaults, {
        value: vnode.state.data[field.k],
        options: field.options,
        autofocus: applyFocus,
        fmt,
        valueCallback,
        buttonPress: () => {
          vnode.state.data._action = 'apply'
          onChange?.(field.k)
        }
      }),
      applyFocus
    ]
  }

  if (field._bool) {
    // The checkbox does not have the "not selected" option, so we need to add select "false" to the data.
    // If we don't, and user just presses submit, the seemingly selected value will be ignored
    vnode.state.data[field.k] = Boolean(vnode.state.data[field.k])
    return [
      m(CheckBox, {
        id: `${field.k}-${field.label}`,
        value: vnode.state.data[field.k],
        label: field.label,
        autofocus: applyFocus,
        hint: field.hint,
        fmt,
        valueCallback
      }),
      applyFocus
    ]
  }

  if (field._surcharges) {
    return [
      field._surcharges.map((surcharge, index) => m(SurchargeRow, {
        fmt,
        surcharge,
        allSurcharges: field._all_surcharges,
        autofocus: applyFocus && index === 0,
        valueCallback: surcharge => {
          field._surcharges[index] = surcharge
          valueCallback(field._surcharges)
        },
        deleteCallback: () => {
          field._surcharges[index] = {}
          valueCallback(field._surcharges)
        }
      })),
      applyFocus
    ]
  }

  if (field._materials) {
    return [
      field._materials.map((material, index) => m(RecipeMaterialRow, {
        fmt,
        material,
        allMaterials: field._all_materials,
        locked: field._locked,
        valueCallback: material => {
          if (Object.values({ ...material, recipe: undefined }).some(Boolean)) {
            field._materials[index] = material
          } else {
            field._materials[index] = {}
          }

          valueCallback(field._materials)
        },
        deleteCallback: () => {
          field._materials[index] = {}
          valueCallback(field._materials)
        }
      })),
      false
    ]
  }

  if (field._pricelists) {
    return [
      m(PricelistsTable, {
        matrix: field._pricelists,
        fmt,
        stateData: vnode.state.data,
        stateKey: field.k
      }),
      false
    ]
  }

  if (field._pricelist_recipes) {
    return [
      field._pricelist_recipes.map((pr, index) => m(PricelistRecipeRow, {
        fmt,
        pricelistRecipe: pr,
        valueCallback: pr => {
          field._pricelist_recipes[index] = pr
          valueCallback(field._pricelist_recipes)
        }
      })),
      false
    ]
  }

  if (field._pricelist_materials) {
    return [
      field._pricelist_materials.map((pm, index) => m(PricelistMaterialRow, {
        fmt,
        pricelistMaterial: pm,
        valueCallback: pm => {
          field._pricelist_materials[index] = pm
          valueCallback(field._pricelist_materials)
        }
      })),
      false
    ]
  }

  if (field._value_modifier) {
    return [
      m(ValueModifier, {
        modifiedValue: vnode.state.data[field.k],
        valueStr: field._value_str,
        modifiedValueStr: field._modified_value_str,
        fmt,
        autofocus: applyFocus,
        title: field.title,
        bold: field._bold,
        readOnly: field._read_only,
        options: field.options,
        valueCallback
      }),
      applyFocus
    ]
  }

  if (field.options) {
    if (!(field.k in vnode.state.data) && field.options[0]?.id) {
      // The selectbox does not have the "not selected" option, so we need to select the first option.
      // If we don't, and user just presses submit, the seemingly selected value will be ignored
      vnode.state.data[field.k] = field.options[0].id
    }
    // Related to the `if` above, we need to feed something to the selects for them to display what we want, even if we do not have it in data
    const displayValue = _.isNil(vnode.state.data[field.k]) ? null : vnode.state.data[field.k]

    if (field._smart) {
      return [
        m('.d-flex', [
          m(SmartSelectBox, {
            value: displayValue,
            options: field.options,
            autofocus: applyFocus,
            fmt,
            valueCallback
          }),
          field.quickadd_path
            ? m('.ml-2', m(ButtonAdd, {
              small: true,
              onClick: () => {
                RedirectStack.push({
                  redirectTo: resolveRoute(field.quickadd_path),
                  returnData: vnode.state.data,
                  returnKey: field.k
                })
              }
            }))
            : undefined
        ]),
        applyFocus
      ]
    }
    return [
      m(SimpleSelectBox, {
        value: displayValue,
        options: field.options,
        autofocus: applyFocus,
        fmt,
        valueCallback
      }),
      applyFocus
    ]
  }

  // Make the data to be a string. If we don't, it will be stringified onFocusOut, triggering onChange unnecessarily
  if (vnode.state.data[field.k]) {
    vnode.state.data[field.k] = String(vnode.state.data[field.k])
  }

  return [
    m(field._multiline ? MultilineField : InputField, {
      value: vnode.state.data[field.k],
      required: field.required,
      number: field.number,
      integer: field.integer,
      password: field._password,
      placeholder: field.placeholder,
      stretch: field.stretch,
      disabled: field.disabled,
      hint: fmt(field.hint),
      datetime: field.datetime,
      autofocus: applyFocus,
      fmt,
      valueCallback,
      enterCallback: onEnter
    }),
    applyFocus
  ]
}

// TODO: find a better name
const formToElements = (vnode, formFields, onChange = null, onEnter = null, applyFocus = true) => {
  const fmt = vnode.attrs.fmt

  return formFields.map(formField => {
    if (Array.isArray(formField)) {
      const ret = formToElements(vnode, formField, onChange, onEnter, applyFocus)
      applyFocus = false
      return ret
    }

    const [element, focused] = oneToElement(vnode, formField, onChange, onEnter, applyFocus)

    if (typeof element === 'undefined') {
      return undefined
    }

    if (focused) {
      applyFocus = false
    }

    if (formField.full_width) {
      return m('.row.py-1', m('.col-12', element))
    }

    return m('.row.py-1', [
      m('.col-sm-12.col-md-4.text-md-right', { class: formField.required ? 'bold' : '' }, fmt(formField.title)),
      m('.col-sm-12.col-md-8', element)
    ])
  }).filter(Boolean)
}

/**
 * Use form definition to produce submit button
 *
 * @param {FMT} fmt
 * @param {Object} formButton - Button definition
 * @param {{[name: String]: (_options: {url: String, extraData?: Object, previousResult: any}) => Promise<{breakChain?: Boolean, result?: any}>}} callbacks - Will be used for buttons of type callback
 */
const oneToSubmit = (fmt, formButton, callbacks) => {
  if (formButton.type === 'callback') {
    return m(CallbackButton, {
      fmt,
      text: formButton.text,
      icon: formButton.icon,
      secondary: formButton.secondary,
      onClick: () => {
        async function asyncRunner () {
          let previousResult

          for (const name of formButton.callbacks) {
            const result = await callbacks[name]?.({ url: formButton.url, extraData: formButton.data, previousResult })
            if (result?.breakChain) {
              break
            }
            previousResult = result?.result
          }
        }

        asyncRunner().catch(error => {
          console.debug('Form button callback error', error)
          ServerMessages.set({ error: error?.response?.error || error?.message || `Error: ${error}` })
        })
      }
    })
  }
  if (formButton.type === 'export') {
    return m(FileExportDropdown, {
      fmt,
      onClick: exportType => callbacks.submit({ url: formButton.url, extraData: { export_format: exportType } })
    })
  }
  console.warn('Unknown form button type:', formButton.type)
}

/**
 * Use form definition to produce submit button row
 *
 * @param {FMT} fmt
 * @param {Array<Object>} formButtons - Buttons definition
 * @param {{[name: String]: (url: String, data?: Object) => Promise<Boolean>}} callbacks - Will be used for buttons of type callback
 */
const formToSubmitArea = (fmt, formButtons, callbacks) => {
  return m(SubmitButtonsArea, formButtons.map(formButton => oneToSubmit(fmt, formButton, callbacks)))
}

/**
 * Split an array of fields by its shape
 * @param {any[]} fields
 * @return any[]
 */
const splitFieldsToColumns = fields => {
  const ret = []
  // The fields are split by columns, but we have rows
  let columnBuffer = []

  for (const field of fields) {
    if (!Array.isArray(field)) {
      ret.push(field)
      continue
    }

    columnBuffer.push(m('.col-sm-12.col-md-6', field))

    if (columnBuffer.length === 2) {
      ret.push(m('.row', columnBuffer))
      columnBuffer = []
    }
  }

  if (columnBuffer.length > 0) {
    ret.push(m('.row', columnBuffer))
  }

  return ret
}

const OpenModelOrNonModel = (modelOrNonModelName, { getChildren } = {}) => {
  const urlPathData = () => {
    // We want to get all the route params, but Mithril mixes-in its fragment query params,
    // such as `bar` in http://localhost:7781?foo=123/#!/replace_materials?bar=456.
    // There is no built-in way to separate them out
    const queryParams = m.parseQueryString(m.route.get().split('?')[1])

    const result = {}
    for (const key in m.route.param()) {
      if (!Object.prototype.hasOwnProperty.call(queryParams, key)) {
        result[key] = m.route.param(key)
      }
    }
    return result
  }

  const defaultForm = () => ({ fields: [], columns: 1 })

  const updateForm = vnode => {
    vnode.state.data = { ...vnode.state.data, ...urlPathData() }

    // Fast tabbing between fields puts several of these in flight at once. Responses can land
    // out of order and an older one would overwrite newer data, so only the latest one applies.
    const seq = vnode.state._formSeq = (vnode.state._formSeq || 0) + 1

    postRequest(`detail/${modelOrNonModelName}/${vnode.attrs.id || ''}`, vnode.state.data)
      .then(data => {
        if (seq !== vnode.state._formSeq) return

        data = data || {}
        vnode.state._form = data.form || defaultForm()
        vnode.state.data = data.item || {}
      }, _.noop)
  }

  const initStateData = vnode => {
    vnode.state.data = RedirectStack.current()?.data || {}

    updateForm(vnode)
  }

  return {
    oninit: vnode => {
      vnode.state._form = defaultForm()

      initStateData(vnode)
    },

    /** This whole bullshit is here to allow for navigation to the same route: https://github.com/MithrilJS/mithril.js/issues/1180 */
    onbeforeupdate: vnode => {
      if (RedirectStack.current()?.data._action) {
        initStateData(vnode)
        RedirectStack.current().data = {}
      }
      return true
    },

    view: vnode => {
      const fmt = vnode.attrs.fmt
      const form = vnode.state._form
      const fields = formToElements(vnode, form.fields, () => { updateForm(vnode) })

      if (!fields.length) {
        return m(Layout, { fmt }, m(Loading))
      }

      const commonCallbacks = {
        back: RedirectStack.back,
        clear: () => {
          vnode.state.data = {}
          vnode.state._form = defaultForm()
          updateForm(vnode)
        }
      }

      if (vnode.state.confirmation) {
        return m(ConfirmationPopup, {
          fmt,
          definition: form.confirmation_popup,
          callbacks: {
            ...commonCallbacks,
            cancel: () => { vnode.state.confirmation = false },
            confirm: async ({ url, extraData } = {}) => {
              await postRequest(url, { ...vnode.state.data, ...extraData })
              vnode.state.confirmation = false
            }
          }
        })
      }

      const children = getChildren?.(vnode, fmt, () => updateForm(vnode))
      const callbacks = { // Check out `oneToSubmit` for usage
        ...commonCallbacks,
        submit: async ({ url, extraData } = {}) => {
          if (!url) {
            console.warn('No url provided to the `submit` callback, skipping')
            return
          }
          const submitData = { ...vnode.state.data, ...(extraData || {}) }

          if (vnode.attrs.id !== null && form.confirmation_popup) {
            vnode.state.confirmation = true
            return { breakChain: true }
          }

          if (form.submit_method === 'get') {
            openUrl(`${url}?${m.buildQueryString(submitData)}`)
          } else if (form.submit_method === 'post') {
            const result = await postRequest(url, submitData)

            RedirectStack.setReturnValue(result?.id)

            return { result }
          }
        },
        /** Expects the `submit` callback to be called right before this one, to use the `previousResult` */
        print: ({ previousResult }) => {
          if (previousResult._print_url) {
            printUrl(`${previousResult._print_url}?lang=${vnode.attrs.lang}`)
          }
          return { result: previousResult }
        },
        /** Expects the `submit` or `print` callback to be called right before this one, to use the `previousResult` */
        expeditions_redirects: ({ previousResult }) => {
          if (vnode.state.data.add_pump_order) {
            const redirectData = {
              _from_expedition: true,
              order: previousResult.order_id,
              customer: vnode.state.data.customer_id,
              customer_name: vnode.state.data.customer_name,
              construction_site: vnode.state.data.construction_site_id,
              construction_site_name: vnode.state.data.construction_site_name
            }
            // The tax document is finalised by the pump-order submit so the resulting doc covers
            // both the order and the pump in a single create_with_orders call. Carry the operator's
            // intent (payment type + source order id) through to that submit.
            if (previousResult._pending_taxdocument_payment_type !== null && previousResult._pending_taxdocument_payment_type !== undefined) {
              redirectData._pending_taxdocument_payment_type = previousResult._pending_taxdocument_payment_type
              redirectData._pending_taxdocument_order_id = previousResult.order_id
            }
            RedirectStack.push({
              redirectTo: '/pump_order_open',
              redirectData
            })
          }

          if (vnode.state.data._sample_note && globalConfig.setup.module_samples) {
            RedirectStack.push({
              redirectTo: `/sample_msgbox/${vnode.state.data.recipe_id}`,
              returnData: RedirectStack.current()?.data
            })
          }
        }
      }

      const submitArea = formToSubmitArea(fmt, form?.buttons || [], callbacks)
      const submitAndChildren = form.buttons_after_children ? [children, submitArea] : [submitArea, children]

      return m(Layout, { fmt },
        [
          form.header ? m(FormHeader, { fmt, text: form.header }) : m('.mt-4'),
          m('.col-12', splitFieldsToColumns(fields)),
          m('.mt-4'),
          ...submitAndChildren
        ]
      )
    }
  }
}

/** Button for {@link Pagination} */
const PaginationButton = {
  /**
   * @typedef {Object} Attrs
   * @property {Boolean} isDisabled - Whether to gray out the button
   * @property {String} text - Text of the button
   * @property {() => void} pageCallback - Will be called on click
   */

  /** @param {{attrs: Attrs}} vnode */
  view: vnode => {
    return m('li.page-item', { class: vnode.attrs.isDisabled ? 'disabled' : 'pointer' },
      m('span.page-link', {
        onclick: vnode.attrs.isDisabled ? undefined : vnode.attrs.pageCallback
      }, vnode.attrs.text)
    )
  }
}

/** Pagination for {@link ListModel} */
const Pagination = {
  /**
   * @typedef {Object} Attrs
   * @property {Number} page - The currently selected page
   * @property {Number} pageSize - Size of the page
   * @property {Number} totalCount - Total page count
   * @property {(value: Number) => void} pageCallback - Will be called with the new page value
   */

  /** @param {{attrs: Attrs}} vnode */
  view: vnode => {
    const { totalCount: count, page, pageSize, pageCallback } = vnode.attrs

    const pageCount = Math.ceil(count / pageSize)
    const isFirstPage = page === 1
    const isLastPage = page >= pageCount

    return m('ul.pagination.justify-content-center.unselectable', [
      m(PaginationButton, {
        isDisabled: isFirstPage,
        text: '<<',
        pageCallback: () => pageCallback(0)
      }),
      m(PaginationButton, {
        isDisabled: isFirstPage,
        text: '<',
        pageCallback: () => pageCallback(page - 1)
      }),
      m(PaginationButton, {
        isDisabled: true,
        text: `${pageCount ? page : 0} / ${pageCount}`
      }),
      m(PaginationButton, {
        isDisabled: isLastPage,
        text: '>',
        pageCallback: () => pageCallback(page + 1)
      }),
      m(PaginationButton, {
        isDisabled: isLastPage,
        text: '>>',
        pageCallback: () => pageCallback(pageCount)
      })
    ])
  }
}

/**
 * Create a List Layout for {@link modelName}
 * @param {String} modelName Name of the model to list
 * @param {Object} [options] Additional options
 * @param {Array<String>} [options.filterBy] - Array of filters to send to the db. Check `atxdispatch.web.filter_query` for more info
 * @param {String} [options.nestedFilter] - Name of the fk column name. Used with nestedId path param
 * @param {Boolean} [options.viewOnly] - Render only the ListView, without the Layout
 */
const ListModel = (modelName, { filterBy = [], nestedFilter = '', viewOnly = false, autoRefresh = !viewOnly } = {}) => {
  /**
   * @typedef {Object} Attrs
   * @property {FMT} fmt
   * @property {String} lang
   * @property {Number} [nestedId] - ID of the object nested under. Will be used with {@link nestedFilter}
   *
   * @typedef {Object} Form
   * @property {Array<Column>} [columns] - Columns accessor keys and labels
   * @property {Array<Action>} [actions] - Additional actions to be displayed as buttons in "actions" column
   * @property {Boolean} [add_button] - Show add button in the footer
   * @property {String} [order_by] - Column name to order by. Prefix "!" reverses the order
   * @property {String} [filter_by] - Column name to filer by. Used for nested relations
   * @property {Boolean} [hideable] - Whether to show the hidden records checkbox
   * @property {Boolean} [searchable] - Whether to show the search box
   *
   * @typedef {Object} Pagination
   * @property {Number} [page] - Current page
   * @property {Number} [page_size] - Size of the page
   * @property {Boolean} [total_count] - Total row count of unpaginated query
   *
   * @typedef {Object} Refresh
   * @property {Boolean} auto - Whether to auto-refresh
   * @property {Number} counter - How many milliseconds passed from the last refresh
   * @property {Number} intervalHandle - Id of the interval that refreshes the listing.
   *
   * @typedef {Object} State
   * @property {String} filterString - Only show records that have this string in their data
   * @property {Boolean} showHidden - Whether to show hidden records
   * @property {Boolean} isLoading - Whether we are loading data -> Shows spinner
   * @property {Array<Object>} data - Loaded data
   * @property {Form} form - Layout configuration
   * @property {Pagination} pagination - Current page information
   * @property {Refresh} refresh - Will be present only if {@link viewOnly} is false
   * @property {(filterString: String) => void} debouncedFilter - Debounced version of filtering call, stored here to preserve it in-between redraws (the debouncing functionality depends on having just one instance of the closure. It would break if new instance is created on each redraw)
   *
   * @typedef {{attrs: Attrs, state: State}} Vnode
   */

  /**
   * Mithril's default querystring building for array args is not compatible with Flask, so we build it ourselves.
   * For example, `{foo: [1, 2]}` encodes as: `foo%5B0%5D=1&foo%5B1%5D=2` (or decoded: `foo[0]=1&foo[1]=2`).
   * This results in two distinct query args; Flask can't merge them.
   * @param {Vnode} vnode
   * @return String
   */
  const filterQuery = /** Vnode */ vnode => {
    const query = []

    for (const filter of filterBy) {
      query.push(m.buildQueryString({ filter_by: filter }))
    }

    if (nestedFilter && vnode.attrs.nestedId) {
      query.push(m.buildQueryString({ filter_by: `${nestedFilter}==${vnode.attrs.nestedId}` }))
    }

    return '?' + query.join('&')
  }

  const clearState = /** Vnode */ vnode => {
    vnode.state.data = []
    vnode.state.form = {}
    vnode.state.pagination = { page: 1 }
  }

  /** Restore state from url */
  const restoreQueryString = /** Vnode */ vnode => {
    vnode.state.pagination.page = parseInt(m.route.param('page') || vnode.state.pagination.page) || 1
    vnode.state.filterString = m.route.param('query') || vnode.state.filterString || null
    vnode.state.form.order_by = m.route.param('orderBy') || vnode.state.form.order_by || null
  }

  const loadData = /** Vnode */ vnode => {
    vnode.state.isLoading = true

    getRequest(
      `select/${modelName}${filterQuery(vnode)}`,
      _({
        query: vnode.state.filterString,
        show_hidden: vnode.state.showHidden,
        order_by: vnode.state.form.order_by,
        page: vnode.state.pagination.page || 1
      }).omitBy(_.isNil).value()
    ).then(data => {
      if (!data) {
        throw new Error('Empty response')
      }

      vnode.state.data = data.data
      vnode.state.form = data.form
      vnode.state.pagination = data.pagination

      if (!Object.hasOwn(vnode.state, 'originalOrderBy')) {
        vnode.state.originalOrderBy = data.form.order_by
      }
    })
      .catch(err => {
        if (err.code === 0) {
          ServerMessages.set({ error: 'Unable to load data' })
          return
        }
        ServerMessages.set({ error: err.message || err })
        loginRedirect(err)
        throw err
      })
      .finally(() => {
        vnode.state.isLoading = false
      })
  }

  return {
    oninit: /** Vnode */ vnode => {
      vnode.state.debouncedFilter = _.debounce(() => loadData(vnode), FILTER_DEBOUNCE_DELAY)
      if (autoRefresh) {
        vnode.state.refresh = {
          auto: true,
          counter: 0,
          intervalHandle: setInterval(() => {
            const refresh = vnode.state.refresh

            if (!refresh.auto) {
              return
            }

            refresh.counter += 1000

            if (refresh.counter >= LISTING_RELOAD_INTERVAL) {
              loadData(vnode)
              refresh.counter = 0
            } else if (!viewOnly) {
              // The visible countdown ("auto refresh in Xs") only renders when
              // the list owns the chrome (non-viewOnly). Skipping the redraw in
              // viewOnly avoids firing redraw signals every second from a hidden
              // counter, which would interfere with frontend test synchronisation.
              m.redraw()
            }
          }, 1000)
        }
      }

      vnode.state.filterString = null
      vnode.state.isLoading = true
      vnode.state.showHidden = false

      clearState(vnode)
      restoreQueryString(vnode)
      loadData(vnode)
    },

    onremove: vnode => {
      clearInterval(vnode.state.refresh?.intervalHandle) // Don't DDOS ourselves :)
    },

    onupdate: vnode => {
      if (vnode.state.isLoading) {
        return
      }

      const routePage = parseInt(m.route.param('page')) || 1
      const routeFilterString = m.route.param('query') || null
      const routeOrderBy = m.route.param('orderBy') != null ? m.route.param('orderBy') : vnode.state.originalOrderBy

      // history.(back|forward) happened
      if (routePage !== vnode.state.pagination.page) {
        vnode.state.pagination.page = routePage
        loadData(vnode)
      }
      if (routeFilterString !== vnode.state.filterString) {
        vnode.state.filterString = routeFilterString
        vnode.state.debouncedFilter()
      }
      if (routeOrderBy !== vnode.state.form.order_by) {
        vnode.state.form.order_by = routeOrderBy
        loadData(vnode)
      }
    },

    view: /** Vnode */ vnode => {
      const fmt = vnode.attrs.fmt

      const orderPrintAction = printType =>
        order => {
          if (order.continuous_mode) {
            m.route.set(`/order/${order.id}/deliveries`)
          } else {
            openUrl(`print/${printType}/${order._deliveries[0].id}?lang=${vnode.attrs.lang}`)
          }
        }

      const actionButtonsCallbacks = {
        delete: /** Object */ record => {
          if (userLocks.isLocked(modelName)) {
            return
          }
          if (window.confirm(fmt('{Delete this record?}')) === true) {
            postRequest(`delete/${modelName}/${record.id}`)
              .then(() => { loadData(vnode) }, _.noop)
          }
        },
        hide: /** Object */ record => {
          if (userLocks.isLocked(modelName)) {
            return
          }
          postRequest(`toggle_hidden/${modelName}/${record.id}`)
            .then(() => loadData(vnode), _.noop)
        },
        editOrder: order => {
          if (order.continuous_mode) {
            m.route.set(`/order_edit/${order.id}`)
          } else {
            m.route.set(`/order_edit_combined/${order.id}`)
          }
        },
        duplicateOrder: order => {
          RedirectStack.push({
            redirectTo: '/',
            redirectData: { _action: 'load_instance', _id: order.id },
            noReturn: true
          })
        },
        cancelOrder: order => {
          if (window.confirm(fmt('{Cancel this order?}')) === true) {
            postRequest(`cancel_order/${order.id}`, {}, '/orders').catch(_.noop)
            // TODO NTH reload data
          }
        },
        deliverySheet: orderPrintAction('delivery_sheet'),
        batchProtocol: orderPrintAction('batch_protocol')
      }

      const listView = m(ListView, {
        fmt,
        lang: vnode.attrs.lang,
        modelName,
        nestedId: vnode.attrs.nestedId,
        data: vnode.state.data,
        isLoading: vnode.state.isLoading,
        orderBy: vnode.state.form?.order_by || '',
        actions: vnode.state.form?.actions || [],
        columns: vnode.state.form?.columns || [],
        actionButtonsCallbacks,
        pagination: m(Pagination, {
          page: vnode.state.pagination.page || 1,
          pageSize: vnode.state.pagination.page_size || 1,
          totalCount: vnode.state.pagination.total_count || 0,
          pageCallback: /** Number */ page => {
            vnode.state.pagination.page = page
            setMithrilQueryString({ page: page > 1 ? page : undefined })
            loadData(vnode)
          }
        }),
        onSortChange: /** String */ orderBy => {
          vnode.state.form.order_by = orderBy
          vnode.state.pagination.page = 1
          setMithrilQueryString({ orderBy: vnode.state.form.order_by, page: undefined })
          loadData(vnode)
        }
      })

      if (viewOnly) {
        return listView
      }

      const buttons = [
        vnode.state.form.add_button
          ? m(ButtonAdd, { onClick: () => { m.route.set(resolveRoute(`${modelName}.new`)) } })
          : undefined
      ]

      return m(ListLayout,
        {
          fmt,
          buttons,
          filterString: vnode.state.filterString,
          onFilterChange: !vnode.state.form.searchable
            ? undefined
            : filterValue => {
              vnode.state.filterString = filterValue || null
              vnode.state.pagination.page = 1
              setMithrilQueryString({ query: vnode.state.filterString, page: undefined })
              vnode.state.debouncedFilter()
            },
          showHidden: vnode.state.showHidden,
          onShowHiddenChange: !vnode.state.form.hideable
            ? undefined
            : checked => {
              vnode.state.showHidden = checked
              vnode.state.pagination.page = 1
              loadData(vnode)
            },
          autoRefresh: vnode.state.refresh.auto,
          refreshTimer: vnode.state.refresh.counter,
          onReloadChange: !vnode.state.refresh
            ? undefined
            : checked => {
              vnode.state.refresh.auto = checked
            }
        },
        listView
      )
    }
  }
}

const ExpeditionOrders = ListModel('Order', {
  viewOnly: true,
  autoRefresh: true,
  filterBy: [`t>=${Math.floor((Date.now() - EXPEDITIONS_PAGE_ORDERS_MAX_AGE) / 1000)}`] // Python expects seconds, not milliseconds
})
const ContractOrders = ListModel('Order', { viewOnly: true, nestedFilter: 'contract' })

/** Edit row for one recipe material, used in OpenRecipe */
const RecipeMaterialRow = {
  /**
   * @typedef {Object} Attrs
   * @property {FMT} fmt
   * @property {Object} material - The material to display
   * @property {{[String]: Array<Object>}} allMaterials - All the available materials, separated by type
   * @property {Boolean} [locked] - The recipe table is read-only for this user, so no row can be removed
   * @property {(material: Object) => void} valueCallback - Will be called when the data changes with the new material
   *
   * @typedef {{attrs: Attrs, state: State}} Vnode
   */

  /**
   * Create a label-field pair for the row
   * @param {Vnode} vnode
   * @param {String} label - Label of the field
   * @param {String} key - Key to the data
   * @param {Boolean} disabled - Disables the input field
   * @param {Boolean} lastOne - The bootstrap sizes are smaller here to accommodate for the trashcan
   * @returns {Array<Object>}
   */
  field: (vnode, label, key, disabled = false, lastOne = false) => {
    const fmt = vnode.attrs.fmt

    return [
      m('.col-6.col-sm-3.col-xl-1.text-right.small', fmt(label)),
      m(
        lastOne ? '.col-4.col-sm-2.col-xl-1' : '.col-6.col-sm-3.col-xl-1',
        m(InputField, {
          fmt,
          value: vnode.attrs.material[key],
          size: 7,
          number: true,
          disabled,
          valueCallback: value => {
            vnode.attrs.material[key] = value
            vnode.attrs.valueCallback(vnode.attrs.material)
          }
        })
      )
    ]
  },

  view: /** Vnode */ vnode => {
    const material = vnode.attrs.material
    const fmt = vnode.attrs.fmt

    const options = Object.entries(vnode.attrs.allMaterials || {}).map(([materialType, options]) => {
      return { title: `{${materialType}}`, options }
    })

    return m('.mt-1.p-1.bg-light.row', [
      // Material selector
      m('.col-sm-12.col-xl-3',
        m(SimpleSelectBox, {
          fmt,
          value: material.material || null,
          options: [
            { id: null, title: null },
            ...options
          ],
          valueCallback: value => {
            material.material = value
            vnode.attrs.valueCallback?.(material)
          }
        })
      ),

      // Properties
      ...RecipeMaterialRow.field(vnode, '{weight}:', 'weight'),
      ...RecipeMaterialRow.field(vnode, '{Delay}:', 'delay'),
      ...RecipeMaterialRow.field(vnode, '{K-value}:', 'k_value', vnode.attrs.material.type !== 'Addition'),
      ...RecipeMaterialRow.field(vnode, '{K-ratio}:', 'k_ratio', vnode.attrs.material.type !== 'Addition', true),

      // Delete button
      material.material && !vnode.attrs.locked
        ? m('.col-1.text-right.fas.fa-trash.text-danger', { onclick: () => { vnode.attrs.deleteCallback(material) } })
        : undefined
    ])
  }
}

const PricelistRecipeRow = {
  view: vnode => {
    const pr = vnode.attrs.pricelistRecipe
    const fmt = vnode.attrs.fmt

    return m('.mt-1.p-1.bg-light.row', [
      m('.col-sm-8.col-xl-6.small.pt-1', pr.recipe_name),
      m('.col-3.col-sm-2.col-xl-1.text-right.small', fmt('{price}:')),
      m('.col-6.col-sm-3.col-xl-2',
        m(InputField, {
          fmt,
          value: pr.price,
          size: 7,
          number: true,
          valueCallback: value => {
            pr.price = value
            vnode.attrs.valueCallback?.(pr)
          }
        })
      )
    ])
  }
}

const PricelistMaterialRow = {
  view: vnode => {
    const pm = vnode.attrs.pricelistMaterial
    const fmt = vnode.attrs.fmt

    return m('.mt-1.p-1.bg-light.row', [
      m('.col-sm-8.col-xl-6.small.pt-1', pm.material_name),
      m('.col-3.col-sm-2.col-xl-1.text-right.small', fmt('{price}:')),
      m('.col-6.col-sm-3.col-xl-2',
        m(InputField, {
          fmt,
          value: pm.price,
          size: 7,
          number: true,
          valueCallback: value => {
            pm.price = value
            vnode.attrs.valueCallback?.(pm)
          }
        })
      )
    ])
  }
}

const PricelistsTable = {
  view: vnode => {
    const matrix = vnode.attrs.matrix
    const fmt = vnode.attrs.fmt

    // Keep vnode.state.data in sync so submit sends the current matrix
    vnode.attrs.stateData[vnode.attrs.stateKey] = matrix

    const addPricelist = () => {
      const tmpId = 'new_' + Date.now()
      matrix.pricelists.push({ id: null, name: '', _new: true, _tmp_id: tmpId })
      for (const row of matrix.rows) {
        row.prices[tmpId] = null
      }
    }

    const deletePricelist = colIdx => {
      const pl = matrix.pricelists[colIdx]
      if (!matrix.deleted_pricelist_ids) matrix.deleted_pricelist_ids = []
      if (pl.id !== null) matrix.deleted_pricelist_ids.push(pl.id)
      const key = pl._new ? pl._tmp_id : String(pl.id)
      matrix.pricelists.splice(colIdx, 1)
      for (const row of matrix.rows) {
        delete row.prices[key]
      }
    }

    return m('.table-responsive', m('table.table.table-sm.table-bordered.mb-0', [
      m('thead', m('tr', [
        m('th', fmt(matrix.row_header)),
        ...matrix.pricelists.map((pl, colIdx) =>
          m('th.text-center', { style: 'min-width: 120px' }, [
            pl.id === null && !pl._new
              ? m('span.small.font-weight-bold', fmt(pl.name))
              : m('input.form-control.form-control-sm', {
                value: pl.name,
                placeholder: fmt('{name}'),
                oninput: e => { pl.name = e.target.value }
              }),
            pl.id !== null || pl._new
              ? m('span.fas.fa-trash.text-danger.ml-1', {
                style: 'cursor: pointer',
                onclick: () => { deletePricelist(colIdx) }
              })
              : undefined
          ])
        ),
        m('th.text-center', m('span.fas.fa-plus.text-success', {
          style: 'cursor: pointer',
          onclick: () => { addPricelist() }
        }))
      ])),
      m('tbody', matrix.rows.map(row =>
        m('tr', [
          m('td.small.align-middle', { style: 'white-space: nowrap' }, row.row_name),
          ...matrix.pricelists.map(pl => {
            const key = pl._new ? pl._tmp_id : (pl.id === null ? 'null' : String(pl.id))
            return m('td.p-1', m(InputField, {
              fmt,
              value: row.prices[key],
              number: true,
              stretch: true,
              valueCallback: value => {
                row.prices[key] = value
              }
            }))
          })
        ])
      ))
    ]))
  }
}

const CustomerSpecialPricesView = ListModel('SpecialPrice', { viewOnly: true, nestedFilter: 'customer' })

const CustomerSpecialPricesChildren = (vnode, fmt) => {
  return globalConfig.setup.module_prices
    ? [
        m('.row',
          m('.col-2', m('.h4', fmt('{customer_special_prices}:'))),
          m('.col-6.p-0', m('a.btn.btn-primary.fas.fa-plus.px-5.ml-0.py-2.mx-1', {
            onclick: () => {
              RedirectStack.push({ redirectTo: '/special_price_open', redirectData: { customer: parseInt(vnode.attrs.id) } })
            }
          }))
        ),
        m('.mt-3', m(CustomerSpecialPricesView, { fmt, nestedId: vnode.attrs.id }))
      ]
    : undefined
}

const Login = {
  oninit: vnode => {
    vnode.state.userOptions = []
    vnode.state.data = {}

    getRequest('select/User').then(response => {
      vnode.state.data.username = response.data?.[0].username

      const lastUsername = window.localStorage.getItem('last_logged_in_user')

      for (const user of response.data) {
        vnode.state.userOptions.push({ id: user.username, title: user.username })

        // Feature: Select the last username that attempted to log in if present, first one otherwise
        if (user.username === lastUsername) {
          vnode.state.data.username = lastUsername
        }
      }
    }).catch(_.noop)
  },

  view: vnode => {
    const fmt = vnode.attrs.fmt

    const submitForm = () => {
      window.localStorage.setItem('last_logged_in_user', vnode.state.data.username)

      postRequest('login', vnode.state.data, '/')
        .then(data => {
          // TODO: i don't like this being here and setting some global state -> move to /config ?
          userLocks.setData(data.user_locks)
          GlobalWrapper.reloadConfig()
        }, _.noop)
    }

    const fields = formToElements(vnode, [
      {
        k: 'username',
        options: vnode.state.userOptions,
        title: '{Username}'
      },
      {
        k: 'password',
        _password: true,
        title: '{Password}'
      }
    ],
    null,
    submitForm
    )

    return m('',
      [
        m(ServerMessages, { fmt }),
        m('.w-50.mt-5.mx-auto.bg-dark.text-white.p-4.rounded', [
          m('h2.my-3.text-center', fmt('{Please log in:}')),
          fields,
          m(FormRow, {
            label: m(''),
            field: m(StdButton, {
              text: fmt('{Login}'),
              onclick: submitForm
            })
          })
        ])
      ]
    )
  }
}

// Manual stock-ups only: production writes one StockMovement per material per batch and every material sale
// writes a negative one, either of which would bury the handful of hand-entered arrivals this form exists to
// record. A row is hand-entered when it belongs to neither - same rule as StockMovement._is_manual.
const StockUpsView = ListModel('StockMovement', {
  viewOnly: true,
  autoRefresh: true,
  filterBy: ['batch:isnull:true', 'material_sale:isnull:true']
})

const StockUps = (vnode, fmt) => [
  m('.mb-2.mt-5.d-flex', m('.mr-2', m('.h4', fmt('{StockUps}:')))),
  m('.mt-3', m(StockUpsView, { fmt, lang: vnode.attrs.lang }))
]

// This can't be inside something called on each view event, like LockedTables or Mithril will re-init it every time
const LockedTablesView = ListModel('LockedTable', { viewOnly: true, nestedFilter: 'user' })

const LockedTables = (vnode, fmt) => {
  return [
    m('.mb-2.mt-5.d-flex',
      m('.mr-2', m('.h4', fmt('{LockedTables}:'))),
      m('.p-0', m(m.route.Link, { href: `/locked_table_open/${vnode.attrs.id}`, class: 'btn btn-primary fas fa-plus px-5 ml-0 py-2 mx-1' }))
    ),
    m('.mt-3', m(LockedTablesView, { fmt, nestedId: vnode.attrs.id }))
  ]
}

const SampleMsgbox = {
  oninit: vnode => {
    vnode.state.data = {}
    vnode.state.serverMessage = ServerMessages.response
  },

  view: vnode => {
    const fmt = vnode.attrs.fmt

    const fields = formToElements(vnode, [
      {
        k: 'comment',
        title: '{comment}'
      }
    ])

    return m(Msgbox, {
      title: fmt('{Was the sample for lab taken?}'),
      fields,
      buttons: [
        m(StdButton, {
          text: fmt('{Yes}'),
          onclick: () => {
            postRequest(`sample/taken/${vnode.attrs.id}`, vnode.state.data).then(() => {
              ServerMessages.set(vnode.state.serverMessage) // Restore the message that would be shown if the modal was not shown
              RedirectStack.back()
            }, _.noop)
          }
        }),
        m(StdButton, { text: fmt('{No}'), onclick: RedirectStack.back })
      ]
    })
  }
}

// FIXME separate to independent components: configuration, locks, ...maybe others
const GlobalWrapper = {
  oninit: () => {
    GlobalWrapper.reloadConfig()
  },

  onupdate: () => {
    if (!Layout.loaderCount) {
      console.debug('MAGIC_REDRAWN_INDICATOR_FOR_TESTS')
    }
  },

  reloadConfig: () => {
    getRequest('config').then(data => {
      globalConfig = data
    }).catch(_.noop)
  },

  view: vnode => _.isEmpty(globalConfig?.setup)
    ? m(Loading)
    : [
      // Ugly hack to keep the spinner in cache so it will pop in instantly
        m('img.d-none', { src: './static/spinner.gif' }),
        ...vnode.children
      ]
}

const Language = {
  oninit: vnode => {
    vnode.state.lang = vnode.attrs.lang || 'cs' // last language seen (should not be really needed)
    vnode.state.d_by_lang = {}

    getRequest('captions').then(data => {
      vnode.state.d_by_lang = byKeyToByLang(data)
    }).catch(_.noop)
  },

  view: vnode => {
    if (!Object.keys(vnode.state.d_by_lang).length) {
      return m(Loading)
    }

    const lang = vnode.attrs.lang || vnode.state.lang || 'cs'
    // TODO: The Component is here and not in .children, because we want to init it with lang and fmt params
    return m(vnode.attrs.Component, {
      lang,
      fmt: makeFmt(lang, vnode.state.d_by_lang),
      ...m.route.param()
    })
  }
}

const RootLayout = Component => {
  // please note this is not a mithril component but a routeResolver object (see mithril's documentation) -> therefore no "view" but "render". the purpose is to request caption data just once, not for each route change.
  return {
    onmatch: (args, requestedPath, route) => {
      Layout.reset()
      RedirectStack.onmatch(args, requestedPath, route)
    },
    render: () => m(GlobalWrapper, m(Language, { lang: urlParams.get('lang'), Component }))
  }
}

const routes = {
  '/': OpenModelOrNonModel('NonModel_Expeditions', {
    getChildren: (vnode, fmt) => m(ExpeditionOrders, { fmt, lang: vnode.attrs.lang })
  }),
  '/login': Login,

  '/orders': ListModel('Order'),
  '/order_edit/:id': OpenModelOrNonModel('Order'),
  '/order_edit_combined/:id': OpenModelOrNonModel('NonModel_OrderDelivery'),
  '/order_open/:nestedId': ListModel('Batch', { nestedFilter: 'delivery.order' }),

  '/order/:nestedId/deliveries': ListModel('Delivery', { nestedFilter: 'order' }),
  '/delivery_open/:id': OpenModelOrNonModel('Delivery'),

  '/replace_materials': OpenModelOrNonModel('NonModel_ReplaceMaterials'),
  '/set_car_driver': OpenModelOrNonModel('NonModel_SetCarDriver'),
  '/production_enable': OpenModelOrNonModel('NonModel_ProductionEnable'),
  '/export_recipes': OpenModelOrNonModel('NonModel_RecipesExport'),

  '/recipes': ListModel('Recipe'),
  '/recipe_open': OpenModelOrNonModel('Recipe'),
  '/recipe_open/:id': OpenModelOrNonModel('Recipe'),
  '/recipe_open/:id/:_action': OpenModelOrNonModel('Recipe'),

  '/materials': ListModel('Material'),
  '/material_open': OpenModelOrNonModel('Material'),
  '/material_open/:id': OpenModelOrNonModel('Material'),

  '/material_sales': ListModel('MaterialSale'),
  '/material_sale_open': OpenModelOrNonModel('MaterialSale'),
  '/material_sale_open/:id': OpenModelOrNonModel('MaterialSale'),

  '/stock_movements': ListModel('StockMovement'),
  '/material/:nestedId/stock_movements': ListModel('StockMovement', { nestedFilter: 'material' }),
  // The sub-listing belongs to the data-entry loop only - when editing one existing record it is noise.
  '/stock_movement_open': OpenModelOrNonModel('StockMovement', { getChildren: StockUps }),
  '/stock_movement_open/:id': OpenModelOrNonModel('StockMovement'),

  '/transport_types': ListModel('TransportType'),
  '/transport_type_open': OpenModelOrNonModel('TransportType'),
  '/transport_type_open/:id': OpenModelOrNonModel('TransportType'),

  '/drivers': ListModel('Driver'),
  '/driver_open': OpenModelOrNonModel('Driver'),
  '/driver_open/:id': OpenModelOrNonModel('Driver'),

  '/cars': ListModel('Car'),
  '/car_open': OpenModelOrNonModel('Car'),
  '/car_open/:id': OpenModelOrNonModel('Car'),

  '/recipe_pricelists': OpenModelOrNonModel('NonModel_RecipePricelists'),
  '/material_pricelists': OpenModelOrNonModel('NonModel_MaterialPricelists'),

  '/pricelists': ListModel('Pricelist'),
  '/pricelist_open': OpenModelOrNonModel('Pricelist'),
  '/pricelist_open/:id': OpenModelOrNonModel('Pricelist'),

  '/pricelist_recipes': ListModel('PricelistRecipe'),
  '/pricelist_recipe_open': OpenModelOrNonModel('PricelistRecipe'),
  '/pricelist_recipe_open/:id': OpenModelOrNonModel('PricelistRecipe'),

  '/special_prices/': ListModel('SpecialPrice'),
  '/special_price_open': OpenModelOrNonModel('SpecialPrice'),
  '/special_price_open/:id': OpenModelOrNonModel('SpecialPrice'),

  '/taxdocuments': ListModel('TaxDocument'),
  '/tax_document_open': OpenModelOrNonModel('TaxDocument'),
  '/tax_document_open/:id': OpenModelOrNonModel('TaxDocument'),

  '/surcharges': ListModel('Surcharge'),
  '/surcharge_open': OpenModelOrNonModel('Surcharge'),
  '/surcharge_open/:id': OpenModelOrNonModel('Surcharge'),

  '/pump_surcharges': ListModel('PumpSurcharge'),
  '/pump_surcharge_open': OpenModelOrNonModel('PumpSurcharge'),
  '/pump_surcharge_open/:id': OpenModelOrNonModel('PumpSurcharge'),

  '/transport_zones': ListModel('TransportZone'),
  '/transport_zone_open': OpenModelOrNonModel('TransportZone'),
  '/transport_zone_open/:id': OpenModelOrNonModel('TransportZone'),

  '/pumps': ListModel('Pump'),
  '/pump_open': OpenModelOrNonModel('Pump'),
  '/pump_open/:id': OpenModelOrNonModel('Pump'),

  '/pump_orders': ListModel('PumpOrder'),
  '/pump_order_open/': OpenModelOrNonModel('PumpOrder'),
  '/pump_order_open/:id': OpenModelOrNonModel('PumpOrder'),

  '/customers': ListModel('Customer'),
  '/customer_open': OpenModelOrNonModel('Customer'),
  '/customer_open/:id': OpenModelOrNonModel('Customer', { getChildren: CustomerSpecialPricesChildren }),

  '/construction_sites': ListModel('ConstructionSite'),
  '/construction_site_open': OpenModelOrNonModel('ConstructionSite'),
  '/construction_site_open/:id': OpenModelOrNonModel('ConstructionSite'),

  '/contracts': ListModel('Contract'),
  '/contract_open': OpenModelOrNonModel('Contract'),
  '/contract_open/:id': OpenModelOrNonModel('Contract', { getChildren: (vnode, fmt) => m(ContractOrders, { fmt, lang: vnode.attrs.lang, nestedId: vnode.state.data.id }) }),

  '/defaults': ListModel('Defaults'),
  '/defaults_open': OpenModelOrNonModel('Defaults'),
  '/defaults_open/:id': OpenModelOrNonModel('Defaults'),

  '/users': ListModel('User'),
  '/user_open': OpenModelOrNonModel('User'),
  '/user_open/:id': OpenModelOrNonModel('User', { getChildren: LockedTables }),

  '/locked_table_open/:user': OpenModelOrNonModel('LockedTable'),

  '/setup': OpenModelOrNonModel('Setup'),

  '/samples': ListModel('Sample'),
  '/sample_msgbox/:id': SampleMsgbox,

  '/stat_consumption': OpenModelOrNonModel('NonModel_StatConsumption'),
  '/stat_production': OpenModelOrNonModel('NonModel_StatProduction'),
  '/stat_production_ov': OpenModelOrNonModel('NonModel_StatProductionOverview'),
  '/stat_stock': OpenModelOrNonModel('NonModel_StatStock'),
  '/stat_taxdocuments': OpenModelOrNonModel('NonModel_StatTaxDocuments')
}

// Wrap the routes in RootLayout
m.route(document.body, '/', Object.fromEntries(Object.entries(routes).map(
  ([key, value]) => [key, RootLayout(value)]
)))
