-- Columns: a custom (Lua) tiling layout — a row of vertical columns. Think of it as
-- one long horizontal strip of columns that spans every monitor: focus/move flow off
-- the edge of one monitor onto the next, so it behaves like a huge row of scrolling
-- workspaces rather than a set of isolated boxes.
--
-- Each column is an INDEPENDENTLY-SCROLLING vertical strip (the whole point): when a
-- column holds more windows than fit, it pans to keep the focused one in view while the
-- other columns stay put — like the wheels of a combination lock. Sizing is flexible,
-- nothing is a fixed fraction:
--   * Each COLUMN has a width weight; the viewport width is split between columns by
--     those weights. Resizing left/right grows/shrinks the focused column.
--   * Each WINDOW has a height weight. The base height of a weight-1 window is
--     viewport/`rows` (rows = windows-per-view). A column that is *underfull* stretches
--     its windows to fill (a lone window fills the column; two equal windows share it
--     50/50) — so you can stack a file-explorer over a terminal in column 1 while
--     Firefox owns column 2. A column that is *overfull* keeps natural heights and
--     SCROLLS. Resizing up/down retunes the focused window's height weight.
-- Weights and per-column pan persist across relayouts.
--
-- This is a real custom layout (hl.layout.register), not the built-in scrolling engine —
-- that one has a single shared tape; this pans each column apart.
--
-- Controls (routed here from binds.lua via `hl.dsp.layout("<cmd>")` → layout_msg):
--   focus l/r/u/d   — l/r step between columns (off the edge → next/prev monitor),
--                     u/d step within the focused column (which pans to it). Swap vertical.
--   move  l/r/u/d   — l/r move between columns (off the edge → next/prev monitor).
--                     u/d PARK/UNPARK within the column: moving toward a single neighbour
--                     parks the two side by side in one slot (persistent; both keep the full
--                     scroll-axis length, split across the column — a vertical divider when
--                     horizontal — and the column closes up so there's no gap). Moving a
--                     parked window again pops it out and past its partner.
--   resize l/r/u/d  — l/r change the focused column's width, u/d the focused window's
--                     height (weights, so neighbours give/take space). Axes swap vertical.
--   count h/j/k/l   — horizontal ws: h/l = −/＋ columns, k/j = −/＋ windows-per-view.
--                     vertical ws:   k/j = −/＋ columns, h/l = −/＋ per-view.
--   cols +/-/N      — direct column-count set (the menu's stepper).
--   orient h/v      — orientation (set from the menu dropdown).
--   center on/off/toggle — "center-focused" switch (menu): when a column is overfull
--                     and scrolls, keep the focused window centred in the column
--                     instead of the minimal-movement default (whichever edge of the
--                     focused window would otherwise go off-screen just barely comes
--                     back into view). Global (mirrors the scrolling layout's
--                     "Centered follow" toggle), persisted across reloads, OFF by
--                     default. Only ever repositions the column holding the focused
--                     window — every other column's pan is untouched, since there is
--                     only one focused window at a time.
--
-- State is per workspace; a window's column assignment, order, both weight tables and
-- each column's pan offset persist across relayouts.

local state = {}   -- [wsid] = { ncols, rows, orient, assign={addr=col}, seq={addr=n},
                   --            nextseq, colw={col=weight}, winh={addr=weight}, pan={col=px} }

-- Resize weights: base 1.0, clamped to a sane band so nothing collapses or hogs.
local RSTEP, RMIN, RMAX = 0.15, 0.25, 6.0
local function clampw(v) return math.max(RMIN, math.min(RMAX, v)) end

-- Publish per-workspace column count so the astal-menu can show a live stepper.
local STATE_FILE = (os.getenv("HOME") or "") .. "/.cache/astal-menu/columns-state.json"

-- "center-focused" switch — global (like the scrolling layout's own fit-method
-- toggle), so a single state, not per-workspace. Seeded once at module load from
-- the last-published state, since a reload wipes this module's Lua state (same
-- reason ncols/rows/orient are seeded — see seed() below).
local center_focus = false
do
    local f = io.open(STATE_FILE, "r")
    if f then
        local data = f:read("*a") or ""; f:close()
        if data:match('"_center"%s*:%s*true') then center_focus = true end
    end
end

local function publish()
    local parts = { string.format('"_center":%s', tostring(center_focus)) }
    for wsid, s in pairs(state) do
        parts[#parts + 1] = string.format('"%s":{"cols":%d,"rows":%d,"orient":"%s"}',
            tostring(wsid), s.ncols, s.rows, s.orient)
    end
    pcall(function()
        local f = io.open(STATE_FILE, "w")
        if f then f:write("{" .. table.concat(parts, ",") .. "}\n"); f:close() end
    end)
end

-- Seed ncols/rows/orient for a workspace from the last published state — a reload wipes
-- this module's Lua state, so without this the user's column count and orientation reset
-- to defaults every reload. (Per-window assignment/weights are not restored; they reflow.)
local function seed(wsid)
    local out = {}
    local f = io.open(STATE_FILE, "r")
    if not f then return out end
    local data = f:read("*a") or ""; f:close()
    local blob = data:match('"' .. tostring(wsid) .. '"%s*:%s*(%b{})')
    if not blob then return out end
    out.ncols  = tonumber(blob:match('"cols"%s*:%s*(%d+)'))
    out.rows   = tonumber(blob:match('"rows"%s*:%s*(%d+)'))
    out.orient = blob:match('"orient"%s*:%s*"(%a+)"')
    return out
end

local function ws_state(wsid)
    local s = state[wsid]
    if not s then
        local sd = seed(wsid)
        s = { ncols = sd.ncols or 2, rows = sd.rows or 2, orient = sd.orient or "h",
              assign = {}, seq = {}, nextseq = 1, colw = {}, winh = {}, pan = {}, pair = {} }
        state[wsid] = s
        publish()
    end
    return s
end

-- Reflow every window evenly across the current column count, by insertion order.
local function reflow(s, targets)
    local addrs = {}
    for _, tg in ipairs(targets) do
        local ok, addr = pcall(function() return tg.window.address end)
        if ok and addr then addrs[#addrs + 1] = addr end
    end
    table.sort(addrs, function(a, b) return (s.seq[a] or 0) < (s.seq[b] or 0) end)
    for i, addr in ipairs(addrs) do s.assign[addr] = ((i - 1) % s.ncols) + 1 end
    for k in pairs(s.pair) do s.pair[k] = nil end   -- a redistribute breaks parked pairs
end

local function win_ws_id(win)
    local ok, ws = pcall(function() return win.workspace end)
    if ok and ws ~= nil then
        local ok2, id = pcall(function() return ws.id end)
        if ok2 and id then return id end
        local id2 = tostring(ws):match("%((%-?%d+)")
        if id2 then return tonumber(id2) end
    end
    return 0
end

local function focused_addr()
    local aw = hl.get_active_window and hl.get_active_window()
    if not aw then return nil end
    local ok, a = pcall(function() return aw.address end)
    return ok and a or nil
end

-- Build the per-column ordered address lists from the live targets, reconciling the
-- persistent assignment (drop closed windows; a single new window stacks into the
-- working column, a batch spreads across the emptiest columns — see below).
local function columnize(s, targets)
    local present, tgt_of = {}, {}
    for _, tg in ipairs(targets) do
        local ok, addr = pcall(function() return tg.window.address end)
        if ok and addr then present[addr] = true; tgt_of[addr] = tg end
    end
    for addr in pairs(s.assign) do
        if not present[addr] then
            local p = s.pair[addr]
            if p then s.pair[p] = nil; s.pair[addr] = nil end   -- release a parked partner
            s.assign[addr] = nil; s.seq[addr] = nil; s.winh[addr] = nil
        end
    end
    local foc = focused_addr()
    local counts = {}
    for c = 1, s.ncols do counts[c] = 0 end
    for addr, c in pairs(s.assign) do
        if present[addr] and c >= 1 and c <= s.ncols then counts[c] = counts[c] + 1 end
    end
    local fresh = {}
    for addr in pairs(present) do if not s.assign[addr] then fresh[#fresh + 1] = addr end end
    local function least_col()
        local best, bestn = 1, math.huge
        for c = 1, s.ncols do if counts[c] < bestn then best, bestn = c, counts[c] end end
        return best
    end
    if #fresh == 1 and next(s.assign) ~= nil then
        -- ONE new window while others already exist → it stacks into the column the user
        -- is working in (the new window itself grabbed focus, so use the remembered
        -- last-focused column). Opening a 2nd window thus shares that column instead of
        -- spreading to an empty one — that's how you get two windows at one height.
        local c = s.lastcol
        if not (c and c >= 1 and c <= s.ncols) then
            -- focus memory not ready (a just-opened window's focus can lag the relayout)
            -- → join the most-recently-opened window's column instead.
            local best_seq = -1
            for addr, col in pairs(s.assign) do
                if present[addr] and (s.seq[addr] or 0) > best_seq then
                    best_seq = s.seq[addr]; c = col
                end
            end
            if not (c and c >= 1 and c <= s.ncols) then c = least_col() end
        end
        -- ...but don't let one column run away: if it's already 2+ ahead of the emptiest,
        -- send the newcomer to the emptiest instead. This both balances rapid opens and
        -- keeps a reload (which re-adds every window one-by-one) from piling into column 1.
        local lc = least_col()
        if counts[c] - counts[lc] >= 2 then c = lc end
        s.assign[fresh[1]] = c; counts[c] = counts[c] + 1
        s.seq[fresh[1]] = s.nextseq; s.nextseq = s.nextseq + 1
    else
        -- first window, or several appearing at once (session start / restore) → spread
        -- across the emptiest columns so a fresh session isn't one giant pile.
        for _, addr in ipairs(fresh) do
            local c = least_col()
            s.assign[addr] = c; counts[c] = counts[c] + 1
            s.seq[addr] = s.nextseq; s.nextseq = s.nextseq + 1
        end
    end
    for addr, c in pairs(s.assign) do
        if c > s.ncols then s.assign[addr] = s.ncols elseif c < 1 then s.assign[addr] = 1 end
    end
    -- remember the focused window's column so the *next* new window stacks there.
    if foc and s.assign[foc] then s.lastcol = s.assign[foc] end
    local cols = {}
    for c = 1, s.ncols do cols[c] = {} end
    for addr in pairs(present) do table.insert(cols[s.assign[addr]], addr) end
    for c = 1, s.ncols do
        table.sort(cols[c], function(a, b) return (s.seq[a] or 0) < (s.seq[b] or 0) end)
    end
    return cols, tgt_of, foc
end

local function recalculate(ctx)
    local t = ctx.targets
    if type(t) ~= "table" or not t[1] then return end
    local A = ctx.area
    if type(A) ~= "table" then return end
    local s = ws_state(win_ws_id(t[1].window))
    local cols, tgt_of, foc = columnize(s, t)
    local horizontal = (s.orient ~= "v")
    local rows = math.max(1, s.rows)

    -- primary axis is split between columns; the secondary axis stacks each column's
    -- windows and pans (scrolls) when they overflow.
    local primLen = horizontal and A.w or A.h
    local primOrg = horizontal and A.x or A.y
    local secLen  = horizontal and A.h or A.w
    local secOrg  = horizontal and A.y or A.x

    local csum = 0
    for c = 1, s.ncols do csum = csum + (s.colw[c] or 1) end
    if csum <= 0 then csum = 1 end

    local cpos = primOrg
    for c = 1, s.ncols do
        local colLen = primLen * ((s.colw[c] or 1) / csum)
        local list = cols[c]
        local m = #list
        if m > 0 then
            -- group the column into rows: two mutually-paired, adjacent windows share one
            -- row side by side (a "parked" pair); every other window is its own row. Pairing
            -- removes a row, so the column closes up around a park — no gap.
            local rowlist = {}
            local i = 1
            while i <= m do
                local a, b = list[i], list[i + 1]
                if b and s.pair[a] == b and s.pair[b] == a then
                    rowlist[#rowlist + 1] = { a, b }; i = i + 2
                else
                    rowlist[#rowlist + 1] = { a }; i = i + 1
                end
            end

            -- base height of a weight-1 row is viewport / windows-per-view; a parked row
            -- takes the larger of its two weights so neither window is clipped.
            local unit = secLen / rows
            local function wof(addr) local w = s.winh[addr] or 1; return (w > 0) and w or 1 end
            local rh, total = {}, 0
            for r, row in ipairs(rowlist) do
                local w = (#row == 2) and math.max(wof(row[1]), wof(row[2])) or wof(row[1])
                rh[r] = w * unit; total = total + rh[r]
            end

            -- row holding the focused window (drives the pan)
            local frow
            for r, row in ipairs(rowlist) do
                for _, addr in ipairs(row) do if addr == foc then frow = r end end
            end

            local pan
            if total <= secLen then
                -- underfull: stretch rows to fill so there's no dead space.
                local scale = secLen / total
                for r = 1, #rowlist do rh[r] = rh[r] * scale end
                pan = 0
            else
                -- overfull: keep natural heights and PAN so the focused row stays in view
                -- (center_focus: centred in the viewport instead of minimal movement).
                -- Only the column holding the focused window (frow ~= nil) is touched here;
                -- every other column keeps whatever pan it already had.
                pan = s.pan[c] or 0
                if frow then
                    local top = 0
                    for r = 1, frow - 1 do top = top + rh[r] end
                    local bottom = top + rh[frow]
                    if center_focus then
                        pan = top + (rh[frow] - secLen) / 2
                    else
                        if top - pan < 0 then pan = top end
                        if bottom - pan > secLen then pan = bottom - secLen end
                    end
                end
                -- clamp to the column's actual scroll range either way — this is also what
                -- keeps "centred" from showing blank space past the first/last row.
                pan = math.max(0, math.min(pan, total - secLen))
            end
            s.pan[c] = pan

            -- place a box given position/length on the primary (column) axis and the
            -- secondary (scroll/stacking) axis, mapped to x/y per orientation.
            local function place_box(addr, ppos, plen, spos, slen)
                local box = horizontal
                    and { x = ppos, y = spos, w = plen, h = slen }
                    or  { x = spos, y = ppos, w = slen, h = plen }
                local tg = tgt_of[addr]
                if tg then pcall(function() tg:place(box) end) end
            end

            local spos = secOrg - pan
            for r, row in ipairs(rowlist) do
                if #row == 1 then
                    place_box(row[1], cpos, colLen, spos, rh[r])
                else
                    -- parked pair: side by side, split across the column, both keeping the
                    -- full row length along the scroll axis (a vertical divider horizontally).
                    local halfp = colLen / 2
                    place_box(row[1], cpos, halfp, spos, rh[r])
                    place_box(row[2], cpos + halfp, halfp, spos, rh[r])
                end
                spos = spos + rh[r]
            end
        end
        cpos = cpos + colLen
    end
end

local function layout_msg(ctx, cmd)
    local verb, arg = tostring(cmd or ""):match("^(%S+)%s*(%S*)$")
    if not verb then return end

    -- "center-focused" switch: global, doesn't touch any window, so handled before the
    -- empty-workspace bail-out below (toggling it from the menu shouldn't require a
    -- window to be focused/present).
    if verb == "center" then
        if arg == "on" then center_focus = true
        elseif arg == "off" then center_focus = false
        else center_focus = not center_focus end
        publish()
        return
    end

    local t = ctx.targets
    if type(t) ~= "table" or not t[1] then return end
    local s = ws_state(win_ws_id(t[1].window))
    local cols, _, foc = columnize(s, t)
    local horizontal = (s.orient ~= "v")

    local fc = (foc and s.assign[foc]) or 1
    local fi = 1
    if foc then for i, a in ipairs(cols[fc] or {}) do if a == foc then fi = i end end end

    -- l/r (horizontal) or u/d (vertical) run perpendicular to the column axis → these
    -- act "between columns"; the other pair acts within the focused column.
    local function between(a) return horizontal and (a == "l" or a == "r") or (not horizontal and (a == "u" or a == "d")) end
    local function decrease(a) return a == "l" or a == "u" end
    local function unpair(addr)
        local p = addr and s.pair[addr]
        if p then s.pair[p] = nil; s.pair[addr] = nil end
    end

    if verb == "focus" then
        if between(arg) then
            local nc = fc + (decrease(arg) and -1 or 1)
            if nc < 1 then
                hl.dispatch(hl.dsp.focus({ monitor = "-1" }))          -- off the left edge
            elseif nc > s.ncols then
                hl.dispatch(hl.dsp.focus({ monitor = "+1" }))          -- off the right edge
            elseif cols[nc] and #cols[nc] > 0 then
                local ni = math.min(fi, #cols[nc])
                hl.dispatch(hl.dsp.focus({ window = "address:" .. cols[nc][ni] }))
            end
        else
            local ni = fi + (decrease(arg) and -1 or 1)
            if cols[fc] and ni >= 1 and ni <= #cols[fc] then
                hl.dispatch(hl.dsp.focus({ window = "address:" .. cols[fc][ni] }))
            end
        end
    elseif verb == "move" then
        if not foc then return end
        if between(arg) then
            unpair(foc)                                                -- leaving the column un-parks it
            local nc = fc + (decrease(arg) and -1 or 1)
            if nc < 1 then
                hl.dispatch(hl.dsp.window.move({ monitor = "-1" }))    -- carry to prev monitor
            elseif nc > s.ncols then
                hl.dispatch(hl.dsp.window.move({ monitor = "+1" }))    -- carry to next monitor
            else
                s.assign[foc] = nc
            end
        else
            -- PARK / UNPARK within the column. Moving toward a single neighbour parks the
            -- two side by side in one slot — a persistent state, and the column closes up so
            -- there's no gap. Moving a parked window again pops it out and past its partner.
            local list = cols[fc]
            local step = decrease(arg) and -1 or 1
            local partner = s.pair[foc]
            if partner then
                -- parked → un-park and move the focused window past its partner.
                s.pair[foc] = nil; s.pair[partner] = nil
                local a, b = s.seq[foc] or 0, s.seq[partner] or 0
                if (step > 0 and a < b) or (step < 0 and a > b) then
                    s.seq[foc], s.seq[partner] = b, a                  -- put foc on the far side
                end
            elseif list then
                local nb = list[fi + step]
                if nb and not s.pair[nb] then
                    s.pair[foc] = nb; s.pair[nb] = foc                 -- park side by side
                elseif nb then
                    -- neighbour is already a parked pair → hop the focused window over both.
                    local nb2 = s.pair[nb]
                    local n1, n2 = s.seq[nb] or 0, s.seq[nb2] or 0
                    s.seq[foc] = (step > 0) and (math.max(n1, n2) + 0.5) or (math.min(n1, n2) - 0.5)
                end
            end
        end
    elseif verb == "resize" then
        if between(arg) then
            -- grow/shrink the focused column's width weight
            s.colw[fc] = clampw((s.colw[fc] or 1) + (decrease(arg) and -RSTEP or RSTEP))
        elseif foc then
            -- grow/shrink the focused window's height weight within its column
            s.winh[foc] = clampw((s.winh[foc] or 1) + (decrease(arg) and -RSTEP or RSTEP))
        end
    elseif verb == "count" then
        -- horizontal ws: h/l change column count, k/j change windows-per-view.
        -- vertical ws:   k/j change column count, h/l change windows-per-view.
        local more_cols = horizontal and "l" or "j"
        local less_cols = horizontal and "h" or "k"
        local more_rows = horizontal and "j" or "l"
        local less_rows = horizontal and "k" or "h"
        local changed_cols = false
        if arg == more_cols then s.ncols = s.ncols + 1; changed_cols = true
        elseif arg == less_cols then s.ncols = math.max(1, s.ncols - 1); changed_cols = true
        elseif arg == more_rows then s.rows = s.rows + 1
        elseif arg == less_rows then s.rows = math.max(1, s.rows - 1) end
        if changed_cols then reflow(s, t) end
        publish()
    elseif verb == "cols" then
        -- direct column-count change (the menu's (-)[N](+) stepper).
        if arg == "+" or arg == "+1" then s.ncols = s.ncols + 1
        elseif arg == "-" or arg == "-1" then s.ncols = math.max(1, s.ncols - 1)
        else local n = tonumber(arg); if n then s.ncols = math.max(1, math.floor(n)) end end
        reflow(s, t)
        publish()
    elseif verb == "orient" then
        s.orient = (arg == "v") and "v" or "h"
        publish()
    end
end

-- Register the layout (guard against the double-register that a live reload can cause).
pcall(function()
    hl.layout.register("columns", { recalculate = recalculate, layout_msg = layout_msg })
end)

-- Spec consumed by the layout registry (init.lua) + the astal-menu dropdown.
return {
    name        = "columns",
    label       = "Columns",
    backend     = "lua:columns",   -- custom Lua layout, addressed with the lua: prefix
    lua         = true,            -- init.lua sends orientation via layout_msg, not scrolling:direction
    directional = true,           -- reuse the menu's direction dropdown for orientation
    dirs        = { "h", "v" },
    dir_labels  = { "Horizontal", "Vertical" },
    default_dir = "h",
    fit_method  = true,    -- menu shows the "center-focused" switch (routed to "center on/off")
    stepper     = true,   -- menu shows a "Columns: (-)[N](+)" count stepper
}
