-- Whole volume persistence, brute force.
--
-- One printable character per voxel, position implied by
-- index. Character is 33 + palette index; index 0 is air.
--
-- Every storage function takes a key and a proj flag, so the
-- same code serves the local autosave, named local slots, and
-- the project-storage homestead. proj=false/nil is local
-- storage (saveLocalData); proj=true is project storage
-- (saveProjectData), which is bundled with the project and
-- travels when it's shared -- the reason the homestead lives
-- there and the autosave does not.

WorldState = {}

WorldState.LIVE  = "WorldState"   -- local autosave slot
WorldState.HOME  = "Homestead"    -- baked default, project storage
WorldState.CHUNK = 8000

-- 80 x 32 x 80. Y is 32 rather than 16 because a volcano and
-- cliffs don't fit in 16.
WorldState.SX = 80
WorldState.SY = 32
WorldState.SZ = 80

-- True only while a saved volume is being painted back in.
-- BlockLibrary's notifyNeighbors checks this and skips
-- scheduling blockUpdate while it's set, so fences and tracks
-- don't re-derive their orientation from a half-painted
-- volume mid-sweep. applyStates is what actually restores
-- their facing, after the full volume exists.
WorldState.restoring = false

function WorldState.cells()
  return WorldState.SX * WorldState.SY * WorldState.SZ
end

-- readLocalData/readProjectData return numbers as floats, and
-- a float concatenates as "2.0" rather than "2" -- anything
-- through storage gets floored before it touches a key name.
function WorldState.num(v, dflt)
  return math.floor(tonumber(v) or dflt or 0)
end

function WorldState.put(key, val, proj)
  if proj then
    saveProjectData(key, val)
  else
    saveLocalData(key, val)
  end
end

function WorldState.getv(key, dflt, proj)
  if proj then return readProjectData(key, dflt) end
  return readLocalData(key, dflt)
end

function WorldState.writeChunked(base, str, proj)
  local n, i = 0, 1
  str = str or ""
  while i <= #str do
    n = n + 1
    WorldState.put(base.."."..n,
    str:sub(i, i + WorldState.CHUNK - 1), proj)
    i = i + WorldState.CHUNK
  end
  -- count last: until it lands, a torn write reads as absent
  WorldState.put(base..".n", n, proj)
end

function WorldState.readChunked(base, proj)
  local n = WorldState.num(WorldState.getv(base..".n", 0, proj))
  if n == 0 then return nil end
  
  local parts = {}
  for i = 1, n do
    local p = WorldState.getv(base.."."..i, nil, proj)
    if p == nil then return nil end
    parts[i] = p
  end
  return table.concat(parts)
end

function WorldState.clearChunked(base, proj)
  local n = WorldState.num(WorldState.getv(base..".n", 0, proj))
  for i = 1, n do WorldState.put(base.."."..i, nil, proj) end
  WorldState.put(base..".n", nil, proj)
end

function WorldState.writeTo(key, blob, pal, states, carts, proj)
  WorldState.writeChunked(key..".st", states, proj)
  WorldState.writeChunked(key..".mc", carts, proj)
  WorldState.put(key..".pal", pal, proj)
  -- blob last: until it lands, a torn write reads absent
  WorldState.writeChunked(key, blob, proj)
end

function WorldState.readChunks(key, proj)
  return WorldState.readChunked(key, proj)
end

function WorldState.clearKey(key, proj)
  WorldState.clearChunked(key, proj)
  WorldState.clearChunked(key..".st", proj)
  WorldState.clearChunked(key..".mc", proj)
  WorldState.put(key..".pal", nil, proj)
end

function WorldState.exists(key, proj)
  return WorldState.num(WorldState.getv(key..".n", 0, proj)) > 0
end

-- Returns blob, paletteString, stateString. Does not write.
-- The state list is sparse: "index,state;" per cell whose
-- BLOCK_STATE is nonzero, since zero is the default anyway.
function WorldState.serialize()
  local vox = scene.voxels
  local SX, SY, SZ = WorldState.SX, WorldState.SY, WorldState.SZ
  
  local idName = {}
  for _, b in pairs(vox.blocks:all()) do
    if b.id and b.name then idName[b.id] = b.name end
  end
  
  local palette, pindex = {}, {}
  local chars, n = {}, 0
  local states, ns = {}, 0
  
  for y = 0, SY - 1 do
    for z = 0, SZ - 1 do
      for x = 0, SX - 1 do
        local id, st = vox:get(x, y, z, BLOCK_ID, BLOCK_STATE)
        id = id or 0
        local p = 0
        
        if id ~= 0 then
          p = pindex[id]
          if not p then
            local nm = idName[id]
            if nm then
              palette[#palette + 1] = nm
              p = #palette
            else
              p = 0
            end
            pindex[id] = p
          end
        end
        
        n = n + 1
        chars[n] = string.char(33 + p)
        
        if p ~= 0 and type(st) == "number" and st ~= 0 then
          ns = ns + 1
          states[ns] = (n - 1)..","..math.floor(st)..";"
        end
      end
    end
  end
  
  return table.concat(chars),
  table.concat(palette, ";"),
  table.concat(states),
  WorldState.serializeCarts()
end

-- Only accepted at exactly the expected length -- a short or
-- long one means the volume size changed or a chunk is
-- missing, and either way the caller should fall back.
function WorldState.readFrom(key, proj)
  local blob = WorldState.readChunks(key, proj)
  if not blob then return nil end
  
  if #blob ~= WorldState.cells() then
    print("WorldState: "..key.." is "..#blob
    ..", expected "..WorldState.cells())
    return nil
  end
  
  local pstr = WorldState.getv(key..".pal", nil, proj)
  if type(pstr) ~= "string" then return nil end
  
  local ids, i = {}, 0
  for name in string.gmatch(pstr, "[^;]+") do
    i = i + 1
    local blk = scene.voxels.blocks:get(name)
    ids[i] = blk and blk.id or 0
  end
  
  return blob, ids,
  (WorldState.readChunked(key..".st", proj) or ""),
  (WorldState.readChunked(key..".mc", proj) or "")
end

-- index -> state, so the paint pass writes a cell's id and
-- its state in one set call. This matters for dynamic blocks
-- like Chest, whose child entities are built once from the
-- facing that has to already be there.
function WorldState.stateMap(states)
  if not states or #states == 0 then return nil end
  
  local map, n = {}, 0
  for idxs, sts in string.gmatch(states, "(-?%d+),(-?%d+);") do
    local idx = tonumber(idxs)
    if idx then
      map[idx] = tonumber(sts)
      n = n + 1
    end
  end
  
  if n == 0 then return nil end
  return map
end

-- Applied AFTER the whole bulk paint, since a cell's state
-- must land on a cell that already holds the right block id.
-- With WorldState.restoring suppressing notifyNeighbors during
-- the paint sweep, this is what actually restores fence and
-- track orientation -- nothing else touches it afterward.
function WorldState.applyStates(states, ids)
  if not states or #states == 0 then return 0 end
  
  local vox = scene.voxels
  local SX, SZ = WorldState.SX, WorldState.SZ
  local applied = 0
  
  for idxs, sts in string.gmatch(states, "(-?%d+),(-?%d+);") do
    local idx = tonumber(idxs)
    local st = tonumber(sts)
    if idx and st then
      local x = idx % SX
      local rest = math.floor(idx / SX)
      local z = rest % SZ
      local y = math.floor(rest / SZ)
      
      local id = vox:get(x, y, z, BLOCK_ID)
      if id and id ~= 0 then
        vox:set(x, y, z, BLOCK_ID, id, BLOCK_STATE, st)
        applied = applied + 1
      end
    end
  end
  
  return applied
end

-- Paints z from z1 to z2 inclusive. Air is WRITTEN, never
-- skipped -- resize() to the same dimensions doesn't clear
-- the volume, so a world loaded over another would otherwise
-- leave the previous world standing wherever this one is air.
function WorldState.paintStrip(blob, ids, z1, z2, smap)
  local vox = scene.voxels
  local SX, SY = WorldState.SX, WorldState.SY
  
  for y = 0, SY - 1 do
    for z = z1, z2 do
      local base = SX * (z + WorldState.SZ * y)
      for x = 0, SX - 1 do
        local idx = base + x
        local p = blob:byte(idx + 1) - 33
        
        local id = nil
        if p > 0 then id = ids[p] end
        
        if id and id ~= 0 then
          local st = smap and smap[idx]
          if st then
            vox:set(x, y, z, BLOCK_ID, id, BLOCK_STATE, st)
          else
            vox:set(x, y, z, BLOCK_ID, id)
          end
        else
          vox:set(x, y, z, 0)
        end
      end
    end
  end
end

function WorldState.save()
  local blob, pal, states, carts = WorldState.serialize()
  WorldState.writeTo(WorldState.LIVE, blob, pal, states, carts)
end

function WorldState.read()
  return WorldState.readFrom(WorldState.LIVE)
end

-- Carts are entities, not voxels, so the volume scan can't
-- see them. spawnMinecart(x, y, z, dir) is the public way
-- back in.
function WorldState.serializeCarts()
  if not MINECARTS then return "" end
  
  local out, n = {}, 0
  for _, c in ipairs(MINECARTS) do
    local cell = c.cell
    if cell then
      n = n + 1
      out[n] = math.floor(cell.x)..","..math.floor(cell.y)
      ..","..math.floor(cell.z)..","..tostring(c.dir or "e")..";"
    end
  end
  return table.concat(out)
end

-- Snapshotted first because Minecart:destroy removes itself
-- from MINECARTS, which would corrupt a live ipairs walk.
function WorldState.clearCarts()
  if not MINECARTS then return 0 end
  
  local doomed = {}
  for i, c in ipairs(MINECARTS) do doomed[i] = c end
  
  for _, c in ipairs(doomed) do
    pcall(function()
      if c.destroy then
        c:destroy()
      elseif c.entity then
        c.entity:destroy()
      end
    end)
  end
  
  MINECARTS = {}
  return #doomed
end

-- Must run AFTER the volume and its block states are
-- restored: Minecart:init falls if it doesn't find a track
-- under the cart.
function WorldState.applyCarts(carts)
  if not carts or #carts == 0 then return 0 end
  if not spawnMinecart then return 0 end
  
  local n = 0
  for xs, ys, zs, dir in string.gmatch(carts, "(-?%d+),(-?%d+),(-?%d+),(%a+);") do
    local ok = pcall(function()
      spawnMinecart(tonumber(xs), tonumber(ys), tonumber(zs), dir)
    end)
    if ok then n = n + 1 end
  end
  return n
end

----------------------------------------------------------------
-- the baked homestead
----------------------------------------------------------------

-- A raw copy, not a load-then-save: readFrom resolves the
-- palette into live block ids and throws the names away, so
-- round-tripping through it would rewrite the palette against
-- this session's registration order. Copying the strings
-- verbatim is cheaper and exactly faithful.
function WorldState.copyKey(srcKey, dstKey, srcProj, dstProj)
  local blob = WorldState.readChunked(srcKey, srcProj)
  if not blob then return false, "source volume missing" end
  
  if #blob ~= WorldState.cells() then
    return false, "source volume is "..#blob
    ..", expected "..WorldState.cells()
  end
  
  local pal = WorldState.getv(srcKey..".pal", nil, srcProj)
  if type(pal) ~= "string" then
    return false, "source palette missing"
  end
  
  local st = WorldState.readChunked(srcKey..".st", srcProj) or ""
  local mc = WorldState.readChunked(srcKey..".mc", srcProj) or ""
  
  WorldState.clearKey(dstKey, dstProj)
  WorldState.writeTo(dstKey, blob, pal, st, mc, dstProj)
  return true
end

function WorldState.homesteadExists()
  return WorldState.exists(WorldState.HOME, true)
end

function WorldState.readHomestead()
  return WorldState.readFrom(WorldState.HOME, true)
end

function WorldState.clearHomestead()
  WorldState.clearKey(WorldState.HOME, true)
  print("homestead: cleared, generator restored")
end

----------------------------------------------------------------
-- author tools -- run from the console, not from gameplay
--   listWorlds()               list saved local slots
--   bakeHomestead("My World")  promote that slot
--   bakeHomestead()            promote whatever is loaded now
--   WorldState.clearHomestead()
----------------------------------------------------------------

function bakeHomesteadFromLive()
  local blob, pal, states, carts = WorldState.serialize()
  WorldState.clearKey(WorldState.HOME, true)
  WorldState.writeTo(WorldState.HOME, blob, pal, states, carts, true)
  print("homestead: baked from the live world ("..#blob.." cells)")
  return true
end

function bakeHomesteadFromSlot(id)
  local ok, err = WorldState.copyKey(WorldSlots.key(id),
  WorldState.HOME, false, true)
  if ok then
    print("homestead: baked from slot "..WorldState.num(id))
  else
    print("homestead: bake failed -- "..tostring(err))
  end
  return ok, err
end

function bakeHomestead(name)
  if name == nil then return bakeHomesteadFromLive() end
  
  for _, e in ipairs(WorldSlots.readIndex()) do
    if e.name == name then
      return bakeHomesteadFromSlot(e.id)
    end
  end
  
  print("homestead: no saved world named \""..tostring(name).."\"")
  return false, "not found"
end

function listWorlds()
  local list = WorldSlots.readIndex()
  if #list == 0 then
    print("no saved worlds")
    return
  end
  for _, e in ipairs(list) do
    print(e.id..": "..e.name)
  end
end
