--!name Klondike Solitaire
--!icon cards
--!category Games
-- Klondike patience, draw one or draw three. Cards are snail.board art tiles
-- from apps/solitaire.art (tools/make_cards_art.py): a card is two cells
-- wide and a fanned column shows the top strip of each card.

local RANK = "A23456789TJQK"
local SUIT = "shdc"
local RN = { "A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K" }
local SN = { "spades", "hearts", "diamonds", "clubs" }
local MAXROWS = 23
local MAXUNDO = 60

local function rank(c) return c % 13 end
local function suit(c) return c // 13 end
local function red(c) local s = c // 13 return s == 1 or s == 2 end
local function cname(c) return RN[c % 13 + 1] .. " of " .. SN[c // 13 + 1] end

-- Positions: 1 waste, 2..5 home piles (spades, hearts, diamonds, clubs),
-- 6..12 the seven columns.
local stock, waste, found, tab, down
local moves, drawN, counted = 0, 1, false
local cur, hold = 6, nil
local undo = {}
local msg, holdMsg
local screen, sel = "game", 1
local st = { p1 = 0, w1 = 0, p3 = 0, w3 = 0, streak = 0, best = 0, fewest = 0 }
local pref = { draw = 1, auto = true }
local seedN = 0

-- State <-> string -----------------------------------------------------------

local function cstr(t, a)
  local o = {}
  for i = a or 1, #t do o[#o + 1] = string.char(65 + t[i]) end
  return table.concat(o)
end

local function cards(s, a)
  local t = {}
  for i = a or 1, #s do t[#t + 1] = s:byte(i) - 65 end
  return t
end

local function enc()
  local p = { drawN, moves, cstr(stock), cstr(waste),
    string.char(65 + found[1], 65 + found[2], 65 + found[3], 65 + found[4]) }
  for i = 1, 7 do p[#p + 1] = down[i] .. cstr(tab[i]) end
  return table.concat(p, ",")
end

local function dec(s)
  local f = {}
  for v in (s .. ","):gmatch("([^,]*),") do f[#f + 1] = v end
  if #f < 12 or #f[5] ~= 4 then return false end
  local n = 0
  local d, m = tonumber(f[1]), tonumber(f[2])
  local sk, ws, fo, tb, dn = cards(f[3]), cards(f[4]), {}, {}, {}
  for i = 1, 4 do fo[i] = f[5]:byte(i) - 65; n = n + fo[i] end
  for i = 1, 7 do
    dn[i] = tonumber(f[5 + i]:sub(1, 1)) or 0
    tb[i] = cards(f[5 + i], 2)
    n = n + #tb[i]
  end
  if n + #sk + #ws ~= 52 or not d or not m then return false end
  drawN, moves, stock, waste, found, tab, down = d, m, sk, ws, fo, tb, dn
  return true
end

local function won()
  return found[1] + found[2] + found[3] + found[4] == 52
end

local function save()
  local head = table.concat({ "K1", st.p1, st.w1, st.p3, st.w3, st.streak, st.best, st.fewest,
    pref.draw, pref.auto and 1 or 0, counted and 1 or 0, seedN, enc() }, ";")
  local parts, len = {}, #head + 1
  for i = #undo, 1, -1 do
    local s = undo[i]
    if len + #s + 1 > 1023 then break end
    table.insert(parts, 1, s)
    len = len + #s + 1
  end
  snail.save(head .. ";" .. table.concat(parts, "|"))
end

local function load()
  local s = snail.load()
  if type(s) ~= "string" or s:sub(1, 3) ~= "K1;" then return end
  local f = {}
  for v in (s .. ";"):gmatch("([^;]*);") do f[#f + 1] = v end
  local function n(i) return tonumber(f[i]) or 0 end
  st.p1, st.w1, st.p3, st.w3 = n(2), n(3), n(4), n(5)
  st.streak, st.best, st.fewest = n(6), n(7), n(8)
  pref.draw = n(9) == 3 and 3 or 1
  pref.auto = n(10) == 1
  counted = n(11) == 1
  seedN = n(12)
  if f[13] and dec(f[13]) then
    for u in (f[14] or ""):gmatch("[^|]+") do undo[#undo + 1] = u end
  else
    stock = nil
  end
end

-- Dealing ----------------------------------------------------------------------

local function deal()
  local d = {}
  for i = 0, 51 do d[i + 1] = i end
  for i = 52, 2, -1 do
    local j = math.random(i)
    d[i], d[j] = d[j], d[i]
  end
  tab, down = {}, {}
  local k = 1
  for i = 1, 7 do
    tab[i] = {}
    for j = 1, i do tab[i][j] = d[k]; k = k + 1 end
    down[i] = i - 1
  end
  stock = {}
  for i = k, 52 do stock[#stock + 1] = d[i] end
  waste, found = {}, { 0, 0, 0, 0 }
  moves, drawN, counted = 0, pref.draw, false
  hold, cur, undo = nil, 6, {}
  msg = "New deal, draw " .. drawN .. ". Good luck!"
end

local autoplay

local function newGame()
  if counted and not won() then st.streak = 0 end
  deal()
  if pref.auto then autoplay() end
  save()
end

-- Rules ------------------------------------------------------------------------

local function flip(i)
  if down[i] >= #tab[i] and down[i] > 0 then down[i] = #tab[i] - 1 end
end

local function canFound(c) return found[suit(c) + 1] == rank(c) end

local function canTab(c, i)
  local t = tab[i]
  if #t == 0 then return rank(c) == 12 end
  local top = t[#t]
  return red(top) ~= red(c) and rank(top) == rank(c) + 1
end

-- The cards that can be picked up at a position, deepest first.
local function src(p)
  if p == 1 then return { waste[#waste] } end
  if p <= 5 then
    local n = found[p - 1]
    if n == 0 then return {} end
    return { (p - 2) * 13 + n - 1 }
  end
  local i = p - 5
  local o = {}
  for j = down[i] + 1, #tab[i] do o[#o + 1] = tab[i][j] end
  return o
end

-- How many cards from p go onto q, or nil.
local function fit(p, q)
  if q == p or q == 1 then return nil end
  local l = src(p)
  if #l == 0 then return nil end
  if q <= 5 then
    if p <= 5 then return nil end
    local c = l[#l]
    if canFound(c) and suit(c) + 2 == q then return 1 end
    return nil
  end
  for i = 1, #l do
    if canTab(l[i], q - 5) then return #l - i + 1 end
  end
end

-- The best place for the cards at p, with a score (nil if none).
local function best(p)
  local l = src(p)
  if #l == 0 then return nil end
  if p == 1 or p > 5 then
    local c = l[#l]
    if canFound(c) then return suit(c) + 2, 1 end
  end
  local bq, bn, bs
  for k = 1, 7 do
    local col = p > 5 and (p - 6 + k) % 7 + 1 or k
    local q = col + 5
    local n = fit(p, q)
    if n then
      local s = #tab[col] > 0 and 2 or 0
      if p > 5 then
        local i = p - 5
        if n == #tab[i] - down[i] then
          s = s + 4
          if down[i] == 0 and #tab[col] == 0 then s = -1 end
        end
      end
      if s >= 0 and (not bs or s > bs) then bq, bn, bs = q, n, s end
    end
  end
  return bq, bn
end

local function targets(p)
  local t = {}
  for q = 2, 12 do
    if fit(p, q) then t[#t + 1] = q end
  end
  return t
end

local function pushUndo()
  undo[#undo + 1] = enc()
  if #undo > MAXUNDO then table.remove(undo, 1) end
end

local function countMove()
  moves = moves + 1
  if not counted then
    counted = true
    if drawN == 3 then st.p3 = st.p3 + 1 else st.p1 = st.p1 + 1 end
  end
end

local function safe(c)
  local r = rank(c)
  if r <= 1 then return true end
  if red(c) then return found[1] >= r and found[4] >= r end
  return found[2] >= r and found[3] >= r
end

function autoplay()
  local n = 0
  repeat
    local any = false
    if #waste > 0 then
      local c = waste[#waste]
      if canFound(c) and safe(c) then
        waste[#waste] = nil
        found[suit(c) + 1] = found[suit(c) + 1] + 1
        any = true
      end
    end
    for i = 1, 7 do
      local t = tab[i]
      local c = t[#t]
      if c and canFound(c) and safe(c) then
        t[#t] = nil
        found[suit(c) + 1] = found[suit(c) + 1] + 1
        flip(i)
        any = true
      end
    end
    if any then n = n + 1 end
  until not any
  return n
end

-- Every column face up (and, drawing three, nothing left to draw): the rest
-- is a formality, so it plays itself home.
local function finishable()
  for i = 1, 7 do if down[i] > 0 then return false end end
  return drawN == 1 or #stock + #waste == 0
end

local function afterMove()
  local extra = pref.auto and autoplay() or 0
  if not won() and finishable() then
    found = { 13, 13, 13, 13 }
    stock, waste = {}, {}
    for i = 1, 7 do tab[i] = {}; down[i] = 0 end
    msg = "All cards face up: finished for you"
  elseif extra > 0 then
    msg = msg .. ", +" .. extra .. " home"
  end
  if won() then
    if drawN == 3 then st.w3 = st.w3 + 1 else st.w1 = st.w1 + 1 end
    st.streak = st.streak + 1
    if st.streak > st.best then st.best = st.streak end
    if st.fewest == 0 or moves < st.fewest then st.fewest = moves end
    hold = nil
  end
  save()
end

local function where(q)
  if q <= 5 then return "home" end
  return "column " .. (q - 5)
end

local function doMove(p, q, n)
  pushUndo()
  local moved
  if p == 1 then
    moved = { table.remove(waste) }
  elseif p <= 5 then
    moved = { (p - 2) * 13 + found[p - 1] - 1 }
    found[p - 1] = found[p - 1] - 1
  else
    local t = tab[p - 5]
    moved = {}
    for j = #t - n + 1, #t do moved[#moved + 1] = t[j] end
    for _ = 1, n do t[#t] = nil end
    flip(p - 5)
  end
  if q <= 5 then
    found[q - 1] = found[q - 1] + 1
  else
    local t = tab[q - 5]
    for j = 1, #moved do t[#t + 1] = moved[j] end
  end
  countMove()
  if #moved == 1 then msg = cname(moved[1]) .. " to " .. where(q)
  else msg = #moved .. " cards to " .. where(q) end
  hold = nil
  cur = #src(p) > 0 and p or q
  afterMove()
end

local function drawStock()
  hold = nil
  if #stock == 0 then
    if #waste == 0 then msg = "The stock is empty"; return end
    pushUndo()
    stock, waste = waste, stock
    countMove()
    msg = "Turned the waste over"
    save()
    return
  end
  pushUndo()
  local n = math.min(drawN, #stock)
  for _ = 1, n do waste[#waste + 1] = table.remove(stock, 1) end
  countMove()
  cur = 1
  msg = (n == 1 and "Drew " .. cname(waste[#waste]) or "Drew " .. n .. " cards") .. ", " .. #stock .. " left"
  afterMove()
end

local function doUndo()
  hold = nil
  if #undo == 0 then msg = "Nothing to undo"; return end
  dec(table.remove(undo))
  msg = "Move undone"
  if #src(cur) == 0 then cur = 6 end
  save()
end

-- Cursor -----------------------------------------------------------------------

local function step(d)
  if hold then
    local t = targets(hold)
    t[#t + 1] = hold
    table.sort(t)
    local i = 1
    for j, q in ipairs(t) do if q == cur then i = j end end
    cur = t[(i - 1 + d) % #t + 1]
    return
  end
  for _ = 1, 12 do
    cur = (cur - 1 + d) % 12 + 1
    if cur == 1 and #waste > 0 or cur > 1 and cur <= 5 and found[cur - 1] > 0 or cur > 5 then return end
  end
end

local function pick()
  local l = src(cur)
  if #l == 0 then
    msg = cur == 1 and "No card here: DN draws one" or "Nothing to pick up here"
    return
  end
  local q = best(cur)
  if not q then
    local t = targets(cur)
    q = t[1]
  end
  local what = #l == 1 and cname(l[1]) or (cur <= 5 and cname(l[1]) or #l .. " cards")
  if cur > 5 and #l > 1 then
    local n = q and fit(cur, q)
    if n == 1 then what = cname(l[#l]) elseif n then what = n .. " cards" end
  end
  if not q then msg = what .. ": no move"; return end
  hold = cur
  cur = q
  holdMsg = "Moving " .. what .. ": OK places"
end

-- Menu -------------------------------------------------------------------------

local function menuItems()
  local t = { { "Back to the game", "resume" } }
  if not won() then
    t[#t + 1] = { #undo > 0 and "Undo last move" or "Undo (nothing to undo)", "undo" }
  end
  t[#t + 1] = { "New game", "new" }
  local d = "Draw " .. (pref.draw == 1 and "one card" or "three cards")
  if pref.draw ~= drawN and moves > 0 then d = d .. " (next game)" end
  t[#t + 1] = { d, "draw" }
  t[#t + 1] = { "Auto-play to home: " .. (pref.auto and "on" or "off"), "auto" }
  t[#t + 1] = { "Statistics", "stats" }
  t[#t + 1] = { "How to play", "help" }
  return t
end

function key(k)
  if screen == "menu" then
    local items = menuItems()
    if k == "up" then sel = (sel - 2) % #items + 1
    elseif k == "down" then sel = sel % #items + 1
    elseif k == "ok" then
      local a = items[sel][2]
      if a == "resume" then screen = "game"
      elseif a == "undo" then doUndo(); screen = "game"
      elseif a == "new" then newGame(); screen = "game"
      elseif a == "draw" then
        pref.draw = 4 - pref.draw
        if moves == 0 and not won() then drawN = pref.draw end
        save()
      elseif a == "auto" then pref.auto = not pref.auto; save()
      else screen = a end
    end
    return
  end
  if screen ~= "game" then
    if k == "top" or k == "ok" then screen = "menu" end
    return
  end
  msg = nil
  if won() then
    if k == "ok" then newGame()
    elseif k == "top" then screen, sel = "menu", 1 end
    return
  end
  if k == "top" then
    if hold then cur, hold = hold, nil; msg = "Put back"
    else screen, sel = "menu", 1 end
  elseif k == "left" then step(-1)
  elseif k == "right" then step(1)
  elseif k == "up" then doUndo()
  elseif k == "down" then drawStock()
  elseif k == "ok" then
    if not hold then pick()
    elseif cur == hold then hold = nil; msg = "Put back"
    else doMove(hold, cur, fit(hold, cur)) end
  end
end

-- Drawing ----------------------------------------------------------------------

local G, NR

local function put(r, c, ch)
  local row = G[r]
  if not row then row = {}; G[r] = row end
  row[c] = ch
  if r > NR then NR = r end
end

local function strip(r, c, card)
  local a, s = rank(card) + 1, suit(card) + 1
  put(r, c, RANK:sub(a, a))
  put(r, c + 1, SUIT:sub(s, s))
end

local function full(r, c, card)
  strip(r, c, card)
  put(r + 1, c, "m"); put(r + 1, c + 1, "n")
  put(r + 2, c, "u"); put(r + 2, c + 1, "v")
end

local function empty(r, c)
  put(r, c, "x"); put(r, c + 1, "y")
  put(r + 1, c, "w"); put(r + 1, c + 1, "z")
end

local function mark(r, c, p)
  local m = (p == cur and "^") or (p == hold and "!")
  if m then put(r, c, m); put(r, c + 1, m) end
end

local function spec()
  local out = {}
  for r = 1, NR do
    local row, o = G[r] or {}, {}
    local last = 0
    for c = 1, 20 do if row[c] then last = c end end
    for c = 1, last do o[c] = row[c] or " " end
    out[r] = table.concat(o)
  end
  return table.concat(out, "/")
end

local function topRow(showCursor)
  if #stock > 0 then
    put(1, 1, "b"); put(1, 2, "e"); put(2, 1, "b"); put(2, 2, "e"); put(3, 1, "f"); put(3, 2, "g")
  else
    empty(1, 1)
  end
  local wc = 4
  if #waste == 0 then
    empty(1, 4)
  else
    local k = drawN == 3 and math.min(3, #waste) or 1
    for j = 1, k - 1 do
      local c = waste[#waste - k + j]
      put(1, 3 + j, RANK:sub(rank(c) + 1, rank(c) + 1)); put(2, 3 + j, "m"); put(3, 3 + j, "u")
    end
    wc = 3 + k
    full(1, wc, waste[#waste])
  end
  if showCursor then mark(4, wc, 1) end
  for i = 1, 4 do
    local c = 10 + 3 * (i - 1)
    if found[i] > 0 then full(1, c, (i - 1) * 13 + found[i] - 1) else empty(1, c) end
    if showCursor then mark(4, c, i + 1) end
  end
end

local function board()
  G, NR = {}, 0
  topRow(true)
  for i = 1, 7 do
    local x, t, d = 3 * i - 2, tab[i], down[i]
    local n = #t
    if n == 0 then
      empty(5, x)
      mark(7, x, i + 5)
    else
      local up = n - d
      local ds = d
      if d + up > MAXROWS - 7 then ds = math.max(1, MAXROWS - 7 - up) end
      local r = 5
      for _ = 1, ds do put(r, x, "b"); put(r, x + 1, "e"); r = r + 1 end
      for j = d + 1, n - 1 do strip(r, x, t[j]); r = r + 1 end
      full(r, x, t[n])
      mark(r + 3, x, i + 5)
    end
  end
  return spec()
end

local function winBoard()
  G, NR = {}, 0
  topRow(false)
  for i, x in ipairs({ 4, 7, 13, 16 }) do
    local s = i - 1
    for r = 12, 1, -1 do strip(5 + 12 - r, x, s * 13 + r) end
    full(17, x, s * 13)
  end
  return spec()
end

local function pct(w, p) return p > 0 and (w * 100 // p) .. "%" or "-" end

function draw()
  if screen == "menu" then
    snail.title("Klondike Solitaire")
    local items = menuItems()
    if sel > #items then sel = #items end
    for i, it in ipairs(items) do snail.row(it[1], i == sel) end
    snail.gap()
    local p, w = st.p1 + st.p3, st.w1 + st.w3
    snail.small("Won " .. w .. " of " .. p .. " games. Streak " .. st.streak .. ".")
    snail.hint("BACK leave OK choose UP/DN move")
    return
  end
  if screen == "stats" then
    snail.title("Statistics")
    snail.text("Draw one: played " .. st.p1 .. ", won " .. st.w1 .. " (" .. pct(st.w1, st.p1) .. ")")
    snail.text("Draw three: played " .. st.p3 .. ", won " .. st.w3 .. " (" .. pct(st.w3, st.p3) .. ")")
    snail.gap()
    snail.text("Winning streak " .. st.streak .. ", best " .. st.best)
    snail.text("Fewest moves to win: " .. (st.fewest > 0 and st.fewest or "-"))
    snail.hint("BACK back OK back")
    return
  end
  if screen == "help" then
    snail.title("How to play")
    snail.text("Build four home piles up from Ace to King, one for each suit.")
    snail.text("On the columns, build down in alternating colours. Only a King fills an empty column.")
    snail.text("L/R moves the bar. OK picks up a card and the cards on top of it.")
    snail.text("The bar jumps to the best place: OK puts the cards there, L/R picks another.")
    snail.text("DN draws from the stock (or turns the waste over). UP undoes a move.")
    snail.text("BACK puts cards back, or opens the options.")
    snail.hint("BACK back OK back")
    return
  end
  snail.title("Klondike - draw " .. drawN .. " - moves " .. moves)
  if won() then
    snail.status("You win! Solved in " .. moves .. " moves")
    snail.board(winBoard(), nil, "Solved!")
    local p, w = st.p1 + st.p3, st.w1 + st.w3
    snail.text("Won " .. w .. " of " .. p .. " (" .. pct(w, p) .. "). Streak " .. st.streak .. ", best " .. st.best .. ".")
    snail.hint("BACK options OK new game")
    return
  end
  local home = found[1] + found[2] + found[3] + found[4]
  snail.status(msg or hold and holdMsg or ("Stock " .. #stock .. ", waste " .. #waste .. ", home " .. home .. " of 52"))
  snail.board(board())
  if hold then snail.hint("BACK cancel OK place L/R move UP undo DN draw")
  else snail.hint("BACK options OK select L/R move UP undo DN draw") end
end

function start()
  snail.ink("fast")
  load()
  math.randomseed(os.time() + seedN * 7919)
  seedN = seedN + 1
  if not stock then
    deal()
    if pref.auto then autoplay() end
  end
  if #src(cur) == 0 then cur = 6 end
  save()
end
