-- Spatial9 Gravity Feed for REAPER + IEM StereoEncoder
-- Status: PROTOTYPE, NOT YET TESTED IN REAPER. Save your project before running it.
-- Version 0.1.0 | Docs: https://spatial9.ai/docs/gravity-feed-reaper
--
-- What it does:
--   1. Reads the time selection (or 32 beats from the edit cursor), tempo markers, regions
--      and the selected tracks, plus any IEM StereoEncoder on those tracks.
--   2. Sends that metadata (never audio) to Gravity Feed with curl.
--   3. Lets you pick restrained, expressive or cinematic.
--   4. Writes azimuth and elevation automation for StereoEncoder, inside one undo step,
--      only if the project has not changed since the request and the plan has not expired.
--
-- Needs: REAPER 6 or later, curl 7.55+ on the PATH (included with Windows 10+ and macOS),
--        a Gravity Feed key from https://spatial9.ai/gravity-feed/console

local API = "https://spatial9.ai/api/gravity-feed/v1/plans"
local EXT = "Spatial9GravityFeed"
local MAX_TRACKS, MAX_BEATS = 32, 128

-- IEM StereoEncoder parameter ranges as documented by IEM. Verify with the
-- front / left / up fixture test in the docs before trusting the result.
local IEM_RANGES = { azimuth = { -180, 180 }, elevation = { -180, 180 } }
local IEM_PARAM_NAMES = { azimuth = "azimuth", elevation = "elevation" }

local function log(s) reaper.ShowConsoleMsg(tostring(s) .. "\n") end

---------------------------------------------------------------- JSON
local function encode(v)
  local t = type(v)
  if t == "nil" then return "null"
  elseif t == "boolean" then return v and "true" or "false"
  elseif t == "number" then
    if v ~= v or v == math.huge or v == -math.huge then return "0" end
    if math.floor(v) == v and math.abs(v) < 1e15 then return string.format("%d", v) end
    return string.format("%.6f", v)
  elseif t == "string" then
    return '"' .. v:gsub('[%c"\\]', function(c)
      local map = { ['"'] = '\\"', ['\\'] = '\\\\', ['\n'] = '\\n', ['\r'] = '\\r', ['\t'] = '\\t' }
      return map[c] or string.format("\\u%04x", c:byte())
    end) .. '"'
  elseif t == "table" then
    if v.__array or #v > 0 then
      local out = {}
      for i = 1, #v do out[i] = encode(v[i]) end
      return "[" .. table.concat(out, ",") .. "]"
    end
    local out = {}
    for k, val in pairs(v) do
      if k ~= "__array" then out[#out + 1] = encode(tostring(k)) .. ":" .. encode(val) end
    end
    return "{" .. table.concat(out, ",") .. "}"
  end
  return "null"
end

local function decode(s)
  local i = 1
  local function ws() i = s:find("[^ \t\r\n]", i) or #s + 1 end
  local value
  local function str()
    i = i + 1
    local buf = {}
    while true do
      local c = s:sub(i, i)
      if c == "" then error("unterminated string") end
      if c == '"' then i = i + 1; break end
      if c == "\\" then
        local n = s:sub(i + 1, i + 1)
        local map = { b = "\b", f = "\f", n = "\n", r = "\r", t = "\t" }
        if n == "u" then
          local code = tonumber(s:sub(i + 2, i + 5), 16) or 63
          buf[#buf + 1] = code < 128 and string.char(code) or "?"
          i = i + 6
        else
          buf[#buf + 1] = map[n] or n
          i = i + 2
        end
      else
        buf[#buf + 1] = c
        i = i + 1
      end
    end
    return table.concat(buf)
  end
  value = function()
    ws()
    local c = s:sub(i, i)
    if c == "{" then
      local obj = {}
      i = i + 1; ws()
      if s:sub(i, i) == "}" then i = i + 1; return obj end
      while true do
        ws(); local k = str(); ws()
        i = i + 1 -- colon
        obj[k] = value(); ws()
        local d = s:sub(i, i); i = i + 1
        if d == "}" then return obj end
      end
    elseif c == "[" then
      local arr = {}
      i = i + 1; ws()
      if s:sub(i, i) == "]" then i = i + 1; return arr end
      while true do
        arr[#arr + 1] = value(); ws()
        local d = s:sub(i, i); i = i + 1
        if d == "]" then return arr end
      end
    elseif c == '"' then return str()
    elseif s:sub(i, i + 3) == "true" then i = i + 4; return true
    elseif s:sub(i, i + 4) == "false" then i = i + 5; return false
    elseif s:sub(i, i + 3) == "null" then i = i + 4; return nil
    else
      local num = s:match("^-?%d+%.?%d*[eE]?[-+]?%d*", i)
      if not num then error("unexpected character at " .. i) end
      i = i + #num
      return tonumber(num)
    end
  end
  return value()
end

---------------------------------------------------------------- Helpers
local function getKey()
  local key = reaper.GetExtState(EXT, "key")
  if key:match("^s9gf_[%w]+$") then return key end
  local ok, input = reaper.GetUserInputs("Gravity Feed key", 1, "Key (s9gf_...),extrawidth=260", "")
  if not ok or not input:match("^s9gf_[%w]+$") then return nil end
  reaper.SetExtState(EXT, "key", input, true)
  return input
end

local function cleanId(s) return (s:gsub("[^%w_.:-]", ""):sub(1, 64)) end

local function round(v, d) local m = 10 ^ (d or 3); return math.floor(v * m + 0.5) / m end

local function findEncoder(track)
  for fx = 0, reaper.TrackFX_GetCount(track) - 1 do
    local _, name = reaper.TrackFX_GetFXName(track, fx, "")
    if name:lower():find("stereoencoder", 1, true) then
      local params = {}
      for p = 0, reaper.TrackFX_GetNumParams(track, fx) - 1 do
        local _, pname = reaper.TrackFX_GetParamName(track, fx, p, "")
        for key, want in pairs(IEM_PARAM_NAMES) do
          if pname:lower() == want then params[key] = p end
        end
      end
      if params.azimuth and params.elevation then return fx, params end
    end
  end
end

local function tempoMap()
  local map = { __array = true }
  local first = reaper.TimeMap_GetDividedBpmAtTime(0)
  map[1] = { beat = 0, bpm = round(first, 3) }
  for m = 0, reaper.CountTempoTimeSigMarkers(0) - 1 do
    local _, timepos, _, _, bpm = reaper.GetTempoTimeSigMarker(0, m)
    local beat = round(reaper.TimeMap2_timeToQN(0, timepos), 4)
    if beat > map[#map].beat and #map < 64 then map[#map + 1] = { beat = beat, bpm = round(bpm, 3) } end
  end
  return map
end

local function sections(startBeat, endBeat)
  local out = { __array = true }
  local i = 0
  while #out < 32 do
    local ret, isrgn, pos, rgnend, name = reaper.EnumProjectMarkers3(0, i)
    if ret == 0 then break end
    if isrgn then
      local s, e = reaper.TimeMap2_timeToQN(0, pos), reaper.TimeMap2_timeToQN(0, rgnend)
      if e > startBeat and s < endBeat and e > s then
        local label = (name ~= "" and name or ("Region " .. (#out + 1))):sub(1, 40)
        out[#out + 1] = { label = label, start_beat = round(s, 4), end_beat = round(e, 4) }
      end
    end
    i = i + 1
  end
  return out
end

local function tempPath(name)
  local dir = reaper.GetResourcePath() .. "/Scripts"
  return dir .. "/" .. name
end

local function post(key, body)
  local req, res, hdr = tempPath("gravity_feed_request.json"), tempPath("gravity_feed_response.json"), tempPath("gravity_feed_headers.txt")
  local f = io.open(req, "wb")
  local h = io.open(hdr, "wb")
  if not f or not h then return nil, "Could not write a temporary file in the REAPER Scripts folder." end
  f:write(body); f:close()
  h:write("Content-Type: application/json\nX-API-Key: " .. key .. "\n"); h:close()
  -- Headers come from a file so the key never appears in the process list.
  local cmd = string.format('curl -sS -m 60 -X POST "%s" -H "@%s" --data-binary "@%s" -o "%s" -w "%%{http_code}"', API, hdr, req, res)
  local out = reaper.ExecProcess(cmd, 65000) or ""
  local code = tonumber(out:match("(%d%d%d)%s*$"))
  local rf = io.open(res, "rb")
  local text = rf and rf:read("*a") or ""
  if rf then rf:close() end
  os.remove(req); os.remove(res); os.remove(hdr)
  if not code then return nil, "curl did not run. Is curl installed and on the PATH?" end
  return code, text
end

---------------------------------------------------------------- Main
local function main()
  local key = getKey()
  if not key then return reaper.MB("A Gravity Feed key is needed. Create one at spatial9.ai/gravity-feed/console.", "Gravity Feed", 0) end

  local selStart, selEnd = reaper.GetSet_LoopTimeRange(false, false, 0, 0, false)
  local startBeat, lengthBeats
  if selEnd > selStart then
    startBeat = reaper.TimeMap2_timeToQN(0, selStart)
    lengthBeats = reaper.TimeMap2_timeToQN(0, selEnd) - startBeat
  else
    startBeat = reaper.TimeMap2_timeToQN(0, reaper.GetCursorPosition())
    lengthBeats = 32
  end
  lengthBeats = math.min(MAX_BEATS, lengthBeats)
  startBeat = round(startBeat, 4)

  local tracks, targets, bound = { __array = true }, { __array = true }, {}
  local count = math.min(MAX_TRACKS, reaper.CountSelectedTracks(0))
  if count == 0 then return reaper.MB("Select the tracks you want Gravity Feed to plan.", "Gravity Feed", 0) end
  for t = 0, count - 1 do
    local tr = reaper.GetSelectedTrack(0, t)
    local _, name = reaper.GetTrackName(tr)
    local id = cleanId(reaper.GetTrackGUID(tr))
    local role = name:lower():gsub("[^%w ]", ""):sub(1, 60)
    local entry = { id = id, name = name:sub(1, 60), role = { value = role ~= "" and role or "unknown", source = role ~= "" and "host" or "unknown" } }
    local fx, params = findEncoder(tr)
    if fx then
      local tid = "enc-" .. (#targets + 1)
      targets[#targets + 1] = { id = tid, profile_id = "iem_stereoencoder_candidate_v1", live_write = false, automation_commit = true }
      entry.target_id = tid
      local function native(name)
        local r = IEM_RANGES[name]
        return r[1] + reaper.TrackFX_GetParamNormalized(tr, fx, params[name]) * (r[2] - r[1])
      end
      local az, el = native("azimuth"), native("elevation")
      if el >= -90 and el <= 90 then
        entry.position = { azimuth_deg = round(az, 2), elevation_deg = round(el, 2) }
      end
      bound[id] = { track = tr, fx = fx, params = params }
    end
    tracks[#tracks + 1] = entry
  end

  local _, intent = reaper.GetUserInputs("Gravity Feed", 1, "Direction (optional),extrawidth=300", reaper.GetExtState(EXT, "intent"))
  intent = (intent or ""):sub(1, 500)
  reaper.SetExtState(EXT, "intent", intent, true)

  local revision = reaper.GetProjectStateChangeCount(0)
  local _, sessionId = reaper.GetProjExtState(0, EXT, "session_id")
  if sessionId == "" then
    sessionId = string.format("reaper-%08x%08x", math.random(0, 0x7fffffff), math.random(0, 0x7fffffff))
    reaper.SetProjExtState(0, EXT, "session_id", sessionId)
    revision = reaper.GetProjectStateChangeCount(0)
  end
  local request = {
    schema_version = "1.0",
    request_id = "reaper-" .. tostring(os.time()) .. "-" .. tostring(math.random(100000, 999999)),
    session = {
      id = sessionId,
      revision = revision,
      tempo_map = tempoMap(),
      selection = { start_beat = startBeat, length_beats = round(lengthBeats, 4) },
      sections = sections(startBeat, startBeat + lengthBeats),
    },
    tracks = tracks,
    targets = targets,
    privacy = { mode = "metadata_only", audio_uploaded = false },
  }
  if #targets == 0 then request.targets = nil end
  if intent ~= "" then request.intent = intent end

  local requestedAt = os.time()
  local code, text = post(key, encode(request))
  if not code then return reaper.MB(text, "Gravity Feed", 0) end
  local okParse, plan = pcall(decode, text)
  if not okParse or type(plan) ~= "table" then return reaper.MB("Gravity Feed returned an unreadable answer (HTTP " .. code .. ").", "Gravity Feed", 0) end
  if code ~= 200 then
    local err = plan.error or {}
    local msg = (err.message or "Request failed") .. " (HTTP " .. code .. ")"
    if code == 401 then reaper.DeleteExtState(EXT, "key", true) end
    for _, f in ipairs(err.fields or {}) do msg = msg .. "\n" .. tostring(f.path) .. ": " .. tostring(f.message) end
    return reaper.MB(msg, "Gravity Feed", 0)
  end

  reaper.ClearConsole()
  log("Gravity Feed plan " .. tostring(plan.plan_id))
  for n, alt in ipairs(plan.alternatives or {}) do
    log(string.format("\n[%d] %s: %s", n, alt.style, alt.summary or ""))
    for _, m in ipairs(alt.motions or {}) do log("   move " .. m.track_id .. ": " .. m.shape .. " - " .. (m.reason or "")) end
    for _, e in ipairs(alt.effect_suggestions or {}) do log("   idea " .. e.track_id .. " (" .. e.kind .. "): " .. e.intent) end
    for _, u in ipairs(alt.unsupported_actions or {}) do log("   not applied: " .. u.reason) end
  end

  local ok, choice = reaper.GetUserInputs("Apply which alternative?", 1, "1, 2 or 3 (0 = none)", "2")
  local alt = ok and plan.alternatives and plan.alternatives[tonumber(choice) or 0]
  if not alt then return end

  if reaper.GetProjectStateChangeCount(0) ~= revision then
    return reaper.MB("The project changed after the plan was requested, so it was not applied. Run the script again.", "Gravity Feed", 0)
  end
  if os.time() - requestedAt > 600 then
    return reaper.MB("This plan has expired (plans last 10 minutes). Run the script again.", "Gravity Feed", 0)
  end

  reaper.Undo_BeginBlock()
  local written = 0
  for _, action in ipairs(alt.actions or {}) do
    local b = bound[action.track_id]
    local range = IEM_RANGES[action.parameter]
    if b and range and action.profile_id == "iem_stereoencoder_candidate_v1" then
      local env = reaper.GetFXEnvelope(b.track, b.fx, b.params[action.parameter], true)
      if env then
        local pts = action.points or {}
        local t0 = reaper.TimeMap2_QNToTime(0, pts[1] and pts[1].beat or startBeat)
        local t1 = reaper.TimeMap2_QNToTime(0, pts[#pts] and pts[#pts].beat or startBeat)
        reaper.DeleteEnvelopePointRange(env, t0, t1 + 0.0001)
        for _, p in ipairs(pts) do
          local norm = (p.value - range[1]) / (range[2] - range[1])
          norm = math.max(0, math.min(1, norm))
          reaper.InsertEnvelopePoint(env, reaper.TimeMap2_QNToTime(0, p.beat), norm, 0, 0, false, true)
        end
        reaper.Envelope_SortPoints(env)
        written = written + 1
      end
    end
  end
  reaper.Undo_EndBlock("Gravity Feed: apply " .. tostring(alt.style) .. " plan", -1)
  reaper.UpdateArrange()
  log(string.format("\nWrote %d automation lane(s). Undo with Ctrl/Cmd+Z.", written))
end

math.randomseed(os.time())
main()
