#!/usr/bin/lua

local uci = require('simple-uci').cursor()
local ip = require 'luci.ip' -- luci-lib-ip
local fetch = require 'luci.httpclient'
local json = require 'luci.jsonc'
local ecdsa = require 'gluon.ecdsa'
local site = require 'gluon.site'

local pretty_hostname = require 'pretty_hostname'
local hostname = pretty_hostname.get(uci)

local manapi = site.manman.api()
local mankey = site.manman.key()

-- CLI lib from olsrd-cli (todo: move to it's own lib package)
-- DO NOT EDIT HERE, JUST COPY FROM THERE

function printf(...)
	print(string.format(...))
end

-- Print contents of `tbl`, with indentation.
-- `indent` sets the initial level of indentation.
-- src https://gist.github.com/xytis/5361405
function tprint (tbl, indent)
  if not indent then indent = 0 end
  for k, v in pairs(tbl) do
    formatting = string.rep('  ', indent) .. k .. ': '
    if type(v) == 'table' then
      print(formatting)
      tprint(v, indent + 1)
    else
      print(formatting .. tostring(v))
    end
  end
end

-- src https://stackoverflow.com/a/24823383/3990041
function table.slice(tbl, first, last, step)
  local sliced = {}

  for i = first or 1, last or #tbl, step or 1 do
    sliced[#sliced+1] = tbl[i]
  end

  return sliced
end

-- CLI lib

function exec_cmd(args, sub)
	if sub[args[1]] == nil then
		return cmd_err('does not exist')
	else
		local cmd = sub[args[1]]
		if cmd[3] ~= nil and #args > 1 then
			exec_cmd(table.slice(args, 2), cmd[3])
		else
			cmd[1](unpack(table.slice(args, 2)))
		end
	end
end

function list_cmd(level, sub)
	for key, cmd in pairs(sub) do
		printf('%s%s: %s', string.rep('  ', level), key, cmd[2])
		if cmd[3] ~= nil then
			list_cmd(level + 1, cmd[3])
		end
	end
end

function show_help()
	printf('Usage: %s <command>', arg[0])
	list_cmd(1, sub)
end

function cmd_err(msg, no_show_help)
	-- since argv0 is at... well... 0... even though this is lua...
	--- ...slice just returns arg without argv0 as the for starts at 1
	printf('Error: Command "%s" %s', table.concat(table.slice(arg), ' '), msg)
	if not no_show_help then
		printf('')
		show_help()
	end
	os.exit(2)
end

function dummy()
	cmd_err('requires a subcommand')
end

-- ///

-- TODO: use manman ecdsa key to verify response

-- NOTE: these will have mesh_ appended for static-ip
local mappings = {
  wifi = 'radio0',
  tunnel = 'vpn',
  eth = 'other',
  lan = 'other',
  wan = 'uplink',
  single = 'uplink',
}

-- https://stackoverflow.com/a/1647577/3990041
function string:split(pat)
  pat = pat or '%s+'
  local st, g = 1, self:gmatch("()("..pat..")")
  local function getter(segs, seps, sep, cap1, ...)
    st = sep and seps + #sep
    return self:sub(segs, (seps or 0) - 1), cap1 or sep, ...
  end
  return function() if st then return getter(st, g()) end end
end

-- https://gist.github.com/Uradamus/10323382
local function shuffle(tbl)
  for i = #tbl, 2, -1 do
    local j = math.random(i)
    tbl[i], tbl[j] = tbl[j], tbl[i]
  end
  return tbl
end

local function fetch_signed_json(remote, url)
  print('GET ' .. url)

  local code, res, result = fetch.request_raw(remote .. url)

  if code < 1 then
    print('E: failed to fetch')
    return 1
  end

  if code == 404 then
    print('E: location does not exist')
    return 2
  end

  if code ~= 200 then
    print('E: got status code ' .. code)
    return 1
  end

  -- cloudflare's reverse proxies send http chunked responses with chunk sizes
  -- for whatever reasons the chunk size gets smashed into the result
  -- this is a hack to fish it out, it is irrelevant on unaffected reverse proxies
  local j_start = result:find('{')
  local j_end = (result:reverse()):find("}")
  result = string.sub(result, j_start, (1 - j_end) > 0 and (1 - j_end) or nil)

  local sig = res.headers['x-ecdsa']
  local ts = res.headers['x-ecdsa-ts']

  if not sig or not ts then
    print('E: provided response is not signed')
    return 1
  end

  local data = ts .. '@' .. result
  if not ecdsa.verify(data, sig, mankey) then
    print('E: signature invalid or not signed with expected key')

    print('C: manman-sync is currently not properly signed')
    print('C: this error will be actually considered once manapi has been fixed')

    -- return 1
  end

  local obj = json.parse(result)

  if obj == nil then
    print('E: failed to parse json data')
    return 1
  end

  return false, obj
end

local function do_manman_sync()
  if not uci:get('gluon-manman-sync', 'sync', 'location_id') and not uci:get('gluon-manman-sync', 'sync', 'location') then
    print('E: manman location missing')
    return 2
  end

  -- check manman reachability, abort if not reachable

  local working_remote

  for _, remote in ipairs(shuffle(manapi)) do
    if not working_remote then -- don't try other remotes if we got one that works
      print('Trying remote ' .. remote)

      local success, a, b, c = pcall(function() return fetch.request_raw(remote .. '/') end)
      if not success then
        print('E: couldnt reach manman: ' .. a)
      else
        if a ~= 200 then
          print('E: couldnt reach manman - unexpected fetch result', a, b, c)
        else
          working_remote = remote
        end
      end
    end
  end

  if not working_remote then
    print('E: couldnt reach any manapi server, giving up')
    return 1
  end

  -- try to fetch data
  print('Fetching manman data...')
  local location_id = uci:get('gluon-manman-sync', 'sync', 'location_id')
  local location = uci:get('gluon-manman-sync', 'sync', 'location')

  if not location_id and location then
    local err, resp = fetch_signed_json(working_remote, '/find_location_by_name/' .. location)
    if err then
      return err
    end

    if not resp.id then
      printf('E: location %s is invalid (404)', location)
      return 2
    end

    uci:set('gluon-manman-sync', 'sync', 'location_id', resp.id)
    location_id = resp.id
  end

  local err, location = fetch_signed_json(working_remote, '/location/show/' .. location_id)
  if err then
    return err
  end

  print('Syncing with location ' .. location.location.name)

  uci:set('gluon-manman-sync', 'sync', 'location', location.location.name)

  if uci:get('gluon-manman-sync', 'sync', 'last_data') and json.stringify(location) == uci:get('gluon-manman-sync', 'sync', 'last_data') then
    print('Nothing changed, skipping sync')
    return 0
  end

  local local_router_id
  for id, _ in string.split(hostname, '-') do
    if id then
      local_router_id = id
    end
  end

  local local_node_name = uci:get('gluon-manman-sync', 'sync', 'node') or local_router_id
  local local_node = uci:get('gluon-manman-sync', 'sync', 'node_id') or local_router_id
  local node
  local should_hostname

  if #location.nodes > 1 then
    for _, potential_node in ipairs(location.nodes) do
      if (local_node ~= nil and tostring(potential_node.id) == local_node) or (local_node_name ~= nil and potential_node.name == local_node_name) then
        node = potential_node
        should_hostname = location.location.name .. '-' .. node.name
      end
    end
  else
    node = location.nodes[1]
    should_hostname = location.location.name
  end

  -- save node data to update name and persist ID
  uci:set('gluon-manman-sync', 'sync', 'node_id', node.id)
  uci:set('gluon-manman-sync', 'sync', 'node', node.name)

  if node == nil then
    print('E: unable to find matching node (selector "' .. node .. '")')
    return 2
  end

  print('Syncing data for node ' .. node.name)

  if hostname ~= should_hostname then
    print('Renaming node to ' .. should_hostname)
    pretty_hostname.set(uci, should_hostname)
  end

  local owner = uci:get_first('gluon-node-info', 'owner')
  uci:set('gluon-node-info', owner, 'contact', location.administrator.email)
  local _location = uci:get_first('gluon-node-info', 'location')
  uci:set('gluon-node-info', _location, 'share_location', '1')
  uci:set('gluon-node-info', _location, 'latitude', location.location.lat)
  uci:set('gluon-node-info', _location, 'longitude', location.location.long)

  -- TODO: compare device

  -- check if anything changed since last time
  -- if yes, apply changes and do gluon-reload

  local has_changes = false

  -- Use this when changing something that needs a reload and/or rollback (not the hostname)
  local function set(a, b, c, d, isbool)
    local curval

    if isbool then
      curval = uci:get_bool(a, b, c)
    else
      curval = uci:get(a, b, c)
    end

    if curval ~= d then
      uci:set(a, b, c, d)
      print('  Value', a, b, c, 'changed to', d, 'was', curval)
      has_changes = true
    else
      print('  Value', a, b, c, 'unchanged', d)
    end
  end

  local has_tunnel = false

  for _, net in ipairs(node.interfaces) do
    local net_name = net.name
    if net_name == 'tunnel' or net_name == 'vpn' or net_name == 'mesh_vpn' then
      has_tunnel = true
    end
    local net_mapped = mappings[net_name] or net_name
    if not string.find(net_mapped, '_') then
      net_mapped = 'mesh_' .. net_mapped
    end

    local cidr = ip.new(net.ip, net.netmask):string()

    print('Syncing ' .. net_name .. ' as ' .. net_mapped .. ' to ' .. cidr)
    set('gluon-static-ip', net_mapped, 'ip4', cidr)
  end

  print('Syncing mesh vpn: ' .. (has_tunnel and 'on' or 'off'))
  set('gluon', 'mesh_vpn', 'enabled', has_tunnel, true)

  uci:set('gluon-manman-sync', 'sync', 'last_data', json.stringify(location))

  uci:save('system')
  uci:save('gluon')
  uci:save('gluon-manman-sync')
  uci:save('gluon-static-ip')
  uci:save('gluon-node-info')
  os.execute('exec uci commit')

  if has_changes then
    print('Applying changes...')
    os.execute('exec gluon-reconfigure')
    os.execute('exec gluon-reload')
  else
    print('No settings changes, no reason to reload')
  end
end

local function maybe_sync()
  if uci:get_bool('gluon-manman-sync', 'sync', 'enabled') then
    do_manman_sync()
  else
    print('manman-sync not enabled, skipping')
  end
end

local function enable_sync(location, node)
  if not uci:get('gluon-manman-sync', 'sync', 'location') and not uci:get('gluon-manman-sync', 'sync', 'location_id') and not location then
    cmd_err('requires at least a location (No location was found in config)', true)
  end

  if location then
    printf('Config location %s', location)
    uci:set('gluon-manman-sync', 'sync', 'location', location)
		-- clear ID, gets fetched from manman-sync
		uci:set('gluon-manman-sync', 'sync', 'location_id', nil)
  end

  if node then
    printf('Config node %s', node)
    uci:set('gluon-manman-sync', 'sync', 'node', node)
		-- clear ID, gets fetched from manman-sync
		uci:set('gluon-manman-sync', 'sync', 'node_id', nil)
  end

  if not uci:get_bool('gluon-manman-sync', 'sync', 'enabled') then
    uci:set('gluon-manman-sync', 'sync', 'enabled', true)
    print('Enabled sync.')
    print('Trigger with manman-sync sync')
  else
    print('Sync already enabled.')
  end

  uci:save('gluon-manman-sync')
end

local function disable_sync()
  if uci:get_bool('gluon-manman-sync', 'sync', 'enabled') then
    uci:set('gluon-manman-sync', 'sync', 'enabled', false)
    print('Disabled sync.')
    uci:save('gluon-manman-sync')
  end
end

local function show_info()
  tprint({
    enabled = uci:get_bool('gluon-manman-sync', 'sync', 'enabled'),
    location = string.format('%s (id %s)',
      uci:get('gluon-manman-sync', 'sync', 'location') or '[none]',
      uci:get('gluon-manman-sync', 'sync', 'location_id') or '[will autodetect]'
    ),
    node = string.format('%s (id %s)',
      uci:get('gluon-manman-sync', 'sync', 'node') or '[none - use only available or error]',
      uci:get('gluon-manman-sync', 'sync', 'node_id') or '[will autodetect]'
    ),
  })
end

sub = {
	info = { show_info, 'Show manman-sync configuration' },
	help = { show_help, 'Show help' },
  sync = { maybe_sync, 'Trigger a manman-sync' },
  force_sync = { do_manman_sync, 'Trigger a manman-sync, despite being disabled' },
  enable = { enable_sync, 'Enable sync. [<location>] [<node>]' },
  disable = { disable_sync, 'Disable sync.' }
}

exec_cmd(table.slice(arg), sub)