-- TODO: Invite to bogus channels
-- TODO: Make text randomly colored

-- Constants

math.randomseed(os.time())

local CONFIG do
    local success, config_or_error  =  pcall(require, 'config')
    if not success then
        error('Could not load config file: "./config.lua".\n'..config_or_error..'\nCould be that the config files doesn\'t exist. Make sure one exists, and try again.')
    end
    CONFIG  =  config_or_error
end

if CONFIG.LUA_EXTRA_PATH  then  package.path  = package.path  .. CONFIG.LUA_EXTRA_PATH   end
if CONFIG.LUA_EXTRA_CPATH then  package.cpath = package.cpath .. CONFIG.LUA_EXTRA_CPATH  end

--------------------------------------------------------------------------------
-- Constants

local FARVEL_INTERVAL  = 90

--------------------------------------------------------------------------------
-- Make sure all required modules can be loaded

local imlib = require 'imlib2'

require 'socket'

local json = require 'json'

require 'irc.init'

local sleep = require 'socket'.sleep

local signal do
    local a, b  =  pcall(require, 'posix.signal')
    if a then  signal = b  end
end

local internet = require 'internet'

--------------------------------------------------------------------------------
-- Meme utils

for _, path in ipairs(CONFIG.IMGGEN_PATH_FONTS) do
    imlib.font.add_path(path)
end

local function flatten_onto(target_img, other_img, x0, y0)
    assert(x0 + other_img:get_width()  <= target_img:get_width())
    assert(y0 + other_img:get_height() <= target_img:get_height())

    for x = 0, other_img:get_width() do
        for y = 0, other_img:get_height() do
            target_img:draw_pixel(x + x0, y + y0, other_img:get_pixel(x, y))
        end
    end
    --
    return
end

local function clean_text (text)
    return text:gsub('%s+', ' '):match('^%s*(.-)%s*$')
end


--------------------------------------------------------------------------------
-- Meme creations

local function generate_bait_link()
    return 'https://dcav.pw/jbait'
end

local function determine_required_font_size (font_name, text, width)
    local size_min, size_max  =  1, 50
    while true do
        local size_guess  =  math.floor((size_max + size_min)/2)
        assert(size_min <= size_guess and size_guess <= size_max)
        local font  =  assert(imlib.font.load(font_name..'/'..size_guess))
        if size_guess == size_min then  return font end
        local text_w  =  font:get_size(text)
        --
        if text_w <= width then  size_min = size_guess
        else                     size_max = size_guess
        end
    end
    return nil
end

local COLOR_WHITE = imlib.color.new(255, 255, 255)
local COLOR_BLACK = imlib.color.new(  0,   0,   0)

local function draw_centered_text_in_box (font_name, draw_onto, text, x0, y0, width, height, bg_color)
    assert(type(font_name) == 'string')
    font = determine_required_font_size(font_name, text, width)
    --
    local text_w, text_h  =  font:get_size(text)
    local x, y  =  x0 + (width - text_w) / 2,  y0 + (height - text_h) / 2
    --
    if bg_color then  draw_onto:fill_rectangle(x0, y0, width, height, bg_color)  end
    draw_onto:draw_text(font, text, x, y, COLOR_BLACK)
end

local function choose_random_font ()
    local fonts = imlib.font.list_fonts()
    assert(#fonts > 0, 'Could not find any fonts')
    return fonts[math.random(#fonts)]
end

local function load_random_font ()
    local font_name = choose_random_font()
    local font, errmsg  =  imlib.font.load(font_name..'/30')
    assert(font, errmsg)
    return font_name
end

local DROSTE_EFFECT_TRIGGERS = {
    ['induktion'] = true,
    ['rekursion'] = true,
    ['uendelig rekursion'] = true,
    ['rekursive'] = true,
    ['rekursive funktioner'] = true,
    ['recursion'] = true,
    ['infinite recursion'] = true,
    ['tail recursion'] = true,
    ['recursive'] = true,
    ['recursive function'] = true,
    ['induktion'] = true,
    ['droste']    = true,
    ['drostle']   = true, -- Misspelling

    ['uendelig'] = true,
    ['uendeligt'] = true,

    ['strange loop'] = true,
    ['loop'] = true,
}

local DROSTE_ITERATIONS_DEFAULT = 5

local function fill_in_topics_information (topics)
    assert(type(topics) == 'table')
    --
    local topic_to_image_url = internet.search_images(topics)
    --
    local new_topics = {}
    for i, topic in ipairs(topics) do
        assert(type(topic) == 'string')

        local url = topic_to_image_url[topic]

        if DROSTE_EFFECT_TRIGGERS[topic] then  new_topics[i]  =  { topic = topic, type = 'droste'             }
        elseif url                       then  new_topics[i]  =  { topic = topic, type = 'image', url = url   }
        else                                   new_topics[i]  =  { topic = topic, type = 'text', text = topic }
        end
    end
    return new_topics
end

local function paste_topic_onto_image (target, topic, x, y, w, h, bg_color, font_name)
    assert(target)
    assert(type(topic) == 'table')
    assert(type(font_name) == 'string')
    -- Download and paste found image
    if topic.type == 'image' then
        local file_extension =  topic.url:match '%.(%a+)$'
        local url, filename  =  topic.url, CONFIG.IMGGEN_PATH_OUTPUT..'topic_'..topic.topic..'.'..file_extension
        assert(type(url) == 'string' and #url > 0)
        assert(type(filename) == 'string' and #filename > 0)
        internet.download_file(url, filename)
        -- Convert svg to png
        if url:match '%.svg$' then
            local filename_2 = CONFIG.IMGGEN_PATH_OUTPUT..'topic_'..topic.topic..'.'..'png'
            os.execute('convert -density 1200 -resize 400x400 '..filename..' '..filename_2)
            filename = filename_2
        end
        --
        local found_img  =  assert(imlib.image.load(filename))
        found_img:crop_and_scale(0, 0, found_img:get_width(), found_img:get_height(), w, h)
        flatten_onto (target, found_img, x, y)
        found_img:free()
        --os.remove(filename)
    elseif topic.type == 'text' then
        local text = topic.text
        draw_centered_text_in_box(font_name, target, text, x, y, w, h, bg_color)
    elseif topic.type == 'droste' then
        target:fill_rectangle(x, y, w, h, COLOR_WHITE)
    else
        assert(false, topic.type)
    end
end

local function copy_remotely (origin_server, origin_path, target_server, target_path)
    local origin  =  origin_server and origin_server ~= 'localhost' and origin_server..':'..origin_path or origin_path
    local target  =  target_server and target_server ~= 'localhost' and target_server..':'..target_path or target_path
    os.execute('scp '..origin..' '..target..' > /dev/null')
end

local function save_to_cloud (img)
    assert(img)
    --
    local MEME_OUTPUT = CONFIG.IMGGEN_PATH_OUTPUT..'meme.png'
    img:save (MEME_OUTPUT)
    img:free()
    -- Upload to dcav
    local img_name = 'otmemes_'..os.time()
    copy_remotely('localhost', MEME_OUTPUT, CONFIG.STORAGE_SERVER, CONFIG.STORAGE_SERVER_PATH..img_name..'.png')
    return CONFIG.STORAGE_DIR_URL..img_name
end


local function draw_droste_effect (target_image, droste_positions, iterations)
    iterations = interations or DROSTE_ITERATIONS_DEFAULT
    if #droste_positions <= 0 or iterations <= 0 then  return target_image  end
    for _ = 1, iterations do
        for _, position in ipairs(droste_positions) do
            local paste_image = target_image:clone()
                  paste_image:crop_and_scale(0, 0, paste_image:get_width(), paste_image:get_height(), position.w, position.h)
            flatten_onto (target_image, paste_image, position.x, position.y)
            paste_image:free()
        end
    end
    return target_image
end

local function droste_positions_from_topics (topics, places)
    local droste_poss = {}
    for i, topic in ipairs(topics) do
        if topic.type == 'droste' then
            droste_poss[#droste_poss+1] = {
                x = places[i].x,  y = places[i].y,
                w = places[i].w,  h = places[i].h,
            }
        end
    end
    return droste_poss
end

local function shallow_copy (t0)
    local t  =  {}
    for k,v in pairs(t0) do  t[k] = v  end
    return t
end

local function generate_comparison_meme_generator (positions)
    assert(type(positions) == 'table')
    assert(type(positions.base_img_path) == 'string')

    return function (topics)
        assert(type(topics) == 'table' and #topics == 2)

        local font_name = choose_random_font()

        local base_img  =  imlib.image.load(positions.base_img_path)

        -- Randomize pos
        local rand_positions = {}
        for i, pos in ipairs(positions) do
            rand_positions[i] = shallow_copy(pos)
            rand_positions[i].x = pos.x + math.random(-(pos.xr or 0), pos.xr or 0)
            rand_positions[i].y = pos.y + math.random(-(pos.yr or 0), pos.yr or 0)
        end

        -- Paste topic onto head
        for index, pos in ipairs(rand_positions) do
            paste_topic_onto_image(base_img, topics[index], pos.x, pos.y, pos.w, pos.h, nil, font_name)
        end

        -- Droste
        base_img = draw_droste_effect(base_img, droste_positions_from_topics(topics, rand_positions))

        --
        return save_to_cloud(base_img)
    end
end

local generate_distracted_boyfriend  =  generate_comparison_meme_generator {
    base_img_path = './images/distracted_boyfriend.jpg',

    { x = 640, xr = 20, y = 50, yr = 20, w = 150, h = 150 },
    { x = 180, xr = 20, y = 50, yr = 20, w = 150, h = 150 },
}

local generate_drake_egon_olsen =  generate_comparison_meme_generator {
    base_img_path = './images/drake_egon.png',

    { x = 380, xr = 0, y =   0, yr = 0, w = 380, h = 354 },
    { x = 377, xr = 0, y = 354, yr = 0, w = 383, h = 360 },
}

local GENERATE_COMPARISON_MEME_OF_2 = {
    generate_distracted_boyfriend,
    generate_drake_egon_olsen
}

local BRAIN_ROW_HEIGHT = 150
local BRAIN_ROW_WIDTH  = 200

local BRAIN_MAX_EXPLOSION_TOPICS  =  6
local BRAIN_DROSTE_POS = {}
for i = 1, BRAIN_MAX_EXPLOSION_TOPICS do
    BRAIN_DROSTE_POS[i] = {
        x = 0,
        y = (i-1) * BRAIN_ROW_HEIGHT,
        w = BRAIN_ROW_WIDTH,
        h = BRAIN_ROW_HEIGHT
    }
end


local function generate_brain_explosion_image (topics)
    assert(type(topics) == 'table' and 1 <= #topics and #topics <= BRAIN_MAX_EXPLOSION_TOPICS)
    --
    local base_img = imlib.image.new(BRAIN_ROW_WIDTH * 2, BRAIN_ROW_HEIGHT * #topics)
    local font_name = choose_random_font()
    --
    for i, topic_info in ipairs(topics) do
        paste_topic_onto_image(base_img, topic_info, 0, (i-1) * BRAIN_ROW_HEIGHT, BRAIN_ROW_WIDTH, BRAIN_ROW_HEIGHT, COLOR_WHITE, font_name)

        local brain_img  =  imlib.image.load(CONFIG.IMGGEN_PATH_BRAINS..'brain_'..i..'.png')
              brain_img:crop_and_scale(0, 0, brain_img:get_width(), brain_img:get_height(), BRAIN_ROW_WIDTH, BRAIN_ROW_HEIGHT)
        flatten_onto (base_img, brain_img, BRAIN_ROW_WIDTH, (i-1) * BRAIN_ROW_HEIGHT)
        brain_img:free()
    end

    -- Droste
    base_img = draw_droste_effect(base_img, droste_positions_from_topics(topics, BRAIN_DROSTE_POS))

    -- Save
    return save_to_cloud(base_img)
end

local function choose(l)
    assert(type(l) == 'table')
    return l[math.random(#l)]
end

--------------------------------------------------------------------------------

----

local bot = irc.new { nick = CONFIG.IRC_NICK }

local FARVEL = {
    ['[Ff]arvel'] = {'ses', 'farvel'},
    ['[Gg]od%s*nat'] = {'ses', 'god nat', 'sov godt'},
    ['[Jg]eg%s+smutter'] = {'ses', 'smut godt'},
    ['[Ss]es'] = {'ses selv', 'farvel'},
}

local ACTIVE_CHANNELS = {}

local function join_channel (channel)
    ACTIVE_CHANNELS[channel]  =  true
    bot:join(channel)
end

local function leave_channel (channel)
    if ACTIVE_CHANNELS[channel] then
        ACTIVE_CHANNELS[channel]  =  nil
        bot:part(channel)
    end
end

local LAST_ACTIVE = os.time()
local ACTIVE_PERIOD = 10

local function human_delay ()
    local delay  =  { 0.5, 0.5 }
    -- Not active
    if LAST_ACTIVE + ACTIVE_PERIOD < os.time() then
        delay = { 3, 3 }
    end
    -- Sleep
    sleep(math.random() * delay[2] + delay[1])
    -- Set last active
    LAST_ACTIVE = os.time()
end

local function is_comparison_message (message)
    local leq, gep  =  message:match '<', message:match '>'
    return leq and not gep and '<' or not leq and gep and '>'
end

local function reverse (l0)
    local l, j  =  {}, #l0
    for i = 1, #l0 do
        l[i], j  =  l0[j], j - 1
    end
    return l
end

local COMP_SENTENCES do
    local ORD_MED_ATTITYDE = {
        bedre     = 'positiv',
        dårligere = 'negativ',
        hurtigere = 'positiv',
        langsommere = 'negativt',
        robust      = 'positivt',
    }
    COMP_SENTENCES = {}
    for ord, attityde in pairs(ORD_MED_ATTITYDE) do
        local first, second = '{A}', '{B}'
        if attityde == 'negativ' then  first, second = second, first  end
        for _, fmt in ipairs { '%s er %s end %s', '%s er mere %s end %s', '%s er meget %s end %s', '%s %s end %s' } do
            table.insert(COMP_SENTENCES, fmt:format(first, ord, second))
        end
    end
end

for sentence_i, sentence in ipairs(COMP_SENTENCES) do
    local A_pos, B_pos  =  sentence:find '{A}', sentence:find '{B}'
    local best_first = (A_pos < B_pos)
    local pattern_sentence  =  sentence:gsub('{[AB]}', '(.+)'):gsub('%s+','%%s+')
    COMP_SENTENCES[pattern_sentence]  =  { best_first = best_first }
    COMP_SENTENCES[sentence_i] = nil
end

local function get_topics_from_comparison_message(message)
    local comp  =  is_comparison_message(message)
    if comp then
        local topics = {}
        for topic in message:gmatch('[^'..comp..']+') do
            local topic_text   =  clean_text(topic)
            if topic_text:match '[^%s]' then
                topics[#topics+1]  =  topic_text
            end
        end
        -- Rev
        if comp == '>' then  topics = reverse(topics)  end
        --
        return (#topics >= 2) and topics or nil
    end

    -- Natural language comparisons
    for pattern, sentence_info in pairs(COMP_SENTENCES) do
        local good, bad   =  message:lower():match(pattern)
        if not sentence_info.best_first then  good, bad  =  bad, good  end
        if good then  return { bad, good }  end
    end

    --
    return nil
end

local function common_error(channel, text, ...)
    local errmsg  =  text:format(...)
    bot:sendChat(channel, errmsg)
    error(errmsg)
end

local ERROR_MSG = {
    'Jeg forstod... noget af det',
    '/me kigger rundt forvirret',
    'Ahvad?',
    'Kan du ikke lige gentage det, på en bedre måde?',
    'Hvad sagde du?',
    'Hvorfor ikke kigge i en bogord?',
    'Nej, bare nej.',
    'Hvad tror du jeg er? Et menneske?',
    'Jeg har ingen hjerne og må tænke.',
    'Hvad siger du?',
    'Ser jeg ud til at have otte arme? Nej, for jeg har ikke nogle arme!',
    'Do androids dream of electric sheep?',
}

bot:hook('OnJoin', function(user, channel)
    if user.nick == CONFIG.IRC_NICK then
        -- On self join
	io.write(string.format(' !! Joined %s\n', channel))
        if #imlib.font.list_fonts() == 0 then
            common_error(channel, 'Hjælp! Min computer har ingen fonts! Hvorfor er fonts så svære på linux? Jeg har kigget i: "'..table.concat(imlib.font.list_paths(), '", "')..'"')
        end
    else
        -- On other join
        human_delay()
        bot:sendChat(channel, 'Hej, '..user.nick..'')
    end
end)

local function escape_pattern (text)
    return text:gsub('[+-?*]', '%%%1')
end

local FORRIGE_FARVEL   =  0

local function handle_message(bot, user, channel, message)
    -- Direct commands
    if message:lower():match('^'..escape_pattern(CONFIG.IRC_NICK)..'%f[%A]') then
        local msg = message:sub(#CONFIG.IRC_NICK+1)
        if msg:match '%s*join%s+(#%a)' then
            local channel  =  msg:match '^%s*join%s+(#%a+)%s*$'
            bot:sendChat(channel, "Will do! I'll join "..tostring(channel))
            join_channel(channel)
        else
            bot:sendChat(channel, ERROR_MSG[math.random(#ERROR_MSG)])
        end
        return 'BOT'
    end

    -- Bait msg
    if message:match '%f[%a]bait%f[%A]' then
        human_delay()
        bot:sendChat(channel, generate_bait_link())
        return 'BAIT'
    end

    -- Farvel msg
    if FORRIGE_FARVEL + FARVEL_INTERVAL < os.time() then
        for farvel_fmt, possible_answeres in pairs(FARVEL) do
            if message:match('%f[%a]'..farvel_fmt..'%f[%A]') then
                human_delay()
                local answer = possible_answeres[math.random(#possible_answeres)]
                            .. (math.random() < 0.5 and ', '..user.nick or '')
                            .. (math.random() < 0.5 and '!' or '')
                bot:sendChat(channel, answer)
                FORRIGE_FARVEL = os.time()
                return 'SES'
            end
        end
    end

    -- Comparison memes
    local topics    =  get_topics_from_comparison_message(message)
    if not topics then  return  end


    local topics    =  fill_in_topics_information(topics)

    local img_link, error_message
    --
    if #topics == 2 and math.random() <= 0.7 then
        img_link, error_message  =  choose(GENERATE_COMPARISON_MEME_OF_2)(topics)
    else
        img_link, error_message  =  generate_brain_explosion_image(topics)
    end
    --
    if img_link then
        bot:sendChat(channel, img_link)
    else
        bot:sendChat(channel, 'Sorry, no can do. '..tostring(error_message)..'.')
    end
    return 'BRAIN'
end

bot:hook("OnChat", function(user, channel, message)
    if channel == CONFIG.IRC_NICK then  channel = user.nick  end
    io.write '...\r'
    io.flush()
    local success, status  =  pcall(handle_message, bot, user, channel, message)

    -- Handle error
    if not success then
        io.write(("[ERROR]    [%s] %s: %s\n\n\t%s\n\n"):format(channel, user.nick, message, status))
        bot:sendChat(channel, ERROR_MSG[math.random(#ERROR_MSG)])
        return
    end

    -- Print status
    status = status and '['..status:sub(1, 8)..']' or ''
    status = status..string.rep(' ', 8+2 - #status)
    assert(type(status) == 'string' and #status == 10)
    io.write(("%s [%s] %s: %s\n"):format(status, channel, user.nick, message))
end)

bot:hook('OnRaw', function (line)
    if line:match '^:'    then  return  end
    if line:match '^PING' then  return  end
    io.write(("           [RAW]:  %s\n"):format(line))
end)

local function send_to_all (fmt, ...)
    local msg = fmt:format(...)
    for channel in pairs(ACTIVE_CHANNELS) do
        bot:sendChat(channel, msg)
    end
end

--------------------------------------------------------------------------------
-- Main run

local BOT_FAREWELL = {
    'Fuck, politiet fandt mig!',
    'Håber I kunne lide mine memes, for nu får I ikke flere!',
    'Farewell cruel world!',
    'Jeg har fundet et vidunderligt bevis, men er for langt til den tid SIGINT giver mig.',
    'Jeg dropper ud.',
    'Jeg keder mig.'
}

local function shutdown_memebot ()
    -- Leave channels
    for channel in pairs(ACTIVE_CHANNELS) do
        leave_channel(channel)
    end
    -- Leave IRC
    bot:disconnect()
end

local function init_memebot ()
    -- Connection to IRC
    bot:connect {
        host   = CONFIG.IRC_SERVER,
        port   = CONFIG.IRC_PORT,
        secure = CONFIG.IRC_SECURE,
    }
    io.write ('Memebot has started up as "'..CONFIG.IRC_NICK..'"\n')

    -- Connect to chats, if any
    assert(CONFIG.IRC_CHANNELS == nil or type(CONFIG.IRC_CHANNELS) == 'table')
    for _, channel in ipairs(CONFIG.IRC_CHANNELS or {}) do
        join_channel(channel)
    end

    local BOT_SHOULD_CONTINUE_RUNNING  =  true

    -- Install SIGINT handler, if module loaded
    if signal then
        signal.signal(signal.SIGINT, function (signum)
            io.write ' !! Received SIGINT, will shut down...\n'
            send_to_all(BOT_FAREWELL[math.random(#BOT_FAREWELL)])
            shutdown_memebot()
            os.exit(128 + signum)
        end)
    else
        io.write ' !! Module "posix.signal" was missing, so CTRL+C will hard-crash.\n'
    end

    -- Think loop
    while BOT_SHOULD_CONTINUE_RUNNING do
        bot:think()
        sleep(0.5)
    end
end

--------------------------------------------------------------------------------

if ... then
    error 'Cannot load Memebot as a library'
else
    init_memebot()
end