local utf8 = rawget(_G, 'utf8');

local NODE_TEXT = 'text';
local NODE_BUTTON = 'button';
local NODE_BOX = 'box';
local NODE_FRAGMENT = 'fragment';

local DEFAULT_COLOR = colors.white;
local DEFAULT_BG_COLOR = colors.black;
local DISABLED_COLOR = colors.gray;

local function isArray(value)
  if type(value) ~= 'table' then
    return false;
  end

  if value.kind then
    return false;
  end

  local count = 0;
  for key, _ in pairs(value) do
    if type(key) ~= 'number' then
      return false;
    end
    if key > count then
      count = key;
    end
  end

  return count > 0;
end

local function firstColor(value, fallback)
  if type(value) == 'table' then
    return value[1] or fallback;
  end

  return value or fallback;
end

local function firstFunction(value)
  if type(value) == 'function' then
    return value;
  end

  if type(value) == 'table' and type(value[1]) == 'function' then
    return value[1];
  end

  return nil;
end

local function shallowCopy(value)
  local result = {};

  if type(value) ~= 'table' then
    return result;
  end

  for key, item in pairs(value) do
    result[key] = item;
  end

  return result;
end

local function utf8Len(value)
  value = tostring(value or '');

  local ok, iter, state, init = pcall(utf8.codes, value);
  if not ok then
    return string.len(value);
  end

  local count = 0;
  for _ in iter, state, init do
    count = count + 1;
  end

  return count;
end

local function utf8Sub(value, startIndex, endIndex)
  value = tostring(value or '');
  startIndex = math.max(1, math.floor(startIndex or 1));
  endIndex = endIndex == nil and math.huge or math.floor(endIndex);

  if endIndex < startIndex then
    return '';
  end

  local ok, iter, state, init = pcall(utf8.codes, value);
  if not ok then
    return string.sub(value, startIndex, endIndex);
  end

  local byteStart;
  local byteEnd;
  local index = 0;

  for pos, _ in iter, state, init do
    index = index + 1;

    if index == startIndex then
      byteStart = pos;
    end

    if index == endIndex + 1 then
      byteEnd = pos - 1;
      break;
    end
  end

  if not byteStart then
    return '';
  end

  if not byteEnd then
    byteEnd = string.len(value);
  end

  return string.sub(value, byteStart, byteEnd);
end

local function makeNode(kind, props)
  props = props or {};
  return {
    kind = kind,
    props = props,
  };
end

local function makeText(value, props)
  if type(value) == 'table' and props == nil then
    props = shallowCopy(value);
  else
    props = shallowCopy(props);
    props.text = value;
  end

  return makeNode(NODE_TEXT, props);
end

local function makeButton(value, props)
  if type(value) == 'table' and props == nil then
    props = shallowCopy(value);
  else
    props = shallowCopy(props);
    props.text = value;
  end

  return makeNode(NODE_BUTTON, props);
end

local function makeBox(props)
  return makeNode(NODE_BOX, shallowCopy(props));
end

local function makeList(props)
  props = shallowCopy(props);
  props.direction = 'column';
  return makeNode(NODE_BOX, props);
end

local function makeFragment(children)
  return makeNode(NODE_FRAGMENT, { children = children or {} });
end

local function lineText(value, width)
  value = tostring(value or '');

  if width <= 0 then
    return '';
  end

  local valueLength = utf8Len(value);
  if valueLength > width then
    return utf8Sub(value, 1, width);
  end

  return value .. string.rep(' ', width - valueLength);
end

local function shrinkRect(rect, amount)
  amount = amount or 0;
  return {
    x = rect.x + amount,
    y = rect.y + amount,
    w = rect.w - amount * 2,
    h = rect.h - amount * 2,
  };
end

local function isInside(rect, x, y)
  return x >= rect.x and x < rect.x + rect.w and y >= rect.y and y < rect.y + rect.h;
end

local function fillRect(rect, bgColor)
  if rect.w <= 0 or rect.h <= 0 then
    return;
  end

  term.setBackgroundColor(bgColor);
  for y = rect.y, rect.y + rect.h - 1 do
    term.setCursorPos(rect.x, y);
    term.write(string.rep(' ', rect.w));
  end
end

local function writeAt(x, y, text, width)
  if width <= 0 then
    return;
  end

  term.setCursorPos(x, y);
  term.write(lineText(text, width));
end

local function drawBorder(rect, props)
  if rect.w <= 1 or rect.h <= 1 then
    return;
  end

  local color = firstColor(props.color, DEFAULT_COLOR);
  local bgColor = firstColor(props.bgColor, DEFAULT_BG_COLOR);
  local top = '+' .. string.rep('-', rect.w - 2) .. '+';
  local bottom = top;
  local title = props.title;

  if title then
    local titleText = ' ' .. tostring(title) .. ' ';
    local titleLength = utf8Len(titleText);
    if titleLength < rect.w - 1 then
      top = '+' .. titleText .. string.rep('-', rect.w - 2 - titleLength) .. '+';
    end
  end

  term.setTextColor(color);
  term.setBackgroundColor(bgColor);
  writeAt(rect.x, rect.y, top, rect.w);
  writeAt(rect.x, rect.y + rect.h - 1, bottom, rect.w);

  for y = rect.y + 1, rect.y + rect.h - 2 do
    term.setCursorPos(rect.x, y);
    term.write('|');
    term.setCursorPos(rect.x + rect.w - 1, y);
    term.write('|');
  end
end

local function normalizeChildren(children)
  local result = {};

  if children == nil then
    return result;
  end

  if type(children) ~= 'table' or children.kind then
    return { children };
  end

  if isArray(children) then
    for _, child in ipairs(children) do
      table.insert(result, child);
    end
  else
    table.insert(result, children);
  end

  return result;
end

local resolveNode;

local function resolveChildren(children)
  local result = {};

  for _, child in ipairs(normalizeChildren(children)) do
    local node = resolveNode(child);
    if node then
      table.insert(result, node);
    end
  end

  return result;
end

function resolveNode(input)
  if input == nil then
    return nil;
  end

  if type(input) == 'function' then
    return resolveNode(input());
  end

  if type(input) == 'string' or type(input) == 'number' then
    return makeText(tostring(input));
  end

  if type(input) ~= 'table' then
    return makeText(tostring(input));
  end

  if input.kind then
    local props = shallowCopy(input.props);
    props.children = resolveChildren(props.children);
    return makeNode(input.kind, props);
  end

  if isArray(input) then
    return makeFragment(resolveChildren(input));
  end

  return makeFragment(resolveChildren(input.children));
end

local function buttonLabel(props)
  return '[ ' .. tostring(props.text or props.label or '') .. ' ]';
end

local function flexOf(child)
  local f = (child.props or {}).flex;
  if f == true then
    return 1;
  end
  if type(f) == 'number' and f > 0 then
    return f;
  end
  return nil;
end

local naturalSize;

local function childrenNaturalSize(children, direction, gap)
  local width = 0;
  local height = 0;
  local mainCount = 0;

  for _, child in ipairs(children) do
    local hasFlex = flexOf(child) ~= nil;
    local childWidth, childHeight = naturalSize(child);

    if direction == 'row' then
      height = math.max(height, childHeight);
      if not hasFlex then
        if mainCount > 0 then
          width = width + gap;
        end
        width = width + childWidth;
        mainCount = mainCount + 1;
      end
    else
      width = math.max(width, childWidth);
      if not hasFlex then
        if mainCount > 0 then
          height = height + gap;
        end
        height = height + childHeight;
        mainCount = mainCount + 1;
      end
    end
  end

  return width, height;
end

function naturalSize(node)
  if not node then
    return 0, 0;
  end

  local props = node.props or {};

  if node.kind == NODE_TEXT then
    return props.width or utf8Len(tostring(props.text or '')), props.height or 1;
  end

  if node.kind == NODE_BUTTON then
    return props.width or utf8Len(buttonLabel(props)), props.height or 1;
  end

  local direction = props.direction or 'column';
  local gap = props.gap or 0;
  local padding = props.padding or 0;
  local border = props.border and 2 or 0;
  local childWidth, childHeight = childrenNaturalSize(props.children or {}, direction, gap);

  if node.kind == NODE_FRAGMENT then
    return childWidth, childHeight;
  end

  return props.width or childWidth + padding * 2 + border, props.height or childHeight + padding * 2 + border;
end

local function applyNodeColors(props)
  term.setTextColor(firstColor(props.color, DEFAULT_COLOR));
  term.setBackgroundColor(firstColor(props.bgColor, DEFAULT_BG_COLOR));
end

local function childAxisSize(child, direction)
  local props = child.props or {};
  local naturalWidth, naturalHeight = naturalSize(child);

  if direction == 'row' then
    return props.width or naturalWidth;
  end

  return props.height or naturalHeight;
end

local function layoutChildren(children, rect, direction, gap)
  local fixedSize = 0;
  local flexSize = 0;
  local axisSize = direction == 'row' and rect.w or rect.h;
  local layouts = {};
  local lastFlexIndex = nil;
  local flexes = {};

  for index, child in ipairs(children) do
    local flex = flexOf(child);
    flexes[index] = flex;
    if flex then
      flexSize = flexSize + flex;
      lastFlexIndex = index;
    else
      fixedSize = fixedSize + childAxisSize(child, direction);
    end
  end

  local remaining = axisSize - fixedSize - math.max(#children - 1, 0) * gap;
  if remaining < 0 then
    remaining = 0;
  end

  local cursor = direction == 'row' and rect.x or rect.y;
  local usedFlexSize = 0;

  for index, child in ipairs(children) do
    local props = child.props or {};
    local flex = flexes[index];
    local size;

    if flex then
      if index == lastFlexIndex then
        size = remaining - usedFlexSize;
      else
        size = math.floor(remaining * flex / flexSize);
        usedFlexSize = usedFlexSize + size;
      end
    else
      size = childAxisSize(child, direction);
    end

    if size < 0 then
      size = 0;
    end

    if direction == 'row' then
      table.insert(layouts, { node = child, rect = { x = cursor, y = rect.y, w = size, h = props.height or rect.h } });
    else
      table.insert(layouts, { node = child, rect = { x = rect.x, y = cursor, w = props.width or rect.w, h = size } });
    end

    cursor = cursor + size + gap;
  end

  return layouts;
end

local function createTui(eventloop)
  assert(type(eventloop) == 'table', 'bad argument #1 (eventloop expected)');
  assert(type(eventloop.register) == 'function', 'bad argument #1 (eventloop expected)');
  assert(type(eventloop.startLoop) == 'function', 'bad argument #1 (eventloop expected)');

  local api = {};
  local root = nil;
  local clickables = {};
  local finalEvent = nil;
  local previousState = nil;

  local function createErrorEvent(reason)
    if type(reason) == 'table' then
      return {
        type = 'error',
        error = {
          name = reason.name or 'libtui error',
          reason = reason.reason or tostring(reason),
        },
      };
    end

    return {
      type = 'error',
      error = {
        name = 'libtui error',
        reason = tostring(reason),
      },
    };
  end

  local function stopWith(event)
    finalEvent = finalEvent or event;
    if eventloop.isRunningLoop and eventloop.isRunningLoop() then
      eventloop.stopLoop();
    end
  end

  local renderNode;

  local function addClickable(node, rect)
    local props = node.props or {};
    local handler = firstFunction(props.onClick);

    if not handler or props.disabled then
      return;
    end

    table.insert(clickables, {
      rect = rect,
      node = node,
      handler = handler,
    });
  end

  local function drawTextNode(node, rect)
    local props = node.props or {};

    if rect.w <= 0 or rect.h <= 0 then
      return;
    end

    fillRect(rect, firstColor(props.bgColor, DEFAULT_BG_COLOR));
    applyNodeColors(props);
    writeAt(rect.x, rect.y, props.text or '', rect.w);
    addClickable(node, rect);
  end

  local function drawButtonNode(node, rect)
    local props = node.props or {};

    if rect.w <= 0 or rect.h <= 0 then
      return;
    end

    fillRect(rect, firstColor(props.bgColor, DEFAULT_BG_COLOR));
    term.setTextColor(props.disabled and DISABLED_COLOR or firstColor(props.color, DEFAULT_COLOR));
    term.setBackgroundColor(firstColor(props.bgColor, DEFAULT_BG_COLOR));
    writeAt(rect.x, rect.y, buttonLabel(props), rect.w);
    addClickable(node, rect);
  end

  local function drawBoxNode(node, rect)
    local props = node.props or {};
    local children = props.children or {};
    local contentRect = rect;
    local padding = props.padding or 0;
    local direction = props.direction or 'column';
    local gap = props.gap or 0;

    if rect.w <= 0 or rect.h <= 0 then
      return;
    end

    fillRect(rect, firstColor(props.bgColor, DEFAULT_BG_COLOR));
    addClickable(node, rect);

    if props.border then
      drawBorder(rect, props);
      contentRect = shrinkRect(contentRect, 1);
    end

    if padding > 0 then
      contentRect = shrinkRect(contentRect, padding);
    end

    if contentRect.w <= 0 or contentRect.h <= 0 then
      return;
    end

    for _, item in ipairs(layoutChildren(children, contentRect, direction, gap)) do
      renderNode(item.node, item.rect);
    end
  end

  function renderNode(node, rect)
    if node.kind == NODE_TEXT then
      drawTextNode(node, rect);
    elseif node.kind == NODE_BUTTON then
      drawButtonNode(node, rect);
    else
      drawBoxNode(node, rect);
    end
  end

  local function redraw()
    local width, height = term.getSize();

    clickables = {};
    term.setCursorBlink(false);
    term.setTextColor(DEFAULT_COLOR);
    term.setBackgroundColor(DEFAULT_BG_COLOR);
    term.clear();
    renderNode(resolveNode(root), { x = 1, y = 1, w = width, h = height });
  end

  local function safeRedraw()
    local ok, reason = pcall(redraw);
    if not ok then
      stopWith(createErrorEvent(reason));
    end
  end

  local function safeClick(handler, event)
    local ok, reason = pcall(handler, api, event);
    if not ok then
      stopWith(createErrorEvent(reason));
    end
  end

  function api.exitUI(reason)
    stopWith({ type = 'exitUI', reason = reason });
  end

  function api.rerender()
    if root == nil then
      return;
    end
    safeRedraw();
  end

  local function restoreTerminal()
    if not previousState then
      return;
    end
    term.setTextColor(previousState.color);
    term.setBackgroundColor(previousState.bgColor);
    term.clear();
    term.setCursorPos(previousState.cursorX, previousState.cursorY);
    term.setCursorBlink(previousState.cursorBlink);
    previousState = nil;
  end

  function api.render(nextRoot)
    finalEvent = nil;
    root = nextRoot;

    previousState = {
      color = term.getTextColor(),
      bgColor = term.getBackgroundColor(),
      cursorX = 1,
      cursorY = 1,
      cursorBlink = false,
    };

    if term.getCursorPos then
      previousState.cursorX, previousState.cursorY = term.getCursorPos();
    end

    if term.getCursorBlink then
      previousState.cursorBlink = term.getCursorBlink();
    end

    eventloop.onStart(safeRedraw);
    eventloop.onStop(restoreTerminal);

    eventloop.register('mouse_click', function(button, x, y)
      for index = #clickables, 1, -1 do
        local item = clickables[index];
        if isInside(item.rect, x, y) then
          safeClick(item.handler, {
            type = 'mouse_click',
            button = button,
            x = x,
            y = y,
            node = item.node,
          });
          return;
        end
      end
    end);

    eventloop.register('term_resize', function()
      safeRedraw();
    end);

    eventloop.register('terminate', function()
      finalEvent = finalEvent or { type = 'terminate' };
    end);

    local ok, reason = pcall(eventloop.startLoop);
    if not ok then
      pcall(restoreTerminal);
      finalEvent = createErrorEvent(reason);
    end

    root = nil;
    clickables = {};

    return finalEvent or { type = 'exitUI' };
  end

  api.Text = makeText;
  api.text = makeText;
  api.Button = makeButton;
  api.button = makeButton;
  api.Box = makeBox;
  api.box = makeBox;
  api.List = makeList;
  api.list = makeList;
  api.Fragment = makeFragment;
  api.eventloop = eventloop;

  return api;
end

return createTui;
