-- Copyright 2026 Vsevolod Stakhov
--
-- Licensed under the Apache License, Version 2.0 (the "License");
-- you may not use this file except in compliance with the License.
-- You may obtain a copy of the License at
--
--    http://www.apache.org/licenses/LICENSE-2.0
--
-- Unless required by applicable law or agreed to in writing, software
-- distributed under the License is distributed on an "AS IS" BASIS,
-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-- See the License for the specific language governing permissions and
-- limitations under the License.

-- Fuzzy hash update script (per-hash, atomic)
-- Handles ADD, DEL, and REFRESH operations including multi-flag merge and shingles
--
-- KEYS[1] = hash_key (prefix + digest)
-- KEYS[2] = count_key (prefix .. "_count")
-- KEYS[3..] = shingle keys (0 or 32 of them)
-- ARGV[1] = operation: "add", "del", "refresh"
-- ARGV[2] = flag (string number)
-- ARGV[3] = value (string number)
-- ARGV[4] = expire (string number, seconds)
-- ARGV[5] = timestamp (string number, calendar seconds)
-- ARGV[6] = is_weak ("0" or "1")
-- ARGV[7] = digest (raw bytes, used as value for shingle SETEX)

local key = KEYS[1]
local count_key = KEYS[2]
local op = ARGV[1]
local new_flag = tonumber(ARGV[2])
local new_value = tonumber(ARGV[3])
local expire = tonumber(ARGV[4])
local timestamp = ARGV[5]
local is_weak = tonumber(ARGV[6])
local digest = ARGV[7]

if op == "add" then
  -- Multi-flag merge logic: up to 8 flag slots (primary '' + extra '1'..'7')
  local data = redis.call('HGETALL', key)
  local fields = {}
  for i = 1, #data, 2 do
    fields[data[i]] = data[i+1]
  end

  local slots = {}
  local n_slots = 0

  -- Check primary slot
  if fields['V'] and fields['F'] then
    slots[''] = {flag=tonumber(fields['F']), value=tonumber(fields['V'])}
    n_slots = n_slots + 1
  end
  -- Check extra slots 1..7
  for i = 1, 7 do
    local si = tostring(i)
    if fields['V'..si] and fields['F'..si] then
      slots[si] = {flag=tonumber(fields['F'..si]), value=tonumber(fields['V'..si])}
      n_slots = n_slots + 1
    end
  end

  -- Try to find existing slot with same flag
  local found_slot = nil
  for slot, entry in pairs(slots) do
    if entry.flag == new_flag then
      found_slot = slot
      break
    end
  end

  if found_slot then
    -- Increment existing slot value
    redis.call('HINCRBY', key, 'V'..found_slot, new_value)
  elseif n_slots == 0 then
    -- Empty hash: create primary slot
    if is_weak == 1 then
      redis.call('HSETNX', key, 'F', new_flag)
      redis.call('HSETNX', key, 'V', new_value)
    else
      redis.call('HSET', key, 'F', new_flag, 'V', new_value)
    end
    slots[''] = {flag=new_flag, value=new_value}
    n_slots = 1
  elseif n_slots < 8 then
    -- Find an empty slot and use it
    local empty_slot = nil
    if not slots[''] then
      empty_slot = ''
    else
      for i = 1, 7 do
        if not slots[tostring(i)] then
          empty_slot = tostring(i)
          break
        end
      end
    end
    if empty_slot then
      redis.call('HSET', key, 'F'..empty_slot, new_flag, 'V'..empty_slot, new_value)
      slots[empty_slot] = {flag=new_flag, value=new_value}
      n_slots = n_slots + 1
    end
  else
    -- All 8 slots full: replace the minimum-value slot if new_value is larger
    if is_weak == 0 then
      local min_slot = nil
      local min_val = nil
      for slot, entry in pairs(slots) do
        if min_val == nil or entry.value < min_val then
          min_val = entry.value
          min_slot = slot
        end
      end
      if min_val ~= nil and new_value > min_val then
        redis.call('HSET', key, 'F'..min_slot, new_flag, 'V'..min_slot, new_value)
        slots[min_slot] = {flag=new_flag, value=new_value}
      end
    end
  end

  -- Ensure primary slot has the highest value (swap if needed)
  if n_slots > 1 then
    local max_val = nil
    local max_slot = nil
    for slot, _ in pairs(slots) do
      local v = tonumber(redis.call('HGET', key, 'V'..slot) or '0')
      if max_val == nil or v > max_val then
        max_val = v
        max_slot = slot
      end
    end
    if max_slot ~= nil and max_slot ~= '' and slots[''] then
      local pv = redis.call('HGET', key, 'V')
      local pf = redis.call('HGET', key, 'F')
      local bv = redis.call('HGET', key, 'V'..max_slot)
      local bf = redis.call('HGET', key, 'F'..max_slot)
      redis.call('HSET', key, 'V', bv, 'F', bf)
      redis.call('HSET', key, 'V'..max_slot, pv, 'F'..max_slot, pf)
    end
  end

  redis.call('HSETNX', key, 'C', timestamp)
  redis.call('EXPIRE', key, expire)
  redis.call('INCR', count_key)

  -- Handle shingles: SETEX each shingle key with expire and digest as value.
  -- Also persist the shingle set in the 'S' field of the digest hash
  -- ("idx_value" suffixes, comma separated), so digest-only deletes and
  -- refreshes can locate the slots without knowing the source message.
  if #KEYS > 2 then
    local suffixes = {}

    for i = 3, #KEYS do
      local suf = string.match(KEYS[i], '(%d+_%d+)$')
      if suf then
        suffixes[#suffixes + 1] = suf
      end
      redis.call('SETEX', KEYS[i], expire, digest)
    end

    if #suffixes > 0 then
      redis.call('HSET', key, 'S', table.concat(suffixes, ','))
    end
  end

elseif op == "del" then
  -- Reconstruct this digest's own shingle slots from the persisted set and
  -- delete those still owned by it: digest-only deletes (e.g. deletions by
  -- a hash list) carry no shingle keys, which used to leave orphaned slots
  local stored = redis.call('HGET', key, 'S')
  if stored then
    local prefix = string.sub(key, 1, #key - #digest)
    for suf in string.gmatch(stored, '[^,]+') do
      local skey = prefix .. '_' .. suf
      if redis.call('GET', skey) == digest then
        redis.call('DEL', skey)
      end
    end
  end

  redis.call('DEL', key)
  redis.call('DECR', count_key)

  for i = 3, #KEYS do
    redis.call('DEL', KEYS[i])
  end

elseif op == "refresh" then
  redis.call('EXPIRE', key, expire)

  -- Prefer the persisted shingle set: refresh the slots this digest still
  -- owns and reclaim expired ones, instead of touching the slots of the
  -- message that merely triggered the refresh
  local stored = redis.call('HGET', key, 'S')
  if stored then
    local prefix = string.sub(key, 1, #key - #digest)
    for suf in string.gmatch(stored, '[^,]+') do
      local skey = prefix .. '_' .. suf
      local owner = redis.call('GET', skey)
      if owner == digest then
        redis.call('EXPIRE', skey, expire)
      elseif not owner then
        redis.call('SETEX', skey, expire, digest)
      end
    end
  else
    for i = 3, #KEYS do
      redis.call('EXPIRE', KEYS[i], expire)
    end
  end
end

return 1
