-- Gen 2 title: colored TitleScreenTilemap BG, scrolling clouds, Ho-Oh
-- wing-flap (Frameset_GSIntroHoOhLugia), spark trails, A/Start to continue.
-- drawWidescreen fills the window with sky/clouds so widescreen has no
-- pillarbox voids; the 160x144 art stays aspect-centered on top.
-- Every Gold/Silver difference arrives as a title.lua key, defaulted to Gold.

-- src/render/Assets.lua is the mod-override choke point: a raw
-- love.graphics.newImage skips overrides/ and AssetTransform output.
local Assets = require("src.render.Assets")
local Chrome = require("src.ui.gen2.Chrome")
local GbcPalette = require("src.render.GbcPalette")
local Music = require("src.core.Music")
local Runtime = require("src.mods.Runtime")
local Sound = require("src.core.Sound")
local SpriteAnims = require("src.ui.gen2.SpriteAnims")

local TitleState = {}
TitleState.__index = TitleState
TitleState.isOpaque = true

-- title_bg_gold.pal mid-sky shade, for a cache built before title.sky existed.
local SKY = { 123 / 255, 165 / 255, 255 / 255, 1 }
-- ...and its grey stand-in, for when COLOR is not GBC.  The title art is the
-- one thing in the port baked with its colours in (see the extractor), so the
-- window fill that matches it has to be picked the same way the sheet is.
-- The sky is BG colour 2, and LoadTitleScreenPals' rBGP (%11011000) sends
-- that colour to shade 1, not to shade 2 -- the grey sheet is baked through
-- the same register, so the fill has to follow it or the surround comes out
-- darker than the screen it surrounds.
local SKY_GRAY = { 170 / 255, 170 / 255, 170 / 255, 1 }

local function tryImage(path)
  if not path then return nil end
  local ok, image = pcall(Assets.image, path)
  if ok then return image end
  return nil
end

function TitleState:wantsFillScale() return true end
function TitleState:drawsWidescreen() return true end

function TitleState.new(game, opts)
  opts = opts or {}
  local self = setmetatable({}, TitleState)
  self.game = game
  self.onContinue = opts.onContinue
  local title = opts.title or {}
  self.title = title
  self.screenColor = tryImage(title.screen
    or "assets/generated/title/title_screen.png")
  self.cloudsColor = tryImage(title.clouds
    or "assets/generated/title/clouds.png")
  self.trailColor = tryImage(title.trail or "assets/generated/title/trail.png")
  -- The uncoloured set, absent from a cache built before COLOR existed -- in
  -- which case pickArt falls back to the colour one and DMG simply looks the
  -- way it did before rather than failing to draw.
  self.screenGray = tryImage(title.screenGray)
  self.cloudsGray = tryImage(title.cloudsGray)
  self.trailGray = tryImage(title.trailGray)
  -- `depixel 12, 11` less the OAM bias and the pose's own origin; see the
  -- extractor, which writes the same pair into title.lua.
  self.hoohX = tonumber(title.hoohX) or 48
  self.hoohY = tonumber(title.hoohY) or 56
  self.cloudY = tonumber(title.cloudY) or 88
  self.cloudScrollEvery = tonumber(title.cloudScrollEvery) or 8
  -- AnimSeq_GSIntroHoOhLugia (engine/sprite_anims/functions.asm:820-838).
  self.hoohBobAmplitude = tonumber(title.hoohBobAmplitude) or 2
  self.hoohBobStep = tonumber(title.hoohBobStep) or 1
  local sky = title.sky
  self.sky = (type(sky) == "table" and #sky >= 3)
    and { sky[1], sky[2], sky[3], 1 } or SKY
  local below = title.below
  self.below = (type(below) == "table" and #below >= 3)
    and { below[1], below[2], below[3], 1 } or { 1, 1, 1, 1 }

  self.hoohColor, self.hoohGray = {}, {}
  local paths = title.hoohFrames
  if type(paths) == "table" then
    for i, path in ipairs(paths) do
      self.hoohColor[i] = tryImage(path)
    end
  end
  if #self.hoohColor == 0 then
    self.hoohColor[1] = tryImage(title.hooh or "assets/generated/title/hooh.png")
  end
  if type(title.hoohFramesGray) == "table" then
    for i, path in ipairs(title.hoohFramesGray) do
      self.hoohGray[i] = tryImage(path)
    end
  end
  self.sequence = title.hoohSequence or {
    { 1, 10 }, { 2, 9 }, { 3, 10 }, { 4, 10 }, { 3, 9 }, { 5, 10 },
  }
  -- A frame shows duration + 1 ticks: GetSpriteAnimFrame stores the byte on
  -- the advancing tick and only decrements on the ones after
  -- (engine/sprite_anims/core.asm:400-434).  Both editions' framesets total
  -- 64 ticks with it, locking the wing beat to the 64-tick sine bob.
  self.seqIndex = 1
  self.seqLeft = (self.sequence[1] and self.sequence[1][2] or 10) + 1
  self.frame = 1

  -- AnimSeq_GSIntroHoOhLugia's SPRITEANIMSTRUCT_VAR1.
  self.hoohPhase = 0
  self.frameCounter = 0
  self.cloudScroll = 0
  self.trails = {}
  -- UpdateTitleTrailSprite / TitleTrailCoords (intro_menu.asm:1069-1124), in
  -- pixels.
  self.trailSpawns = title.trailSpawns or {
    { 80, 88 }, { 104, 88 }, { 104, 88 }, { 120, 88 },
    { 120, 88 }, { 88, 88 },
  }
  self.trailSpawnIndex = 1
  -- AnimSeq_GSTitleTrail (engine/sprite_anims/functions.asm:720-818).
  self.trailMode = title.trailMode or "gold"
  self.trailSpawnEvery = tonumber(title.trailSpawnEvery) or 4
  self.trailStepX = tonumber(title.trailStepX) or 4
  self.trailStepY = tonumber(title.trailStepY) or 1
  self.trailBobAmplitude = tonumber(title.trailBobAmplitude) or 2
  self.trailPhaseStep = tonumber(title.trailPhaseStep) or 3
  self.trailPhase = tonumber(title.trailPhase)
  -- How far past the 160px frame trails may fly (GB pixels); set each draw.
  self.trailMaxX = 200
  self.musicStarted = false

  -- engine/movie/title.asm:104-127,217-273,304-338
  self.suicuneColor, self.suicuneGray = {}, {}
  if type(title.suicuneFrames) == "table" then
    for i, path in ipairs(title.suicuneFrames) do
      self.suicuneColor[i] = tryImage(path)
    end
  end
  if type(title.suicuneFramesGray) == "table" then
    for i, path in ipairs(title.suicuneFramesGray) do
      self.suicuneGray[i] = tryImage(path)
    end
  end
  self.suicuneX = tonumber(title.suicuneX) or 48
  self.suicuneY = tonumber(title.suicuneY) or 96
  -- SuicuneFrameIterator's `and %111` clock (engine/movie/title.asm:217-243).
  self.suicuneEvery = tonumber(title.suicuneEvery) or 8
  self.suicuneTick = 0
  self.suicuneFrame = 1
  self.gemColor = tryImage(title.gem)
  self.gemGray = tryImage(title.gemGray)
  self.gemX = tonumber(title.gemX) or 56
  self.gemRestY = tonumber(title.gemY) or 6
  self.gemStep = tonumber(title.gemStep) or 2
  -- TitleScreenEntrance (engine/menus/intro_menu.asm:1078-1123): hSCX walks
  -- to 0 while alternating logo lines converge and the gem descends.
  local entrance = type(title.entrance) == "table" and title.entrance or nil
  self.entrance = entrance
  self.entranceScx = entrance and (tonumber(entrance.scx) or 112) or 0
  self.entranceStep = entrance and (tonumber(entrance.step) or 4) or 4
  self.entranceLines = entrance and (tonumber(entrance.lines) or 80) or 0
  self.entranceHideBelow = entrance and tonumber(entrance.hideBelow) or nil
  self.gemY = entrance and (tonumber(title.gemFromY) or -50) or self.gemRestY
  self.entranceSfx = title.entranceSfx
  -- engine/menus/intro_menu.asm:951-966
  self.timeoutFrames = tonumber(title.timeoutFrames)
    or ((self.trailMode == "silver") and (73 * 60 + 36) or (84 * 60 + 16))
  self.onTimeout = opts.onTimeout
  -- The copyright window line is pal 7 (engine/movie/title.asm:40-43); its
  -- colour 0 backs the whole band.
  local pals = type(title.palettes) == "table" and title.palettes.bg or nil
  local band = type(pals) == "table" and type(pals[8]) == "table"
    and pals[8][1] or nil
  self.bandColor = (type(band) == "table" and #band >= 3)
    and { band[1] / 255, band[2] / 255, band[3] / 255, 1 } or { 0, 0, 0, 1 }
  return self
end

function TitleState:startMusic()
  if self.musicStarted then return end
  local data = self.game and self.game.data
  if data and data.audio and data.audio.runtime then
    Music.play(data, "Music_TitleScreen", true, { reason = "title" })
    self.musicStarted = true
  end
end

function TitleState:enter()
  if self.entrance then
    -- _TitleScreen silences the channels and plays the entrance sting;
    -- MUSIC_TITLE waits for the entrance to land
    -- (engine/movie/title.asm:184,210-213; engine/menus/intro_menu.asm:1118-1119).
    Music.stop()
    local data = self.game and self.game.data
    if data and data.audio and data.audio.runtime and self.entranceSfx
        and data.audio.sfx and data.audio.sfx[self.entranceSfx] then
      Sound.play(data, self.entranceSfx)
    end
  else
    self:startMusic()
  end
  -- intro.boot.title: the last card of the GS boot cinema, up with its music
  -- started.  Gen 2 only, like the rest of intro.boot.* (see
  -- src/ui/gen2/CopyrightSplash.lua for why the set is new rather than shared).
  -- Its end is the main menu opening, which src/ui/gen2/MainMenu.lua owns, so
  -- there is no `ended` name here.  The title's MENU is a different seam again:
  -- that is Gen 1's `ui.title_menu.items`, which Gold reaches through the main
  -- menu rather than through this screen.
  if Runtime.wants("intro.boot.title") then
    Runtime.emit("intro.boot.title", { screen = self, game = self.game })
  end
end

-- Sprites_Sine hands back the byte the ASM leaves in a, so the down half of
-- the wave arrives in two's complement and is a signed pixel delta here.
local function signed(value)
  if value >= 0x80 then return value - 0x100 end
  return value
end

-- AnimSeq_GSIntroHoOhLugia (engine/sprite_anims/functions.asm:820-838).
function TitleState:hoohBob()
  return signed(SpriteAnims.sine(self.hoohPhase, self.hoohBobAmplitude))
end

function TitleState:advanceHooh()
  self.hoohPhase = (self.hoohPhase + self.hoohBobStep) % 256
  self.seqLeft = self.seqLeft - 1
  if self.seqLeft > 0 then return end
  self.seqIndex = self.seqIndex + 1
  if self.seqIndex > #self.sequence then self.seqIndex = 1 end
  local step = self.sequence[self.seqIndex]
  self.frame = step[1]
  self.seqLeft = step[2] + 1
end

function TitleState:spawnTrail()
  if not (self.trailColor or self.trailGray) then return end
  if #self.trailSpawns == 0 then return end
  if self.frameCounter % self.trailSpawnEvery ~= 0 then return end
  local spawn = self.trailSpawns[self.trailSpawnIndex]
  self.trailSpawnIndex = self.trailSpawnIndex % #self.trailSpawns + 1
  if not spawn then return end
  self.trails[#self.trails + 1] = {
    x = spawn[1], y = spawn[2],
    phase = self.trailPhase or love.math.random(0, 255),
  }
end

function TitleState:stepTrails()
  local alive = {}
  local maxX = self.trailMaxX or 200
  local silver = self.trailMode == "silver"
  for _, t in ipairs(self.trails) do
    t.x = t.x + self.trailStepX
    t.y = t.y + self.trailStepY
    t.phase = t.phase + self.trailPhaseStep
    if silver then
      t.drawY = t.y + signed(SpriteAnims.sine(t.phase, self.trailBobAmplitude))
    else
      t.drawY = t.y
        + math.floor(math.sin(t.phase / 16) * self.trailBobAmplitude)
    end
    if t.x < maxX then alive[#alive + 1] = t end
  end
  self.trails = alive
end

-- SuicuneFrameIterator (engine/movie/title.asm:217-249): advance once per
-- `suicuneEvery` frames, cycling the four frame bases.
function TitleState:advanceSuicune()
  if #self.suicuneColor == 0 then return end
  local c = self.suicuneTick
  self.suicuneTick = (c + 1) % 256
  if c % self.suicuneEvery ~= 0 then return end
  self.suicuneFrame = math.floor(c % (self.suicuneEvery * 4)
    / self.suicuneEvery) + 1
end

function TitleState:update(_dt)
  self.frameCounter = self.frameCounter + 1
  self:advanceHooh()
  self:advanceSuicune()
  if self.frameCounter % self.cloudScrollEvery == 0 then
    self.cloudScroll = (self.cloudScroll - 1) % 160
  end
  self:spawnTrail()
  self:stepTrails()

  if self.entranceScx > 0 then
    -- TitleScreenEntrance polls no buttons and moves the gem by 2 a frame
    -- (engine/menus/intro_menu.asm:1078-1107; engine/movie/title.asm:340-362).
    self.entranceScx = math.max(0, self.entranceScx - self.entranceStep)
    if self.gemY < self.gemRestY then
      self.gemY = math.min(self.gemRestY, self.gemY + self.gemStep)
    end
    if self.entranceScx == 0 then
      self:startMusic()
      -- TitleScreenTimer only starts once the entrance scene hands over
      -- (engine/menus/intro_menu.asm:1110-1136).
      self.timeoutStart = self.frameCounter
    end
    return
  end

  -- engine/menus/intro_menu.asm:1023-1059
  if self.timeoutFrames and self.onTimeout then
    if self.fadeStart then
      if self.frameCounter - self.fadeStart >= 60 then self.onTimeout() end
      return
    end
    if self.frameCounter - (self.timeoutStart or 0) >= self.timeoutFrames then
      self.fadeStart = self.frameCounter
      Music.fadeOut(8)
      return
    end
  end

  local input = self.game.input
  if input and (input:wasPressed("a") or input:wasPressed("start")) then
    if self.onContinue then self.onContinue() end
  end
end

function TitleState:gray()
  return GbcPalette.mode == "dmg" or GbcPalette.mode == "classic"
end

function TitleState:art()
  if self:gray() then
    return self.screenGray or self.screenColor,
      self.cloudsGray or self.cloudsColor,
      self.trailGray or self.trailColor,
      (#self.hoohGray > 0) and self.hoohGray or self.hoohColor
  end
  return self.screenColor, self.cloudsColor, self.trailColor, self.hoohColor
end

-- Tile the cloud strip across [x0, x1) in GB pixel space (y = 0 of strip).
function TitleState:drawCloudSpan(x0, x1)
  local _, clouds = self:art()
  if not clouds then return end
  local G = love.graphics
  local s = self.cloudScroll % 160
  -- First tile origin such that the scroll lines up with the 160px frame.
  local start = math.floor((x0 + s) / 160) * 160 - s
  for x = start, x1, 160 do
    G.draw(clouds, x, 0)
  end
end

-- TitleScreenEntrance's interlace: even lines slide in from the left, odd
-- from the right, converging as hSCX walks to 0
-- (engine/menus/intro_menu.asm:1084-1103).
function TitleState:drawEntranceScreen(screen)
  local G = love.graphics
  local scx = self.entranceScx
  if scx <= 0 then
    G.draw(screen, 0, 0)
    return
  end
  local w, h = screen:getDimensions()
  local lines = math.min(self.entranceLines, h)
  self.entranceQuad = self.entranceQuad or G.newQuad(0, 0, w, 1, w, h)
  for line = 0, lines - 1 do
    self.entranceQuad:setViewport(0, line, w, 1, w, h)
    G.draw(screen, self.entranceQuad, line % 2 == 0 and -scx or scx, line)
  end
  -- hWY holds the copyright window off screen until the entrance lands
  -- (engine/movie/title.asm:198-199; engine/menus/intro_menu.asm:1121-1122).
  local bottom = (self.entranceHideBelow or h) - lines
  if bottom > 0 then
    self.entranceQuad:setViewport(0, lines, w, bottom, w, h)
    G.draw(screen, self.entranceQuad, 0, lines)
  end
end

function TitleState:drawContent()
  local G = love.graphics
  local screen, _, trail, hoohFrames = self:art()
  G.setColor(1, 1, 1, 1)

  local gem = self:gray() and (self.gemGray or self.gemColor) or self.gemColor
  local suicuneFrames = nil
  if #self.suicuneColor > 0 then
    suicuneFrames = (self:gray() and #self.suicuneGray > 0)
      and self.suicuneGray or self.suicuneColor
  end
  if gem or suicuneFrames then
    -- Crystal's layering: the gem is OAM_PRIO so every BG colour 1-3 pixel
    -- beats it; Suicune is BG; the window copyright covers Suicune's last
    -- row (engine/movie/title.asm:81-85,334; engine/menus/intro_menu.asm:1121-1122).
    local fill = self:gray() and SKY_GRAY or self.sky
    G.setColor(fill[1], fill[2], fill[3], 1)
    G.rectangle("fill", 0, 0, 160, 144)
    G.setColor(1, 1, 1, 1)
    if gem then G.draw(gem, self.gemX, self.gemY) end
    if suicuneFrames then
      local frame = suicuneFrames[self.suicuneFrame] or suicuneFrames[1]
      if frame then G.draw(frame, self.suicuneX, self.suicuneY) end
    end
    if self.entranceScx <= 0 then
      -- The pal-7 window line covers the BG once hWY lands at $88
      -- (engine/movie/title.asm:40-43; engine/menus/intro_menu.asm:1121-1122).
      local bandTop = self.entranceHideBelow or 136
      local band = self.bandColor
      G.setColor(band[1], band[2], band[3], 1)
      G.rectangle("fill", 0, bandTop, 160, 144 - bandTop)
      G.setColor(1, 1, 1, 1)
    end
    if screen then
      self:drawEntranceScreen(screen)
    end
    return
  end

  if screen then
    -- title_screen.png already carries the cart's © GAME FREAK line on row 17.
    G.draw(screen, 0, 0)
  else
    G.rectangle("fill", 0, 0, 160, 144)
  end

  -- Center cloud scroll (matches the side tiles from drawWidescreen).
  G.push()
  G.translate(0, self.cloudY)
  self:drawCloudSpan(0, 160)
  G.pop()

  local bob = self:hoohBob()
  local hooh = hoohFrames[self.frame] or hoohFrames[1]
  if hooh then
    G.draw(hooh, self.hoohX, self.hoohY + bob)
  end

  if trail then
    for _, t in ipairs(self.trails) do
      G.draw(trail, t.x, t.drawY or t.y)
    end
  end
end

-- Letterboxed fallback (non-widescreen hosts).
function TitleState:draw()
  self:drawContent()
end

-- Full-window sky + cloud wrap, then centered 160x144 art.
function TitleState:drawWidescreen(winW, winH)
  local G = love.graphics
  local scale = Chrome.fitScale(winW, winH)
  local ox, oy = Chrome.fitOrigin(winW, winH, scale)
  local cloudTop = oy + self.cloudY * scale
  -- Let trails fly into the side bands.
  self.trailMaxX = math.ceil((winW - ox) / scale) + 16

  -- Sky above the cloud line, title.below under it (Gold's white cloud
  -- field, Silver's black sea) : edge to edge.  The fill has to match
  -- whichever baked set is showing, or the surround would stay blue around a
  -- grey screen.
  local sky = self:gray() and SKY_GRAY or self.sky
  G.setColor(sky[1], sky[2], sky[3], 1)
  G.rectangle("fill", 0, 0, winW, math.max(0, cloudTop))
  local below = self.below
  G.setColor(below[1], below[2], below[3], 1)
  G.rectangle("fill", 0, cloudTop, winW, winH - cloudTop)
  G.setColor(1, 1, 1, 1)

  -- Clouds across the full window width, aligned to the GB cloud band.
  G.push()
  G.translate(ox, cloudTop)
  G.scale(scale, scale)
  local left = -math.ceil(ox / scale) - 160
  local right = math.ceil((winW - ox) / scale) + 160
  self:drawCloudSpan(left, right)
  G.pop()

  -- Centered original composition (logo / Ho-Oh / copyright / trails).
  G.push()
  G.translate(ox, oy)
  G.scale(scale, scale)
  self:drawContent()
  G.pop()
end

return TitleState
