// title_scene.cpp - Implementació de l'escena de títol 3D real
// © 2026 JailDesigner

#include "title_scene.hpp"

#include <algorithm>
#include <cfloat>
#include <cmath>
#include <iostream>
#include <numbers>
#include <string>

#include "core/audio/audio.hpp"
#include "core/defaults.hpp"
#include "core/graphics/shape_loader.hpp"
#include "core/input/define_inputs.hpp"
#include "core/input/input.hpp"
#include "core/locale/locale.hpp"
#include "core/math/easing.hpp"
#include "core/rendering/shape_renderer.hpp"
#include "core/system/scene_context.hpp"
#include "core/system/service_menu.hpp"
#include "game/systems/demo_pilot.hpp"
#include "project.h"

using SceneManager::SceneContext;
using SceneType = SceneContext::SceneType;
using Option = SceneContext::Option;

namespace {

    // Botons per iniciar partida des de MAIN (només START).
    constexpr std::array<InputAction, 1> START_GAME_BUTTONS = {InputAction::START};

}  // namespace

TitleScene::TitleScene(SDLManager& sdl, SceneContext& context)
    : sdl_(sdl),
      context_(context),
      text_(sdl.getRenderer()),
      curtain_(sdl.getRenderer()) {
    std::cout << "SceneType Titol: Inicialitzant...\n";

    match_config_.player1_active = false;
    match_config_.player2_active = false;
    match_config_.mode = GameConfig::Mode::NORMAL;

    auto option = context_.consumeOption();
    if (option == Option::JUMP_TO_TITLE_MAIN) {
        std::cout << "SceneType Titol: Opció JUMP_TO_TITLE_MAIN activada\n";
        current_state_ = TitleState::MAIN;
        state_time_main_ = 0.0F;
    }

    // Càmera 3D: posicionada a l'origen, mirant cap a +Z, amb Y cap amunt.
    camera_ = std::make_unique<Graphics::Camera3D>(
        Vec3{.x = 0.0F, .y = 0.0F, .z = 0.0F},
        Vec3{.x = 0.0F, .y = 0.0F, .z = 1.0F},
        Vec3{.x = 0.0F, .y = 1.0F, .z = 0.0F},
        CAMERA_FOV_Y_RAD,
        static_cast<float>(Defaults::Game::WIDTH),
        static_cast<float>(Defaults::Game::HEIGHT));

    starfield_ = std::make_unique<Graphics::Starfield>(
        sdl_.getRenderer(),
        camera_.get(),
        200);
    starfield_->setColor(Defaults::Title::Colors::STARFIELD);
    if (current_state_ == TitleState::MAIN) {
        starfield_->setBrightness(BRIGHTNESS_STARFIELD);
    } else {
        starfield_->setBrightness(0.0F);
    }

    ship_animator_ = std::make_unique<Title::ShipAnimator>(sdl_.getRenderer(), camera_.get());
    ship_animator_->init();
    // Les naus comencen invisibles; updateMainState() les dispara al moment
    // correcte de la intro coreografiada (també quan venim de JUMP_TO_TITLE_MAIN).
    ship_animator_->setVisible(false);

    // Flash que tapa el "pop" final de la nau al VP. Es spawneja al centre
    // de pantalla (= projecció del VP) quan ship_animator avisa.
    flash_shape_ = Graphics::ShapeLoader::load("effect/title_flash.shp");
    ship_animator_->setOnShipDisappear([this](int /*player_id*/) {
        triggerFlash(Vec2{
            .x = static_cast<float>(Defaults::Window::WIDTH) / 2.0F,
            .y = static_cast<float>(Defaults::Window::HEIGHT) / 2.0F});
    });

    initTitle();
    inicialitzarJailgames();

    if (Audio::getMusicState() != Audio::MusicState::PLAYING) {
        Audio::get()->playMusic("title.ogg");
    }
}

TitleScene::~TitleScene() {
    // Attract mode: si saltem a la demo, NO parem la música — ha de seguir
    // sonant durant tot el cicle TÍTOL→DEMO→LOGO→TÍTOL. La resta de sortides
    // (partida normal, EXIT) sí paren.
    const bool ENTERING_DEMO =
        context_.nextScene() == SceneType::GAME &&
        context_.getMatchConfig().mode == GameConfig::Mode::DEMO;
    if (!ENTERING_DEMO) {
        Audio::get()->stopMusic();
    }
}

void TitleScene::initTitle() {
    using namespace Graphics;

    const std::vector<std::string> FITXERS_ORNI = {
        "title/letra_o.shp",
        "title/letra_r.shp",
        "title/letra_n.shp",
        "title/letra_i.shp"};

    float total_width_orni = 0.0F;
    for (const auto& file : FITXERS_ORNI) {
        auto shape = ShapeLoader::load(file);
        if (!shape || !shape->isValid()) {
            std::cerr << "[TitleScene] Error carregant " << file << '\n';
            continue;
        }
        float min_x = FLT_MAX;
        float max_x = -FLT_MAX;
        float min_y = FLT_MAX;
        float max_y = -FLT_MAX;
        for (const auto& prim : shape->getPrimitives()) {
            for (const auto& point : prim.points) {
                min_x = std::min(min_x, point.x);
                max_x = std::max(max_x, point.x);
                min_y = std::min(min_y, point.y);
                max_y = std::max(max_y, point.y);
            }
        }
        const float WIDTH = (max_x - min_x) * Defaults::Title::Layout::LOGO_SCALE;
        const float HEIGHT = (max_y - min_y) * Defaults::Title::Layout::LOGO_SCALE;
        const float CENTER_OFFSET = (shape->getCenter().x - min_x) * Defaults::Title::Layout::LOGO_SCALE;
        letters_orni_.push_back({shape, {.x = 0.0F, .y = 0.0F}, WIDTH, HEIGHT, CENTER_OFFSET});
        total_width_orni += WIDTH;
    }
    total_width_orni += LETTER_SPACING * static_cast<float>(letters_orni_.size() - 1);

    float x_actual = (Defaults::Game::WIDTH - total_width_orni) / 2.0F;
    for (auto& letter : letters_orni_) {
        letter.position.x = x_actual + letter.center_offset;
        letter.position.y = Defaults::Game::HEIGHT * Defaults::Title::Layout::LOGO_POS;
        x_actual += letter.width + LETTER_SPACING;
    }

    const float ORNI_HEIGHT = letters_orni_.empty() ? 50.0F : letters_orni_[0].height;
    const float Y_ORNI = Defaults::Game::HEIGHT * Defaults::Title::Layout::LOGO_POS;
    const float SEPARACION = Defaults::Game::HEIGHT * Defaults::Title::Layout::LOGO_LINE_SPACING;
    dynamic_attack_y_ = Y_ORNI + ORNI_HEIGHT + SEPARACION;

    const std::vector<std::string> FITXERS_ATTACK = {
        "title/letra_a.shp",
        "title/letra_t.shp",
        "title/letra_t.shp",
        "title/letra_a.shp",
        "title/letra_c.shp",
        "title/letra_k.shp",
        "title/letra_exclamacion.shp"};

    float total_width_attack = 0.0F;
    for (const auto& file : FITXERS_ATTACK) {
        auto shape = ShapeLoader::load(file);
        if (!shape || !shape->isValid()) {
            std::cerr << "[TitleScene] Error carregant " << file << '\n';
            continue;
        }
        float min_x = FLT_MAX;
        float max_x = -FLT_MAX;
        float min_y = FLT_MAX;
        float max_y = -FLT_MAX;
        for (const auto& prim : shape->getPrimitives()) {
            for (const auto& point : prim.points) {
                min_x = std::min(min_x, point.x);
                max_x = std::max(max_x, point.x);
                min_y = std::min(min_y, point.y);
                max_y = std::max(max_y, point.y);
            }
        }
        const float WIDTH = (max_x - min_x) * Defaults::Title::Layout::LOGO_SCALE;
        const float HEIGHT = (max_y - min_y) * Defaults::Title::Layout::LOGO_SCALE;
        const float CENTER_OFFSET = (shape->getCenter().x - min_x) * Defaults::Title::Layout::LOGO_SCALE;
        letters_attack_.push_back({shape, {.x = 0.0F, .y = 0.0F}, WIDTH, HEIGHT, CENTER_OFFSET});
        total_width_attack += WIDTH;
    }
    total_width_attack += LETTER_SPACING * static_cast<float>(letters_attack_.size() - 1);

    x_actual = (Defaults::Game::WIDTH - total_width_attack) / 2.0F;
    for (auto& letter : letters_attack_) {
        letter.position.x = x_actual + letter.center_offset;
        letter.position.y = dynamic_attack_y_;
        x_actual += letter.width + LETTER_SPACING;
    }

    original_positions_orni_.clear();
    for (const auto& letter : letters_orni_) {
        original_positions_orni_.push_back(letter.position);
    }
    original_positions_attack_.clear();
    for (const auto& letter : letters_attack_) {
        original_positions_attack_.push_back(letter.position);
    }
}

void TitleScene::inicialitzarJailgames() {
    using namespace Graphics;

    const std::vector<std::string> FITXERS = {
        "logo/letra_j.shp",
        "logo/letra_a.shp",
        "logo/letra_i.shp",
        "logo/letra_l.shp",
        "logo/letra_g.shp",
        "logo/letra_a.shp",
        "logo/letra_m.shp",
        "logo/letra_e.shp",
        "logo/letra_s.shp"};

    constexpr float SCALE = Defaults::Title::Layout::JAILGAMES_SCALE;

    float total_width = 0.0F;
    float max_height = 0.0F;
    for (const auto& file : FITXERS) {
        auto shape = ShapeLoader::load(file);
        if (!shape || !shape->isValid()) {
            std::cerr << "[TitleScene] Error carregant " << file << '\n';
            continue;
        }
        float min_x = FLT_MAX;
        float max_x = -FLT_MAX;
        float min_y = FLT_MAX;
        float max_y = -FLT_MAX;
        for (const auto& prim : shape->getPrimitives()) {
            for (const auto& point : prim.points) {
                min_x = std::min(min_x, point.x);
                max_x = std::max(max_x, point.x);
                min_y = std::min(min_y, point.y);
                max_y = std::max(max_y, point.y);
            }
        }
        const float WIDTH = (max_x - min_x) * SCALE;
        const float HEIGHT = (max_y - min_y) * SCALE;
        const float CENTER_OFFSET = (shape->getCenter().x - min_x) * SCALE;
        letters_jailgames_.push_back({shape, {.x = 0.0F, .y = 0.0F}, WIDTH, HEIGHT, CENTER_OFFSET});
        total_width += WIDTH;
        max_height = std::max(max_height, HEIGHT);
    }
    constexpr float JAILGAMES_SPACING = LETTER_SPACING * SCALE;
    if (!letters_jailgames_.empty()) {
        total_width += JAILGAMES_SPACING * static_cast<float>(letters_jailgames_.size() - 1);
    }

    const float Y_COPY = Defaults::Game::HEIGHT * Defaults::Title::Layout::COPYRIGHT1_POS;
    const float GAP = Defaults::Game::HEIGHT * Defaults::Title::Layout::JAILGAMES_COPYRIGHT_GAP;
    const float Y_CENTRE = Y_COPY - GAP - (max_height / 2.0F);
    const float X_INICIAL = (Defaults::Game::WIDTH - total_width) / 2.0F;

    float x_actual = X_INICIAL;
    for (auto& letter : letters_jailgames_) {
        letter.position.x = x_actual + letter.center_offset;
        letter.position.y = Y_CENTRE;
        x_actual += letter.width + JAILGAMES_SPACING;
    }
}

void TitleScene::dibuixarPeuTitol(float spacing) const {
    namespace S = Defaults::Title::Sequence;

    // Pivot al centre de pantalla (= projecció VP). Cada element s'expandeix
    // des d'aquí mentre s_factor passa de SCALE_START (gran, prop de l'usuari)
    // a 1.0 (a la mida i posició finals, "lluny" al VP).
    const float SCREEN_CENTRE_X = Defaults::Game::WIDTH / 2.0F;
    const float SCREEN_CENTRE_Y = Defaults::Game::HEIGHT / 2.0F;
    // dive_zoom_ (attract) afegeix el zoom del dive per travessar el peu.
    const float JAILGAMES_S = dive_zoom_ * std::lerp(S::FOOTER_INTRO_SCALE_START, 1.0F, Easing::easeOutQuad(intro_jailgames_progress_));
    const float COPYRIGHT_S = dive_zoom_ * std::lerp(S::FOOTER_INTRO_SCALE_START, 1.0F, Easing::easeOutQuad(intro_copyright_progress_));

    const float JAILGAMES_RENDER_SCALE = Defaults::Title::Layout::JAILGAMES_SCALE * JAILGAMES_S;
    for (const auto& letter : letters_jailgames_) {
        const Vec2 POS{
            .x = SCREEN_CENTRE_X + (JAILGAMES_S * (letter.position.x - SCREEN_CENTRE_X)),
            .y = SCREEN_CENTRE_Y + (JAILGAMES_S * (letter.position.y - SCREEN_CENTRE_Y)),
        };
        Rendering::renderShape(sdl_.getRenderer(), letter.shape, POS, 0.0F, JAILGAMES_RENDER_SCALE, 1.0F, 1.0F, Defaults::Title::Colors::JAILGAMES_LOGO);
    }
    std::string copyright = Project::COPYRIGHT;
    for (char& c : copyright) {
        if (c >= 'a' && c <= 'z') {
            c = static_cast<char>(c - 32);
        }
    }
    const float Y_COPY_FINAL = Defaults::Game::HEIGHT * Defaults::Title::Layout::COPYRIGHT1_POS;
    const float COPY_X = SCREEN_CENTRE_X;  // ja al centre
    const float COPY_Y = SCREEN_CENTRE_Y + (COPYRIGHT_S * (Y_COPY_FINAL - SCREEN_CENTRE_Y));
    const float COPY_RENDER_SCALE = Defaults::Title::Layout::COPYRIGHT_SCALE * COPYRIGHT_S;
    text_.renderCentered(copyright, {.x = COPY_X, .y = COPY_Y}, COPY_RENDER_SCALE, spacing, 1.0F, Defaults::Title::Colors::COPYRIGHT);
}

auto TitleScene::isFinished() const -> bool {
    return context_.nextScene() != SceneType::TITLE;
}

void TitleScene::update(float delta_time) {
    if (starfield_) {
        starfield_->update(delta_time);
    }
    if (ship_animator_ &&
        (current_state_ == TitleState::STARFIELD_FADE_IN ||
            current_state_ == TitleState::STARFIELD ||
            current_state_ == TitleState::MAIN ||
            current_state_ == TitleState::PLAYER_JOIN_PHASE)) {
        ship_animator_->update(delta_time);
    }
    updateFlashes(delta_time);

    switch (current_state_) {
        case TitleState::STARFIELD_FADE_IN:
            updateStarfieldFadeInState(delta_time);
            break;
        case TitleState::STARFIELD:
            updateStarfieldState(delta_time);
            break;
        case TitleState::MAIN:
            updateMainState(delta_time);
            break;
        case TitleState::PLAYER_JOIN_PHASE:
            updatePlayerJoinPhaseState(delta_time);
            break;
        case TitleState::BLACK_SCREEN:
            updateBlackScreenState(delta_time);
            break;
        case TitleState::DEMO_DIVE:
            updateDemoDiveState(delta_time);
            break;
        case TitleState::DEMO_CURTAIN:
            updateDemoCurtainState(delta_time);
            break;
    }

    // Les animacions segueixen pero els inputs es bloquegen mentre el menu
    // de servei o l'overlay de redefinicio estiguin actius: en cas contrari,
    // SDL_GetKeyboardState i SDL_GetGamepadButton segueixen veient les tecles
    // pulsades i podrien disparar handleSkipInput/handleStartInput sense
    // intencio. Mateixa logica: per a GameScene tota la pausa es global,
    // pero a TitleScene nomes guardem els polls d'input.
    const auto* menu = System::ServiceMenu::get();
    const auto* di = System::DefineInputs::get();
    const bool INPUT_BLOCKED = (menu != nullptr && menu->isOpen()) ||
        (di != nullptr && di->isActive());
    if (!INPUT_BLOCKED) {
        handleSkipInput();
        handleStartInput();
    }

    // Attract mode: al state MAIN, acumular inactivitat; qualsevol botó
    // arcade la reseteja. En esgotar el timeout, saltar a la demo (mode DEMO,
    // P1 actiu) sense fer fadeOut de la música (a diferència del START real).
    if (current_state_ == TitleState::MAIN && !INPUT_BLOCKED) {
        if (Input::get()->checkAnyPlayerAction(ARCADE_BUTTONS, Input::ALLOW_REPEAT)) {
            idle_timer_ = 0.0F;
        } else {
            idle_timer_ += delta_time;
        }
        if (idle_timer_ >= TITLE_DEMO_TIMEOUT) {
            // L'escenari curat (mateix índex que llegirà GameScene) decideix
            // quants jugadors IA hi ha. No avancem l'índex ací: ho fa GameScene.
            const Systems::Demo::Scenario SC = Systems::Demo::scenario(context_.demoScenarioIndex());
            GameConfig::MatchConfig demo_cfg;
            demo_cfg.player1_active = true;
            demo_cfg.player2_active = (SC.players >= 2);
            demo_cfg.mode = GameConfig::Mode::DEMO;
            context_.setMatchConfig(demo_cfg);
            // No saltem en sec: primer un "dive" de càmera cap al punt de fuga
            // (deixa enrere títol/naus/logo) i després la cortinilla. L'estat
            // deixa de ser MAIN, així que ni es re-dispara la demo ni s'accepta
            // START durant la transició. Amaguem ja el "PREMEU START".
            press_start_visible_ = false;
            current_state_ = TitleState::DEMO_DIVE;
            dive_time_ = 0.0F;
            dive_zoom_ = 1.0F;
            temps_acumulat_ = 0.0F;
        }
    }
}

void TitleScene::updateStarfieldFadeInState(float delta_time) {
    temps_acumulat_ += delta_time;
    const float PROGRESS = std::min(1.0F, temps_acumulat_ / DURATION_FADE_IN);
    starfield_->setBrightness(PROGRESS * BRIGHTNESS_STARFIELD);
    if (temps_acumulat_ >= DURATION_FADE_IN) {
        current_state_ = TitleState::STARFIELD;
        temps_acumulat_ = 0.0F;
        starfield_->setBrightness(BRIGHTNESS_STARFIELD);
    }
}

void TitleScene::updateStarfieldState(float delta_time) {
    temps_acumulat_ += delta_time;
    if (temps_acumulat_ >= DURATION_INIT) {
        current_state_ = TitleState::MAIN;
        state_time_main_ = 0.0F;
        animation_active_ = false;
        lerp_factor_ = 0.0F;
    }
}

void TitleScene::updateMainState(float delta_time) {
    state_time_main_ += delta_time;

    namespace S = Defaults::Title::Sequence;
    namespace Sh = Defaults::Title::Ships;

    // Thresholds derivats de la coreografia (vegeu Defaults::Title::Sequence).
    constexpr float T_FOOTER_START = S::LOGO_ENTRY_DURATION;
    constexpr float T_COPY_START = T_FOOTER_START + S::COPYRIGHT_STAGGER;
    constexpr float T_JAILGAMES_END = T_FOOTER_START + S::JAILGAMES_ENTRY_DURATION;
    constexpr float T_COPYRIGHT_END = T_COPY_START + S::COPYRIGHT_ENTRY_DURATION;
    constexpr float T_FOOTER_END = std::max(T_JAILGAMES_END, T_COPYRIGHT_END);
    constexpr float T_SHIPS_START = T_FOOTER_END + S::SHIPS_DELAY_AFTER_FOOTER;
    constexpr float T_SHIPS_LANDED = T_SHIPS_START + Sh::ENTRY_DURATION + Sh::P2_ENTRY_DELAY;
    constexpr float T_PRESS_START_VISIBLE = T_SHIPS_LANDED + S::PRESS_START_DELAY_AFTER_SHIPS;

    intro_logo_progress_ = std::clamp(state_time_main_ / S::LOGO_ENTRY_DURATION, 0.0F, 1.0F);
    intro_jailgames_progress_ = std::clamp(
        (state_time_main_ - T_FOOTER_START) / S::JAILGAMES_ENTRY_DURATION,
        0.0F,
        1.0F);
    intro_copyright_progress_ = std::clamp(
        (state_time_main_ - T_COPY_START) / S::COPYRIGHT_ENTRY_DURATION,
        0.0F,
        1.0F);

    if (!ships_intro_launched_ && state_time_main_ >= T_SHIPS_START &&
        ship_animator_ != nullptr) {
        ship_animator_->setVisible(true);
        ship_animator_->startEntryAnimation();
        ships_intro_launched_ = true;
    }

    if (!press_start_visible_ && state_time_main_ >= T_PRESS_START_VISIBLE) {
        press_start_visible_ = true;
    }

    // L'oscil·lació suau del logo arrenca quan el logo ha aterrat. Així
    // l'amplitud creix gradualment (lerp) durant la resta de la intro.
    if (state_time_main_ < S::LOGO_ENTRY_DURATION) {
        lerp_factor_ = 0.0F;
        animation_active_ = false;
    } else if (state_time_main_ < S::LOGO_ENTRY_DURATION + DURATION_LERP) {
        lerp_factor_ = (state_time_main_ - S::LOGO_ENTRY_DURATION) / DURATION_LERP;
        animation_active_ = true;
    } else {
        lerp_factor_ = 1.0F;
        animation_active_ = true;
    }
    updateLogoAnimation(delta_time);
}

void TitleScene::updatePlayerJoinPhaseState(float delta_time) {
    temps_acumulat_ += delta_time;
    updateLogoAnimation(delta_time);

    const bool P1_ABANS = match_config_.player1_active;
    const bool P2_ABANS = match_config_.player2_active;

    if (checkStartGameButtonPressed()) {
        context_.setMatchConfig(match_config_);
        triggerExitForJoinedPlayers(P1_ABANS, P2_ABANS, "late join - ");
        Audio::get()->playSound(Defaults::Sound::START, Audio::Group::GAME);
        temps_acumulat_ = 0.0F;
    }

    if (temps_acumulat_ >= DURATION_TRANSITION) {
        current_state_ = TitleState::BLACK_SCREEN;
        temps_acumulat_ = 0.0F;
    }
}

void TitleScene::updateBlackScreenState(float delta_time) {
    temps_acumulat_ += delta_time;
    if (temps_acumulat_ >= DURATION_BLACK_SCREEN) {
        context_.setNextScene(SceneType::GAME);
    }
}

void TitleScene::advanceDive(float delta_time) {
    namespace D = Defaults::Game::Dive;
    dive_time_ += delta_time;
    // T SENSE topall: easeInQuad (=T²) fa que la posició creixi quadràticament,
    // és a dir, la velocitat creix linealment → acceleració constant que no
    // s'atura. CAMERA_DISTANCE i ZOOM_MAX deixen de ser límits: són el valor
    // assolit en l'instant de referència (T=1, quan cau la cortinilla) i a
    // partir d'ací el moviment continua accelerant (std::lerp extrapola).
    const float T = dive_time_ / D::DURATION;
    const float EASED = Easing::easeInQuad(T);

    // Càmera 3D real cap a +Z: starfield i naus es projecten amb la càmera, així
    // que les estrelles es rasguen i les naus creixen i s'escapen pels costats.
    // IMPORTANT: cal moure posició I target alhora; si només es mou la posició,
    // forward = (target - position) s'inverteix en passar el target i la càmera
    // gira cap enrere (tot apareix invertit i mirant a l'espectador).
    if (camera_ != nullptr) {
        const float Z = EASED * D::CAMERA_DISTANCE;
        camera_->setPosition(Vec3{.x = 0.0F, .y = 0.0F, .z = Z});
        camera_->setTarget(Vec3{.x = 0.0F, .y = 0.0F, .z = Z + 1.0F});
    }
    // Logo i peu són 2D: els fakegem el dive amb un zoom des del centre.
    dive_zoom_ = std::lerp(1.0F, D::ZOOM_MAX, EASED);
}

void TitleScene::updateDemoDiveState(float delta_time) {
    advanceDive(delta_time);

    // En l'instant de referència (dive_time_ == DURATION) cau la cortinilla,
    // però la càmera NO s'atura: segueix accelerant a DEMO_CURTAIN.
    if (dive_time_ >= Defaults::Game::Dive::DURATION) {
        current_state_ = TitleState::DEMO_CURTAIN;
        curtain_.cover(Defaults::Game::Curtain::COVER_DURATION);
    }
}

void TitleScene::updateDemoCurtainState(float delta_time) {
    advanceDive(delta_time);  // la càmera continua accelerant sota la tela
    curtain_.update(delta_time);
    if (curtain_.isDone()) {
        context_.setNextScene(SceneType::GAME);
    }
}

void TitleScene::handleSkipInput() {
    if (current_state_ != TitleState::STARFIELD_FADE_IN && current_state_ != TitleState::STARFIELD) {
        return;
    }
    if (!checkSkipButtonPressed()) {
        return;
    }
    current_state_ = TitleState::MAIN;
    starfield_->setBrightness(BRIGHTNESS_STARFIELD);

    // Saltar la intro coreografiada: deixar tots els elements ja in-place.
    namespace S = Defaults::Title::Sequence;
    namespace Sh = Defaults::Title::Ships;
    constexpr float T_FOOTER_END = std::max(
        S::LOGO_ENTRY_DURATION + S::JAILGAMES_ENTRY_DURATION,
        S::LOGO_ENTRY_DURATION + S::COPYRIGHT_STAGGER + S::COPYRIGHT_ENTRY_DURATION);
    constexpr float T_PRESS_START_VISIBLE = T_FOOTER_END + S::SHIPS_DELAY_AFTER_FOOTER +
        Sh::ENTRY_DURATION + Sh::P2_ENTRY_DELAY + S::PRESS_START_DELAY_AFTER_SHIPS;
    state_time_main_ = T_PRESS_START_VISIBLE;
    intro_logo_progress_ = 1.0F;
    intro_jailgames_progress_ = 1.0F;
    intro_copyright_progress_ = 1.0F;
    press_start_visible_ = true;
    ships_intro_launched_ = true;
    if (ship_animator_ != nullptr) {
        ship_animator_->setVisible(true);
        ship_animator_->startEntryAnimation();
    }
}

void TitleScene::handleStartInput() {
    if (current_state_ != TitleState::MAIN) {
        return;
    }
    // No acceptar START fins que la intro coreografiada haja conclòs i el
    // text "PRESS START TO PLAY" siga visible.
    if (!press_start_visible_) {
        return;
    }
    const bool P1_ABANS = match_config_.player1_active;
    const bool P2_ABANS = match_config_.player2_active;

    if (!checkStartGameButtonPressed()) {
        return;
    }

    if (ship_animator_ && !ship_animator_->isVisible()) {
        ship_animator_->setVisible(true);
        ship_animator_->skipToFloatingState();
    }

    context_.setMatchConfig(match_config_);
    current_state_ = TitleState::PLAYER_JOIN_PHASE;
    temps_acumulat_ = 0.0F;

    triggerExitForJoinedPlayers(P1_ABANS, P2_ABANS, "");

    Audio::get()->fadeOutMusic(MUSIC_FADE);
    Audio::get()->playSound(Defaults::Sound::START, Audio::Group::GAME);
}

void TitleScene::triggerExitForJoinedPlayers(bool p1_was_active, bool p2_was_active, const char* log_prefix) {
    if (ship_animator_ == nullptr) {
        return;
    }
    if (match_config_.player1_active && !p1_was_active) {
        ship_animator_->triggerExitAnimationForPlayer(1);
        std::cout << "[TitleScene] P1 " << log_prefix << "ship exiting\n";
    }
    if (match_config_.player2_active && !p2_was_active) {
        ship_animator_->triggerExitAnimationForPlayer(2);
        std::cout << "[TitleScene] P2 " << log_prefix << "ship exiting\n";
    }
}

void TitleScene::updateLogoAnimation(float delta_time) {
    if (!animation_active_) {
        return;
    }
    animation_time_ += delta_time * lerp_factor_;

    const float TWO_PI = 2.0F * Defaults::Math::PI;
    const float OFFSET_X = ORBIT_AMPLITUDE_X * std::sin(TWO_PI * ORBIT_FREQUENCY_X * animation_time_);
    const float OFFSET_Y = ORBIT_AMPLITUDE_Y * std::sin((TWO_PI * ORBIT_FREQUENCY_Y * animation_time_) + ORBIT_PHASE_OFFSET);

    for (std::size_t i = 0; i < letters_orni_.size(); ++i) {
        letters_orni_[i].position.x = original_positions_orni_[i].x + std::round(OFFSET_X);
        letters_orni_[i].position.y = original_positions_orni_[i].y + std::round(OFFSET_Y);
    }
    for (std::size_t i = 0; i < letters_attack_.size(); ++i) {
        letters_attack_[i].position.x = original_positions_attack_[i].x + std::round(OFFSET_X);
        letters_attack_[i].position.y = original_positions_attack_[i].y + std::round(OFFSET_Y);
    }
}

void TitleScene::draw() {
    if (starfield_ && current_state_ != TitleState::BLACK_SCREEN) {
        starfield_->draw();
    }

    if (ship_animator_ &&
        (current_state_ == TitleState::STARFIELD_FADE_IN ||
            current_state_ == TitleState::STARFIELD ||
            current_state_ == TitleState::MAIN ||
            current_state_ == TitleState::PLAYER_JOIN_PHASE ||
            current_state_ == TitleState::DEMO_DIVE ||
            current_state_ == TitleState::DEMO_CURTAIN)) {
        ship_animator_->draw();
    }
    drawFlashes();

    if (current_state_ == TitleState::STARFIELD_FADE_IN || current_state_ == TitleState::STARFIELD) {
        return;
    }

    // DEMO_DIVE/DEMO_CURTAIN es pinten com MAIN (logo + peu) perquè el títol
    // segueixi visible sota el dive i la cortinilla.
    if (current_state_ != TitleState::MAIN &&
        current_state_ != TitleState::PLAYER_JOIN_PHASE &&
        current_state_ != TitleState::DEMO_DIVE &&
        current_state_ != TitleState::DEMO_CURTAIN) {
        curtain_.draw();  // BLACK_SCREEN i altres: només la cortinilla (si activa)
        return;
    }

    // Factor d'escala+posició per simular un moviment 3D des de l'usuari (prop,
    // sprite gran i posició projectada extrema) cap al VP (lluny, sprite a la
    // seva mida i posició finals). Pivot: centre de pantalla (= projecció VP).
    // El dive (attract) hi afegeix un zoom extra (dive_zoom_) per travessar-los.
    const float LOGO_S = dive_zoom_ * std::lerp(Defaults::Title::Sequence::LOGO_INTRO_SCALE_START, 1.0F, Easing::easeOutQuad(intro_logo_progress_));
    const float SCREEN_CENTRE_X = Defaults::Game::WIDTH / 2.0F;
    const float SCREEN_CENTRE_Y = Defaults::Game::HEIGHT / 2.0F;
    const float LOGO_RENDER_SCALE = Defaults::Title::Layout::LOGO_SCALE * LOGO_S;

    if (animation_active_) {
        float temps_shadow = std::max(0.0F, animation_time_ - SHADOW_DELAY);
        const float TWO_PI = 2.0F * Defaults::Math::PI;
        const float SHADOW_OX = (ORBIT_AMPLITUDE_X * std::sin(TWO_PI * ORBIT_FREQUENCY_X * temps_shadow)) + SHADOW_OFFSET_X;
        const float SHADOW_OY = (ORBIT_AMPLITUDE_Y * std::sin((TWO_PI * ORBIT_FREQUENCY_Y * temps_shadow) + ORBIT_PHASE_OFFSET)) + SHADOW_OFFSET_Y;

        for (std::size_t i = 0; i < letters_orni_.size(); ++i) {
            const float BASE_X = original_positions_orni_[i].x + std::round(SHADOW_OX);
            const float BASE_Y = original_positions_orni_[i].y + std::round(SHADOW_OY);
            const Vec2 POS_SHADOW{
                .x = SCREEN_CENTRE_X + (LOGO_S * (BASE_X - SCREEN_CENTRE_X)),
                .y = SCREEN_CENTRE_Y + (LOGO_S * (BASE_Y - SCREEN_CENTRE_Y)),
            };
            Rendering::renderShape(sdl_.getRenderer(), letters_orni_[i].shape, POS_SHADOW, 0.0F, LOGO_RENDER_SCALE, 1.0F, SHADOW_BRIGHTNESS, Defaults::Title::Colors::LOGO_SHADOW);
        }
        for (std::size_t i = 0; i < letters_attack_.size(); ++i) {
            const float BASE_X = original_positions_attack_[i].x + std::round(SHADOW_OX);
            const float BASE_Y = original_positions_attack_[i].y + std::round(SHADOW_OY);
            const Vec2 POS_SHADOW{
                .x = SCREEN_CENTRE_X + (LOGO_S * (BASE_X - SCREEN_CENTRE_X)),
                .y = SCREEN_CENTRE_Y + (LOGO_S * (BASE_Y - SCREEN_CENTRE_Y)),
            };
            Rendering::renderShape(sdl_.getRenderer(), letters_attack_[i].shape, POS_SHADOW, 0.0F, LOGO_RENDER_SCALE, 1.0F, SHADOW_BRIGHTNESS, Defaults::Title::Colors::LOGO_SHADOW);
        }
    }

    for (const auto& letter : letters_orni_) {
        const Vec2 POS{
            .x = SCREEN_CENTRE_X + (LOGO_S * (letter.position.x - SCREEN_CENTRE_X)),
            .y = SCREEN_CENTRE_Y + (LOGO_S * (letter.position.y - SCREEN_CENTRE_Y)),
        };
        Rendering::renderShape(sdl_.getRenderer(), letter.shape, POS, 0.0F, LOGO_RENDER_SCALE, 1.0F, 1.0F, Defaults::Title::Colors::LOGO_MAIN);
    }
    for (const auto& letter : letters_attack_) {
        const Vec2 POS{
            .x = SCREEN_CENTRE_X + (LOGO_S * (letter.position.x - SCREEN_CENTRE_X)),
            .y = SCREEN_CENTRE_Y + (LOGO_S * (letter.position.y - SCREEN_CENTRE_Y)),
        };
        Rendering::renderShape(sdl_.getRenderer(), letter.shape, POS, 0.0F, LOGO_RENDER_SCALE, 1.0F, 1.0F, Defaults::Title::Colors::LOGO_MAIN);
    }

    const float SPACING = Defaults::Title::Layout::TEXT_SPACING;

    if (press_start_visible_) {
        bool mostrar_text = true;
        if (current_state_ == TitleState::PLAYER_JOIN_PHASE) {
            const float FASE = temps_acumulat_ * BLINK_FREQUENCY * 2.0F * std::numbers::pi_v<float>;
            mostrar_text = (std::sin(FASE) > 0.0F);
        }
        if (mostrar_text) {
            const std::string MAIN_TEXT = Locale::get().text("title.press_start");
            const float MAIN_SCALE = Defaults::Title::Layout::PRESS_START_SCALE;
            const float CENTRE_X = Defaults::Game::WIDTH / 2.0F;
            const float CENTRE_Y = Defaults::Game::HEIGHT * Defaults::Title::Layout::PRESS_START_POS;
            text_.renderCentered(MAIN_TEXT, {.x = CENTRE_X, .y = CENTRE_Y}, MAIN_SCALE, SPACING, 1.0F, Defaults::Title::Colors::PRESS_START);
        }
    }

    dibuixarPeuTitol(SPACING);

    // Cortinilla negra (attract): per damunt de tot. No-op si no està activa.
    curtain_.draw();
}

auto TitleScene::checkSkipButtonPressed() -> bool {
    return Input::get()->checkAnyPlayerAction(ARCADE_BUTTONS);
}

auto TitleScene::checkStartGameButtonPressed() -> bool {
    auto* input = Input::get();
    bool any_pressed = false;
    for (auto action : START_GAME_BUTTONS) {
        if (input->checkActionPlayer1(action, Input::DO_NOT_ALLOW_REPEAT)) {
            if (!match_config_.player1_active) {
                match_config_.player1_active = true;
                any_pressed = true;
            }
        }
        if (input->checkActionPlayer2(action, Input::DO_NOT_ALLOW_REPEAT)) {
            if (!match_config_.player2_active) {
                match_config_.player2_active = true;
                any_pressed = true;
            }
        }
    }
    return any_pressed;
}

void TitleScene::handleEvent(const SDL_Event& event) {
    (void)event;
}

namespace {
    constexpr float FLASH_DURATION = 0.40F;
    constexpr float FLASH_MAX_SCALE = 2.5F;
    constexpr SDL_Color FLASH_COLOR = {.r = 255, .g = 255, .b = 255, .a = 255};
}  // namespace

void TitleScene::triggerFlash(Vec2 pos) {
    for (auto& f : flashes_) {
        if (!f.active) {
            f.active = true;
            f.position = pos;
            f.timer = 0.0F;
            return;
        }
    }
}

void TitleScene::updateFlashes(float delta_time) {
    for (auto& f : flashes_) {
        if (!f.active) {
            continue;
        }
        f.timer += delta_time;
        if (f.timer >= FLASH_DURATION) {
            f.active = false;
        }
    }
}

void TitleScene::drawFlashes() {
    if (!flash_shape_) {
        return;
    }
    for (const auto& f : flashes_) {
        if (!f.active) {
            continue;
        }
        // Escala 0 → max al midpoint → 0. Sinus simètric.
        const float T_NORM = f.timer / FLASH_DURATION;
        const float SCALE = FLASH_MAX_SCALE * std::sin(T_NORM * Defaults::Math::PI);
        Rendering::renderShape(sdl_.getRenderer(), flash_shape_, f.position, 0.0F, SCALE, 1.0F, 1.0F, FLASH_COLOR);
    }
}
