#!/usr/bin/env lua
-- QWiKBoard: A QWK-powered message board system
-- Copyright (C) 2024 plugd

-- This program is free software: you can redistribute it and/or modify
-- it under the terms of the GNU General Public License as published by
-- the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.

-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-- GNU General Public License for more details.

-- You should have received a copy of the GNU General Public License
-- along with this program.  If not, see <http://www.gnu.org/licenses/>.

local posix = require "posix"

local usage = [[Usage: qwikboard DIR COMMAND arguments

Here DIR is the path to the message board directory and COMMAND is one of:
  newcfg
  init
  qwkout
  repin
  nncp_handler

Each command accepts different command-line arguments.]]

qb = {}
local function load_config ()
   local config_file = qb.path .. "/config.lua"
   io.write("Loading config from file '" .. config_file .. "'... ")
   dofile(config_file)
   print("done.")
end

--- Utility functions ---

local function write_object (fh, obj, lev)
   lev = lev or 0
   local ind = string.rep("\t", lev)

   if type(obj)=="table" then
      fh:write("{\n")
      for k,v in pairs(obj) do
         fh:write(ind .. "\t[" .. string.format("%q",k) .. "]=")
         write_object(fh, v, lev+1)
         fh:write(",\n")
      end
      fh:write(ind .. "}")
   else
      fh:write(string.format("%q",obj))
   end
end

local function print_object(obj)
   write_object(io.stdout, obj)
end


local function space_pad(s, n)
   return string.sub(s,1,n) .. string.rep(" ", math.max(0,n-string.len(s)))
end

local fs = {}

function fs.exists(filename)
   return io.open(filename, "r")
end

function fs.direxists(dirname)
   return pcall(posix.dir, dirname)
end

function fs.dir(path)
   return posix.dir(path)
end

function fs.mkdir(dirname)
   posix.mkdir(dirname)
end

function fs.rmdir(dirname, force)
   if force then
      for i,fname in ipairs(posix.glob(dirname .. "/*")) do
         posix.unlink(fname)
      end
   end
   assert(posix.rmdir(dirname))
end

function fs.rmfile(filename)
   posix.unlink(filename)
end

function fs.copy(filename, destfilename, skip_if_not_found)

   if skip_if_not_found and not fs.exists(filename) then
      return
   end

   src = assert(io.open(filename,"r"))
   dest = assert(io.open(destfilename,"w"))

   dest:write(src:read("*all"))
   dest:close()
   src:close()
end

function fs.touch(filename)
   fh = assert(io.open(filename,"w"))
   fh:close()
end

function fs.mktempdir()
   return posix.mkdtemp("/tmp/qwikboard-XXXXXX")
end

function fs.getmd5(filename)
   local md5_file_name = filename .. ".md5"
   os.execute(qb.md5sum .. " " .. filename .. " > " .. md5_file_name)
   local md5fh = assert(io.open(md5_file_name), "r")
   local md5f_str = md5fh:read("*all")
   md5fh:close()
   fs.rmfile(md5_file_name)

   return string.match(md5f_str, "^(%w+)%s")
end


--- Message database ---

function Message(msg)
   msgs[msg_index] = msg
   msg_index = msg_index + 1
end

local function load_messages(conf_name)
   msg_index = 1
   msgs = {}
   dofile(qb.path .. "/conferences/" .. conf_name .. ".msgs")
end


local function get_next_msg_number(conf_num)
   local msgnum_filename = qb.path .. "/conferences/" ..
      qb.conferences[conf_num+1] .. ".next"
   local mnf = assert(io.open(msgnum_filename, "r"))
   local next_num = tonumber(mnf:read("*all"))
   mnf:close()  
   return next_num
end

local function set_next_msg_number(conf_num, next_num)
   local msgnum_filename = qb.path .. "/conferences/" ..
      qb.conferences[conf_num+1] .. ".next"
   local mnf = assert(io.open(msgnum_filename, "w"))
   mnf:write(next_num)
   mnf:close()  
end

local function append_new_msg(conf_num, msg)
   local cf = assert(io.open(qb.path .. "/conferences/" ..
                             qb.conferences[conf_num+1] .. ".msgs", "a+"))
   cf:write("\n")
   cf:write("Message{\n")
   cf:write("\tnumber=" .. msg.number .. ",\n")
   cf:write("\tto=" .. string.format("%q", msg.to) .. ",\n")
   cf:write("\tfrom=" .. string.format("%q", msg.from) .. ",\n")
   cf:write("\tsubject=" .. string.format("%q", msg.subject) .. ",\n")

   if msg.replyto then
      cf:write("\treplyto=" .. msg.replyto .. ",\n")
   end
      
   cf:write("\tdate=" .. string.format("%q", msg.date) .. ",\n")
   cf:write("\ttime=" .. string.format("%q", msg.time) .. ",\n")
   cf:write("\ttext=" .. string.format("%q",msg.text) .. "\n")
   cf:write("}\n")
   cf:close()
end

local function export_messages(handle, output_file_name)

   local fh = assert(io.open(output_file_name, "w"))

   for cnum,cname in ipairs(qb.conferences) do
      -- msgs = {}
      -- dofile(qb.path .. "/conferences/" .. cname .. ".msgs")
      load_messages(cname)
      for i,msg in ipairs(msgs) do
         if string.match(string.lower(msg.from), "^%s*" .. string.lower(handle) .. "%s*$") then
            fh:write("MessageArea: " .. cname .. "\n")
            fh:write("MessageNum: " .. msg.number .. "\n")
            fh:write("From: " .. msg.from .. "\n")
            fh:write("To: " .. msg.to .. "\n")
            fh:write("Subject: " .. msg.subject .. "\n")
            fh:write("Date: " .. msg.date .. "\n")
            fh:write("Time: " .. msg.time .. "\n")
            fh:write("---\n")
            fh:write(msg.text .. "\n\n")
         end
      end
   end

   fh:close()
end

local function mirror_new_msg(mirror_dir, msg)
   local fname = msg.number .. "--" .. msg.date .. "-" ..
      msg.from .. ".txt"
   local fh = assert(io.open(mirror_dir .. "/" .. fname, "w"))

   local header = msg.subject .. " (" .. msg.from .. ", " .. msg.date .. ")"
   fh:write(header .. "\n")
   fh:write(string.rep("-",#header) .. "\n\n")
   fh:write(msg.text .. "\n")
   fh:close()
end


--- QWK formatting ---

local qwk = {}

function qwk.build_qwk(user_name, qwk_filename, first_unseen_msgs)
   first_unseen_msgs = first_unseen_msgs or {}

   local work_dir = fs.mktempdir()

   -- CONTROL.DAT
   qwk.write_control(work_dir, user_name)

   -- Copy BBS welcome, news and goodbye files

   fs.copy(qb.path .. "/notices/hello", work_dir .. "/HELLO", true)
   fs.copy(qb.path .. "/notices/news", work_dir .. "/NEWS", true)
   fs.copy(qb.path .. "/notices/goodbye", work_dir .. "/GOODBYE", true)

   -- Pack messages

   first_unseen_msgs = qwk.write_message(work_dir, first_unseen_msgs)

   -- Create archive in outgoing

   os.execute(qb.zip .. " -rj " .. qwk_filename .. " " .. work_dir)

   fs.rmdir(work_dir, true)

   return first_unseen_msgs
end


function qwk.write_control(target_dir, user_name)
   local cf = assert(io.open(target_dir .. "/CONTROL.DAT", "w"))

   cf:write(qb.name .. "\r\n")
   cf:write("\r\n") -- BBS location
   cf:write("\r\n") -- BBS phone number
   cf:write(qb.sysop .. "\r\n")
   cf:write("12345," .. qb.bbsid .. "\r\n")
   cf:write(os.date("%d-%m-%Y,%X") .. "\r\n") -- packet creation time
   cf:write(user_name .. "\r\n")
   cf:write("\r\n")
   cf:write(0 .. "\r\n")
   cf:write(0 .. "\r\n") -- TODO: Number of messages in packet
   cf:write(#qb.conferences-1 .. "\r\n") -- Index of final conference
   for i,v in ipairs(qb.conferences) do
      cf:write(i-1 .. "\r\n")
      cf:write(v .. "\r\n")
   end

   cf:write("HELLO\r\n")
   cf:write("NEWS\r\n")
   cf:write("GOODBYE\r\n")

   cf:close()
end

function qwk.write_message (target_dir, first_unseen_msgs)
   local mf = assert(io.open(target_dir .. "/MESSAGES.DAT", "w"))

   -- Also write MultiMail compatible RED file
   -- (For some reson, MM doesn't respect read status in header.)
   local rf = assert(io.open(target_dir .. "/" .. qb.bbsid .. ".RED", "w"))

   -- print_object(first_unseen_msgs)

   local pkt_msg_num = 0

   mf:write(space_pad("Produced by QWiKBoard.", 128))

   for cnum,cname in ipairs(qb.conferences) do
      -- msgs = {}
      -- dofile(qb.path .. "/conferences/" .. cname .. ".msgs")
      load_messages(cname)
      for i,msg in ipairs(msgs) do
         pkt_msg_num = pkt_msg_num + 1

         if first_unseen_msgs[cnum]
            and msg.number<first_unseen_msgs[cnum] then
            msg.read = true -- hacky way to notify writer
            rf:write("\x01")
         else
            rf:write("\x00")
         end
         
         qwk.write_message_header_block(mf, cnum, pkt_msg_num, msg)
         qwk.write_message_text(mf, msg)
      end

      first_unseen_msgs[cnum] = get_next_msg_number(cnum-1)
   end

   mf:close()
   rf:close()

   return first_unseen_msgs
end

function qwk.write_message_header_block(mf, conf_num, pkt_msg_num, msg)
   -- print_object(msg)
   if msg.read then -- Message status flag
      mf:write("-")
   else
      mf:write(" ")
   end

   mf:write(space_pad(tostring(msg.number), 7))
   mf:write(msg.date) -- mm-dd-yy
   mf:write(msg.time) -- hh:mm
   mf:write(space_pad(string.upper(msg.to), 25))
   mf:write(space_pad(string.upper(msg.from), 25))
   mf:write(space_pad(msg.subject, 25))
   mf:write(string.rep(" ", 12)) -- Password (blank)

   if msg.reply_to then
      mf:write(space_pad(tostring(msg.reply_to), 8))
   else
      mf:write(string.rep(" ", 8))
   end

   nblocks = math.ceil(string.len(msg.text)/128) + 1
   mf:write(space_pad(tostring(nblocks), 6)) -- number of blocks including header

   mf:write("\xe1") -- flag indicating message is "active" (not "to be killed")
   mf:write(string.pack("I2",conf_num-1))
   mf:write(string.pack("I2",pkt_msg_num))
   mf:write(" ") -- No network tag-line present
end

function qwk.write_message_text(mf, msg)
   mf:write((string.gsub(msg.text, "\n", "\xe3")))
   
   local padding = (128 - (string.len(msg.text) % 128)) % 128
   mf:write(string.rep("\0",padding))
end

function qwk.import_rep(user_name, rep_filename)

   if not fs.exists(rep_filename) then
      print("Error: reply packet file '" .. rep_filename .. "' does not exist.")
      return false
   end
   
   local archive_dir = fs.mktempdir()

   fs.copy(rep_filename, archive_dir .. "/repfile.rep")
   os.execute(qb.unzip .. " " .. archive_dir .. "/repfile.rep -d " .. archive_dir)


   local msg_filename = archive_dir .. "/" .. qb.bbsid .. ".MSG"

   if not fs.exists(msg_filename) then
      print("Error: MSG file not found.")
      return false
   end

   local mf = assert(io.open(msg_filename, "r"))
   local block = mf:read(128)

   if not string.find(block, qb.bbsid .. " *") then
      print("Error: Failed to match BBSID in first block.")
      return false
   end

   while mf:read(0) do
      qwk.process_reply(mf, user_name)
   end

   fs.rmdir(archive_dir, true)
end

function qwk.process_reply(mf, user_name)
   local msg = {}

   mf:read(1) -- Message status (ignore for now)
   local conf_num = tonumber(mf:read(7))
   msg.date = mf:read(8)
   msg.time = mf:read(5)
   msg.to = string.lower(string.gsub(mf:read(25), " *$", ""))
   mf:read(25) -- take user name from provided user name instead
   msg.from = string.lower(string.sub(user_name, 1, 25))
   msg.subject = string.gsub(mf:read(25), " *$", "")
   mf:read(12) -- Password (unused)
   msg.replyto = tonumber(mf:read(8))

   local nblocks = tonumber(mf:read(6))

   mf:read(1) -- flag (ignore for now)
   mf:read(2) -- conference number (already have this)
   mf:read(2) -- message number in packet (unused)
   mf:read(1) -- network tag flag (ignore)

   msg.text = ""
   for b=2,nblocks do
      msg.text = msg.text .. mf:read(128)
   end
   msg.text = string.gsub(msg.text, "\xe3", "\n")

   msg.number = get_next_msg_number(conf_num)
   set_next_msg_number(conf_num, msg.number+1)

   append_new_msg(conf_num, msg)

   local conf_name = qb.conferences[conf_num+1]

   print("Processed message for conference " .. conf_name .. ".")

   if qb.conference_mirrors and qb.conference_mirrors[conf_name] then
      mirror_new_msg(qb.conference_mirrors[conf_name], msg)
   end
   
end

--- Commands ---

local cmd = {}

-- Send default configuration to stdout
function cmd.newcfg ()

   local config_file = qb.path .. "/config.lua"

   if fs.exists(config_file) then
      print("Configuration file '" .. config_file .. "' already exists.  Aborting.")
      return false
   end

   print("Writing new configuration file " .. config_file .. "...")

   local cf = assert(io.open(config_file, "w"))
   cf:write([[
-- QWiKBoard Configuration File

qb.name = "My BBS Name"
qb.bbsid = "MYBBSID" -- upper case, max 8 char
qb.sysop = "Sysop Name"

qb.conferences = {
   "Announcements",
   "General",
   "Meta"
}

-- Use the following table to specify directories
-- to mirror conference contents to in a plain text
-- format suitable for a BBS phlog:
-- qb.conference_mirrors = {
--    ["Meta"] = "/path/to/mirror"
-- }

-- Ensure these point to zip and unzip on your system
qb.zip = "/usr/bin/zip"
qb.unzip = "/usr/bin/unzip"

-- If you're going to be using the NNCP handler, ensure
-- this points to the nncp-file binary on your system
-- and that the handler can execute it and read your
-- system's nncp.hjson file.
qb.nncp_file = "/usr/bin/nncp-file"

-- Location of md5sum.  If available, this is used to check
-- for duplicate REP submissions.
qb.md5sum = "/usr/bin/md5sum"

]])
   cf:close()
   
end

-- Initialise conference directory structure
function cmd.init ()
   load_config()

   if fs.exists(qb.path .. "/conferences") or
      fs.exists(qb.path .. "/notices") then
      print("One or more QWiKBoard directories already exist. Aborting.")
      return false
   end

   fs.mkdir(qb.path .. "/conferences")
   for i,v in ipairs(qb.conferences) do
      fs.touch(qb.path .. "/conferences/" .. v .. ".msgs")
      io.open(qb.path .. "/conferences/" .. v .. ".next", "w"):write(1)
   end

   fs.mkdir(qb.path .. "/notices")
   fs.touch(qb.path .. "/notices/hello")
   fs.touch(qb.path .. "/notices/news")
   fs.touch(qb.path .. "/notices/goodbye")
end

-- qwkout: generate QWK file
function cmd.qwkout ()
   load_config()

   if not arg[2] then
      print [[Usage: qwikboard DIR qwkout USER OUTFILE

Here OUTFILE is is the name of the output QWK file relative to
the current directory.  The QWK file is traditionally named
BBSID.QWK, with BBSID being the 8 character ID of the BBS.]]
      return
   end

   qwk.build_qwk(arg[1], arg[2])
end

-- repin: read REP file in

function cmd.repin()
    load_config()

   if not arg[2] then
      print [[Usage: qwikboard DIR repin USER INFILE

Here INFILE is the name of the REP file to import new messages
from. This is traditionally named BBSID.rep, where BBSID is
the 8 character max ID of the BBS, but QWiKBoard doesn't care
about this.]]
      return
   end

   qwk.import_rep(arg[1], arg[2])
end

local function restore_nncp_userdata()
   nncp_userdata = {}
   if fs.exists(qb.path .. "/nncp_users.lua") then
      dofile(qb.path .. "/nncp_users.lua")
   end
end

local function store_nncp_userdata()
   local fh = assert(io.open(qb.path .. "/nncp_users.lua", "w"))
   for uid,tab in pairs(nncp_userdata) do
      fh:write("nncp_userdata[" .. string.format("%q",uid) .. "] = ")
      write_object(fh, tab)
      fh:write("\n")
      -- for k,v in pairs(tab) do
      --    fh:write("\t" .. k .. "=" .. string.format("%q", v) .. ",\n")
      -- end
      -- fh:write("}\n")
   end
end

function cmd.nncp_handler()
   load_config()

   local node_id = os.getenv("NNCP_SENDER")

   if not node_id then
      print [[The nncp_handler command is intended to be called by nncp on
receipt of an exec packet.  For instance, the offline BBS could
be made accessible by user (node) NODENAME by adding the following
to their neigh entry in the /etc/nncp.hjson file

exec: {
  offline: ["/usr/local/bin/qwikboard", "/var/bbs/", "nncp_handler"]
}]]
     return false
   end

   restore_nncp_userdata()
   if not nncp_userdata[node_id] then
      nncp_userdata[node_id] = {}
   end

   local input_string = io.stdin:read("*all")

   if string.match(input_string, "^%s*get") then

      local handle = string.match(input_string, "^%s*get%s+(%w+)%s*$")
      if handle then
         nncp_userdata[node_id].handle = handle
      else
         handle = nncp_userdata[node_id].handle
      end
      
      if not handle then
         print("Error: no known handle for node " .. node_id .. ". Aborting.")
         return false
      end

      print("Building QWK packet for user '" .. handle .. "'")
      -- Construct QWK packet

      local out_file_name = os.tmpname()
      fs.rmfile(out_file_name)
      local out_file_name = out_file_name .. ".qwk"

      nncp_userdata[node_id].next_unread_msgs =
         qwk.build_qwk(handle, out_file_name,
                       nncp_userdata[node_id].next_unread_msgs)

      -- print(qb.nncp_file .. " " .. out_file_name .. " " ..
                 -- node_id .. ":" .. qb.bbsid .. ".QWK")
      os.execute(qb.nncp_file .. " " .. out_file_name .. " " ..
                 node_id .. ":" .. qb.bbsid .. ".QWK")

      fs.rmfile(out_file_name)

      store_nncp_userdata()

   else

      local handle = nncp_userdata[node_id].handle
      
      if not handle then
         print("Error: No user name for node " .. node_id .. " found. Aborting.")
         return false
      end

      if string.match(input_string, "^%s*export") then
         -- Constructing text file for export

         local out_file_name = os.tmpname()

         export_messages(handle, out_file_name)

         -- print(qb.nncp_file .. " " .. out_file_name .. " " ..
               -- node_id .. ":" .. qb.bbsid .. ".export.txt")
         os.execute(qb.nncp_file .. " " .. out_file_name .. " " ..
                    node_id .. ":" .. qb.bbsid .. ".export.txt")

         fs.rmfile(out_file_name)

      else
         print("Accepting REP packet from user '" .. handle .. "'")
         -- Accept REP packet

         local rep_file_name = os.tmpname()
         fs.rmfile(rep_file_name)
         local rep_file_name = rep_file_name .. ".rep"

         local rf = assert(io.open(rep_file_name, "w"))
         rf:write(input_string)
         rf:close()

         if qb.md5sum then
            -- Check for duplicate REP submissions
            
            local md5 = fs.getmd5(rep_file_name)
            local stored_md5 = nncp_userdata[node_id].md5
            if stored_md5 and md5 == stored_md5 then
               print("Error: Duplicate REP submission detected. Skipping.")
               return false
            end

            nncp_userdata[node_id].md5 = md5
            store_nncp_userdata()
         end

         qwk.import_rep(handle, rep_file_name)

         fs.rmfile(rep_file_name)
      end
   end
end

-- Main

local function main()
   if #arg < 2 then
      print(usage)
   else
      if not fs.direxists(arg[1]) then
         print("Directory '" .. arg[1] .. "' does not exist. Aborting.")
      else
         qb.path = arg[1]
         table.remove(arg, 1)
         if cmd[arg[1]] then
            local f = cmd[arg[1]]
            table.remove(arg,1)
            f()
         else
            print("Unknown command '" .. arg[1] .. "'")
            print(usage)
         end
      end
   end
end

main()
