-- sellkeys Auth – Lua SDK (Lua 5.1–5.4 and LuaJIT)
--
--   luarocks install luasocket && luarocks install luasec && luarocks install lua-cjson && luarocks install luasodium
--   (luasodium needs libsodium: apt install libsodium-dev / brew install libsodium; luasec is only needed for https)
--
--   local SellkeysAuth = require("sellkeys_auth")
--   local auth = SellkeysAuth.new("APP_ID", "PUBLIC_KEY", "1.0")
--   local ok, err = pcall(function()
--     auth:init()
--     local lic = auth:license("XXXXX-XXXXX-XXXXX-XXXXX")
--     print("Welcome! Level " .. lic.level)
--   end)
--   if not ok then print("Login failed: " .. tostring(err.code or err) .. " " .. tostring(err.message or "")) end
--
-- Failed calls raise an error table { code = "...", message = "...", data = <full server answer> } (use pcall).
-- JSON null comes back as cjson.null. Every answer is signed with your app's Ed25519 key; the SDK rejects wrong
-- signatures and replayed nonces, so a fake server cannot unlock your program. (It does not protect against patching
-- your program.)
local http = require("socket.http")
local ltn12 = require("ltn12")
local cjson = require("cjson")
local sodium = require("luasodium")

local M = { API_URL = "https://auth.sellkeys.cc/v1" }
M.__index = M

local Error = { __tostring = function(e) return e.code .. ": " .. e.message end }
M.Error = Error

local function fail(code, message, data)
  error(setmetatable({ code = code, message = message, data = data or {} }, Error), 0)
end

local function sha256hex(s) return sodium.sodium_bin2hex(sodium.crypto_hash_sha256(s)) end

local function b64decode(s)
  local ok, bin = pcall(sodium.sodium_base642bin, s or "", sodium.sodium_base64_VARIANT_ORIGINAL)
  return ok and bin or ""
end

local function trim(s) return (s:gsub("^%s+", ""):gsub("%s+$", "")) end

local function sh(cmd)
  local f = io.popen(cmd)
  if not f then return "" end
  local out = f:read("*a") or ""
  f:close()
  return out
end

--- Stable per-machine id (hashed, the raw value never leaves the machine).
function M.default_hwid()
  local raw = ""
  if package.config:sub(1, 1) == "\\" then
    raw = sh('reg query "HKLM\\SOFTWARE\\Microsoft\\Cryptography" /v MachineGuid /reg:64 2>nul'):match("MachineGuid%s+REG_SZ%s+(%S+)") or ""
  elseif sh("uname 2>/dev/null"):match("Darwin") then
    raw = sh("ioreg -rd1 -c IOPlatformExpertDevice 2>/dev/null"):match('"IOPlatformUUID" = "([^"]+)"') or ""
  else
    for _, p in ipairs({ "/etc/machine-id", "/var/lib/dbus/machine-id" }) do
      local f = io.open(p, "r")
      if f then
        raw = trim(f:read("*a") or "")
        f:close()
        if raw ~= "" then break end
      end
    end
  end
  if raw == "" then raw = trim(sh("hostname")) end
  return sha256hex(raw)
end

--- SHA-256 of your program file (for the app's hash check).
function M.file_hash(path)
  local f = assert(io.open(path, "rb"))
  local data = f:read("*a")
  f:close()
  return sha256hex(data)
end

--- opts: api_url, hwid, timeout (seconds)
function M.new(app_id, public_key, version, opts)
  opts = opts or {}
  local pub = b64decode(public_key)
  if #pub ~= sodium.crypto_sign_PUBLICKEYBYTES then fail("bad_public_key", "The public key must be 32 bytes, base64.") end
  return setmetatable({
    app_id = app_id, version = version, api_url = opts.api_url or M.API_URL,
    hwid = opts.hwid or M.default_hwid(), timeout = opts.timeout or 15, _pub = pub,
  }, M)
end

local function urlencode(s)
  return (tostring(s):gsub("[^%w%-_%.~]", function(c) return string.format("%%%02X", string.byte(c)) end))
end

local function request(url, body, timeout)
  local out = {}
  local req = http
  if url:sub(1, 8) == "https://" then req = require("ssl.https") end
  req.TIMEOUT = timeout
  local args = { url = url, sink = ltn12.sink.table(out), redirect = body == nil }
  if body then
    args.method = "POST"
    args.source = ltn12.source.string(body)
    args.headers = { ["content-type"] = "application/x-www-form-urlencoded", ["content-length"] = tostring(#body) }
  end
  local ok, status, headers = req.request(args)
  if not ok then return nil, status end
  return table.concat(out), status, headers or {}
end

function M:_call(type_, fields)
  local nonce = sodium.sodium_bin2hex(sodium.randombytes_buf(16))
  local parts = { "type=" .. urlencode(type_), "app=" .. urlencode(self.app_id), "nonce=" .. nonce }
  for k, v in pairs(fields or {}) do parts[#parts + 1] = urlencode(k) .. "=" .. urlencode(v) end
  local raw, status, headers = request(self.api_url, table.concat(parts, "&"), self.timeout)
  if not raw then fail("network_error", "Could not reach the license server: " .. tostring(status)) end
  local sig = headers["x-sellkeys-signature"]
  if not sig then -- only errors before the app is known (rate limit, unknown app) are unsigned
    local ok, b = pcall(cjson.decode, raw)
    if not ok or type(b) ~= "table" then b = {} end
    fail(type(b.code) == "string" and b.code or ("http_" .. tostring(status)), type(b.message) == "string" and b.message or "Unexpected server response.")
  end
  local sigbin = b64decode(sig)
  local ok, verified = false, false
  if #sigbin == sodium.crypto_sign_BYTES then ok, verified = pcall(sodium.crypto_sign_verify_detached, sigbin, raw, self._pub) end
  if not (ok and verified) then fail("bad_signature", "The server response is not signed by sellkeys.") end
  local body = cjson.decode(raw)
  if body.nonce ~= nonce then fail("bad_nonce", "The server response does not belong to this request.") end
  if body.success ~= true then
    fail(type(body.code) == "string" and body.code or "error", type(body.message) == "string" and body.message or "Request failed.", body)
  end
  return body
end

function M:_ensure_session() if not self.session then self:init() end end

--- Starts a session. Raises code "update_required" (see err.data.update_url) if your version is too old.
function M:init(hash)
  local b = self:_call("init", { version = self.version, hash = hash })
  self.session = b.session
  self.app = b.app
  return self.app
end

function M:license(key)
  self:_ensure_session()
  self.license_info = self:_call("license", { session = self.session, key = trim(key), hwid = self.hwid }).license
  return self.license_info
end

--- Call periodically: raises if the license/account was banned, expired or the session ended.
function M:check()
  local b = self:_call("check", { session = self.session })
  if b.user then self.user = b.user return b.user end
  self.license_info = b.license
  return b.license
end

-- ---- accounts (username + password; the key is redeemed to the account) ----
function M:register(username, password, key)
  self:_ensure_session()
  self.user = self:_call("register", { session = self.session, username = username, password = password, key = trim(key), hwid = self.hwid }).user
  return self.user
end

function M:login(username, password)
  self:_ensure_session()
  self.user = self:_call("login", { session = self.session, username = username, password = password, hwid = self.hwid }).user
  return self.user
end

--- Adds another license (renewal, higher level) to an existing account.
function M:upgrade(username, key)
  self:_ensure_session()
  return self:_call("upgrade", { session = self.session, username = username, key = trim(key) }).user
end

-- ---- content ----
--- Global app variable (set in the dashboard).
function M:var(name) return self:_call("var", { session = self.session, name = name }).value end
function M:get_user_var(name) return self:_call("getvar", { session = self.session, name = name }).value end
function M:set_user_var(name, value) self:_call("setvar", { session = self.session, name = name, value = tostring(value) }) end

--- Downloads an app file and checks it against the signed SHA-256. Returns the bytes as a string.
function M:download(file_id)
  local info = self:_call("file", { session = self.session, file = file_id })
  local data, status = request(info.url, nil, math.max(self.timeout, 120))
  if not data or status ~= 200 then fail("network_error", "Download failed: " .. tostring(status)) end
  if sha256hex(data) ~= info.sha256 then fail("bad_file_hash", "The downloaded file does not match its signed checksum.") end
  return data
end

--- Calls a webhook set in the dashboard through sellkeys (its URL and keys stay on the server). Returns status, response.
function M:webhook(name, params, body, content_type)
  if params == "" then params = nil end
  local b = self:_call("webhook", { session = self.session, webid = name, params = params, body = body, content_type = content_type })
  return b.status, b.response
end

--- Writes a line to the app's logs in the sellkeys dashboard.
function M:log(message) self:_call("log", { session = self.session, message = tostring(message):sub(1, 500) }) end

function M:logout()
  if not self.session then return end
  self:_call("logout", { session = self.session })
  self.session = nil
end

return M
