#!/usr/bin/env python3
"""
Boot-/Intro-Sequenz von "Tony & Friends in Kellogg's Land" -- erster
spielbarer (bzw. hier: zuschaubarer) pygame-Meilenstein des Remakes.

Reihenfolge + Timing wurde am 22.07.2026 durch eine echte DOSBox-Screenshot-
Serie vermessen, siehe NOTES.md Abschnitt "Boot-Sequenz vermessen". Kurzfassung:

  1. Rauser-Advertainment-Karte (Komposit aus RAUSER1+2+3.PCC) auf WEISSEM
     Grund. RAUSER1 ("RAUSER"-Box) faehrt von LINKS ein, RAUSER3 (blaue Box
     mit "!") faehrt von OBEN ein, RAUSER2 ("ADVERTAINMENT") steht von Anfang
     an fest. Sobald beide Teile ihre Endposition erreicht haben, spielt ein
     kurzer Sound-Sting (TITEL2-Song 0 -- 2.04s natuerliche Laenge, siehe
     tfmx_audio-Docstring zu den mehreren Songs pro Modul). Danach faedet der
     WEISSE Hintergrund kurz zu GRAU um, haelt kurz, dann schwarze Pause.
     KORREKTUR-GESCHICHTE (Aufloesungs-Bug, zwei Anlaeufe):
       v1: Rauser-Assets bei 0.5x per smoothscale verkleinert -- Ergebnis war
           verwaschen/unscharf (Stefans erste Meldung "Aufloesung ist
           schlecht").
       v2 (falscher Fix): Annahme war, die Verkleinerung selbst sei der
           Fehler -- also ASSET_DISPLAY_SCALE auf 1.0 (native Groesse ohne
           jede Skalierung) gesetzt. Sah in DIESEM Container scharf aus, war
           aber ein NEUER Bug: bei nativer Groesse (RAUSER1+RAUSER3 = 198px
           Breite) ist das Logo auf der 320px-Leinwand riesig -- Stefan hat
           nie "zu gross" gemeldet, weil das schon in v1 unter dem Verwaschen
           unterging.
       v3 (echter Fix, 22.07.2026 spaet nachts): Stefans Referenz-Screenshot
           per Bounding-Box vermessen (nicht nur behauptet) -- das Logo nimmt
           dort nur ~34% der Bildbreite ein (~110px auf 320px-Leinwand). Die
           urspruengliche 0.5x-GROESSE war also die ganze Zeit richtig
           (182+16=198px * 0.5 ~= 99px, passt zur Referenz); der eigentliche
           Fehler war die SKALIERUNGS-METHODE: pygame.transform.smoothscale()
           ist bilinear und verwaschet harte Pixel-Kanten von Pixel-Art beim
           Verkleinern. Fix: ASSET_DISPLAY_SCALE bleibt 0.5, aber
           pygame.transform.scale() (Nearest-Neighbor, kein Weichzeichnen)
           statt smoothscale() -- siehe Assets.pcc_surface_scaled(). Per
           Bounding-Box-Nachmessung des eigenen Renders bestaetigt: 33.0%
           Breite / 13.6% Hoehe vs. Referenz 34.5% / 16.6% -- nah dran, nicht
           mehr der 62%-Breite-Fehlgriff von v2.
  2. kurze schwarze Pause
  3. Factor5-Studio-Logo (FACTOR5.PCC, STATISCHES Bild -- keine Animation,
     das "Geister"-Doppellogo ist Teil des Assets selbst). KORREKTUR
     (22.07.2026, spaeter Abend -- Stefans Meldung: "beim factor 5 hast ein
     Level-Sound genommen ... der factor 5 Sound ist so ein Brausen, geht nur
     ein bisschen laenger als der Rauser-Sound, das Bild wird ca. 5 Sekunden
     eingeblendet"): die vorige Version hat faelschlich ONGAME2-Song 0
     (~123s!) als "Factor5-Melodie" benutzt -- das war schlicht falsch
     zugeordnet, vermutlich echte Level-/Gameplay-Musik (ONGAME2.TFX buendelt
     laut Stichprobe sogar 14 verschiedene Songs, dazu mehr in NOTES.md).
     Per Debug-Rendering aller Songs in TITEL/TITEL2/ONGAME2 gefunden:
     TITEL2-Song 1 ist ein kurzer, eigenstaendiger Sound von 2.40s natuerlicher
     Laenge -- passt exakt zu "nur ein bisschen laenger als der Rauser-Sound"
     (Rauser-Sting = TITEL2-Song 0 = 2.04s). Das ist jetzt der Factor5-Sound.
     WICHTIG (ehrlich, nicht nur behauptet): ich kann Audio nicht selbst
     abhoeren, die Zuordnung ist eine Indizien-Schlussfolgerung aus der
     Songlaenge (passt sehr gut), nicht durch Reinhoeren verifiziert -- falls
     es beim Testen doch nicht "brausen" klingt, bitte melden, dann probieren
     wir TITEL-Song 2 (~8.0s) oder einen der kurzen ONGAME2-Songs (3/11/12).
     Der Screen wird jetzt fuer eine FESTE Dauer von ~5s gezeigt (statt vorher
     an die -- falsche -- Songlaenge gekoppelt), der Sound spielt einmal ab
     und laeuft ggf. vor Screen-Ende aus (er ist ja nur 2.4s lang).
  4. kurze schwarze Pause
  5. Kellogg's-Markenlogo (KELLOGGS.PCC) erscheint SOFORT komplett; darunter
     faedet der Text "praesentiert" per Palette-Fade von blass nach saettigt-
     rot ein (~3s).
  6. kurze schwarze Pause
  7. Titelbild "TONY & FRIENDS in Kellogg's Land" -- die 4 Quadranten KELL256A
     (oben links) / B (oben rechts) / C (unten links) / D (unten rechts) sind
     je 160x240 PCC-Nativaufloesung, werden aber nur bei **halber Groesse**
     (160x120) in ein 2x2-Raster gezeichnet: A|B oben, C|D unten -> 320x240
     Gesamtbild. Per Pixel-Rekonstruktion gegen eine echte DOSBox-Aufnahme
     verifiziert. Reveal-Effekt: obere Haelfte (A|B) waechst von oben (Hoehe
     0->120), untere Haelfte (C|D) waechst von unten (Hoehe 0->120) -- treffen
     sich in der Mitte. Waehrend dieser Reveal-Phase ist die untere Haelfte
     erst graustichig/entsaettigt MIT Scanlines ueberzogen, die sich danach in
     einer zweiten Phase zu vollen Farben aufloesen (Stefans "Screenshot
     4/5/6"-Effekt). HINWEIS: diese Quadranten nutzen denselben
     Downscale-Mechanismus (smoothscale halbe Hoehe) wie die Rauser-Assets
     vor dem obigen Fix -- hier bisher KEINE Bildschaerfe-Beschwerde von
     Stefan, deshalb unangetastet gelassen. Falls das Titelbild auch mal als
     "verwaschen" gemeldet wird: gleicher Fix-Ansatz (native Groesse zeichnen,
     nur einmal ganzzahlig hochskalieren) anwendbar.

Alle Nicht-BOB/ICO/MAP-Assets werden per kellogg_formats.load_pcc() (Pillows
PCX-Decoder) aus PCKELL.DAT gelesen -- kein eigener RLE-Code mehr (siehe
Regressions-Warnung in pcc_to_png.py).

Rauser-Layout und Fade-Text-Bounding-Box sind aus den echten Screenshots per
Augenmass/Index-Analyse bestimmt (siehe tools/analyze_kelloggs.py-Fund:
Indizes 34/42/43/44/45 in KELLOGGS.PCC = "praesentiert"-Text, bbox x71-241
y108-141) -- nicht 100% pixelidentisch zum Original, aber nah dran. Kann bei
Bedarf spaeter nachjustiert werden.

HANG-FIX (23.07.2026 -- Stefans Meldung "nur noch ein schwarzes Bild,
verschiebe ich das Fenster wird der Desktop reingerendert, das Programm
haengt"): war real und per Xvfb-Test verifiziert, kein Bedienfehler. Ursache:
tfmx_audio.render_wav() rendert TFMX-Musik per BLOCKIERENDEM subprocess.run()
-- bei einem frischen, ungecachten Song (v.a. TITEL, ~109s) dauert das
reproduzierbar mehrere -zig Sekunden (gemessen: ~14s), waehrend derer die
pygame-Eventloop NICHT lief -- das Betriebssystem haelt das Fenster dann fuer
"nicht reagierend" (schwarz eingefroren, Desktop blitzt beim Verschieben
durch). Fix: alle drei benoetigten Songs werden jetzt VOR der eigentlichen
Sequenz einmalig in preload_all_audio() gerendert, mit sichtbarem, responsivem
Ladebildschirm (render_audio_with_loading() -- Rendering laeuft in einem
Thread, Hauptthread pumpt weiter Events). run_once() greift danach nur noch
auf fertige WAV-Pfade zu, nie mehr Live-Rendering im Hauptloop.

Aufruf: python3 intro_sequence.py [--once]  (--once: nach einem Durchlauf beenden,
sonst loopt die Sequenz weiter wie im Original-Titelbildschirm)
"""
import sys
import os
import time
import threading
from collections import deque

sys.path.insert(0, os.path.dirname(__file__))
from kellogg_formats import DATContainer, load_pcc
import tfmx_audio

import pygame

try:
    import numpy as np
    HAVE_NUMPY = True
except ImportError:
    HAVE_NUMPY = False

PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DAT_PATH = os.environ.get('KELLOGG_DAT', os.path.join(PROJECT_ROOT, 'raw', 'PCKELL.DAT'))

WINDOW_W, WINDOW_H = 960, 720
BG_COLOR = (0, 0, 0)

# "praesentiert"-Text-Faelle in KELLOGGS.PCC (siehe Docstring)
PRAESENTIERT_FADE_INDICES = [34, 42, 43, 44, 45]
PRAESENTIERT_FADE_SECONDS = 2.5

# KORREKTUR #2 (22.07.2026 spaet nachts, Stefans Referenz-Screenshot
# vermessen): 1.0 (native Groesse) war KEIN Aufloesungs-Fix, sondern ein
# neuer Bug -- ich hatte die Blur-Ursache falsch diagnostiziert. Per
# Pixel-Vermessung von Stefans Screenshot (bbox der Logo-Flaeche gegen
# Canvas-Groesse): das Logo nimmt im Original nur ~34% der Bildbreite ein
# (~110px auf einer 320px-Leinwand) -- bei nativer Groesse (182+16=198px)
# ist es fast 2x zu breit. Die vorige 0.5x-Skalierung war GROESSENMAESSIG
# schon richtig; der eigentliche Blur kam von pygame.transform.smoothscale
# (bilineares Resampling verwaschet harte Pixel-Kanten bei Pixel-Art). Fix:
# 0.5x beibehalten, aber mit pygame.transform.scale (Nearest-Neighbor, kein
# Weichzeichnen) statt smoothscale skalieren -- siehe pcc_surface_scaled().
ASSET_DISPLAY_SCALE = 0.5

# Rauser-Timing (siehe Docstring)
RAUSER_SLIDE_SECONDS = 0.9
RAUSER_HOLD_SECONDS = 1.3
RAUSER_FADE_TO_GREY_SECONDS = 0.4
RAUSER_GREY_HOLD_SECONDS = 0.3
RAUSER_GREY = (73, 73, 73)
RAUSER_WHITE = (255, 255, 255)

# Factor5-Timing (siehe Docstring-Korrektur -- Stefans Meldung 22.07.2026
# abends): feste Anzeigedauer statt (falsch) an eine 123s-Melodie gekoppelt.
FACTOR5_HOLD_SECONDS = 5.0

# Titelbild-Timing (siehe Docstring)
TITLE_HEIGHT_REVEAL_FRACTION = 0.55  # Anteil der Wipe-Dauer fuer das Hoehenwachstum
TITLE_WIPE_SECONDS = 2.3


def indices_to_surface(width, height, indices, palette):
    '''Baut ein pygame.Surface (RGB) aus Palette-Indizes + (r,g,b)-Liste.'''
    buf = bytearray(width * height * 3)
    for i, idx in enumerate(indices):
        r, g, b = palette[idx & 0xFF]
        buf[i*3:i*3+3] = bytes([r, g, b])
    surf = pygame.image.frombuffer(bytes(buf), (width, height), 'RGB')
    return surf.convert()


def make_border_bg_transparent(surface, threshold=235):
    '''Fix fuer Stefans Meldung (22.07.2026 abends): "der Rauser-Text fadet
    nicht mit aus, bleibt hart umrandet". Ursache: die Rauser-PCC-Assets haben
    KEINEN Alphakanal -- ihr papierweisser Hintergrund sitzt als hartes
    Rechteck um die eigentliche Box-Grafik (RAUSER-Schriftzug/ADVERTAINMENT/
    "!"-Box). Auf weissem Bildhintergrund faellt das nicht auf, aber sobald
    die Szene zu Grau ueberblendet, bleibt dieses Rechteck stur weiss stehen
    statt mit auszufaden. Ein v1-Versuch mit set_colorkey() wurde verworfen,
    weil er auch die WEISSEN BUCHSTABEN in RAUSER1 durchsichtig machte.

    Fix: Flood-Fill vom Bildrand aus ueber alle "papierweissen" Pixel (alle
    Kanaele >= threshold) -- NUR die vom Rand aus zusammenhaengende Flaeche
    wird transparent gemacht. Isolierte weisse Pixel MITTEN im Motiv (z.B.
    weisse Buchstaben im RAUSER-Schriftzug) beruehren den Bildrand nicht und
    bleiben deshalb unangetastet. Ergebnis: eine Surface mit echtem
    Alphakanal, die beim Blit auf einen sich veraendernden Hintergrund
    (weiss->grau) sauber durchscheint, statt eine harte Kante zu zeigen.'''
    w, h = surface.get_size()
    surf = surface.convert_alpha()
    px = pygame.PixelArray(surf)

    def is_bg(x, y):
        color = surf.unmap_rgb(px[x, y])
        return color.r >= threshold and color.g >= threshold and color.b >= threshold

    seen = bytearray(w * h)
    dq = deque()

    def consider(x, y):
        if not seen[y * w + x] and is_bg(x, y):
            seen[y * w + x] = 1
            dq.append((x, y))

    for x in range(w):
        consider(x, 0)
        consider(x, h - 1)
    for y in range(h):
        consider(0, y)
        consider(w - 1, y)

    while dq:
        cx, cy = dq.popleft()
        if cx + 1 < w:
            consider(cx + 1, cy)
        if cx - 1 >= 0:
            consider(cx - 1, cy)
        if cy + 1 < h:
            consider(cx, cy + 1)
        if cy - 1 >= 0:
            consider(cx, cy - 1)
    del px

    alpha = pygame.surfarray.pixels_alpha(surf)
    for x in range(w):
        for y in range(h):
            if seen[y * w + x]:
                alpha[x, y] = 0
    del alpha
    return surf


class Assets:
    def __init__(self, dat_path):
        self.container = DATContainer(dat_path)

    def pcc(self, name):
        '''name z.B. "RAUSER1" (ohne .PCC). Gibt (w,h,indices,palette) zurueck.'''
        return load_pcc(self.container.entries[name + '.PCC'])

    def pcc_surface(self, name):
        w, h, indices, palette = self.pcc(name)
        return indices_to_surface(w, h, indices, palette)

    def pcc_surface_scaled(self, name, scale=ASSET_DISPLAY_SCALE, cut_border_bg=False):
        '''Skaliert BEIDE Achsen um scale. KORREKTUR #2 (22.07.2026 spaet
        nachts, siehe Docstring-Korrektur ganz oben): scale ist wieder 0.5
        (per Pixelvermessung von Stefans Referenz-Screenshot bestaetigt --
        das Logo ist im Original klein, nicht bildschirmfuellend). Der
        eigentliche Blur-Fix ist pygame.transform.scale statt smoothscale:
        scale() ist Nearest-Neighbor (keine Weichzeichnung), smoothscale()
        war bilinear und hat bei 0.5x harte Pixel-Kanten der Pixel-Art
        verwaschen -- DAS war Stefans "Aufloesung ist schlecht"-Bug, nicht
        die Groesse selbst.
        cut_border_bg=True macht den vom Bildrand aus erreichbaren papierweissen
        Hintergrund transparent (siehe make_border_bg_transparent) -- fuer die
        Rauser-Teile, damit sie beim Fade zu Grau nicht als harte weisse
        Kaesten stehen bleiben.'''
        surf = self.pcc_surface(name)
        if cut_border_bg:
            surf = make_border_bg_transparent(surf)
        if scale == 1.0:
            return surf
        w, h = surf.get_size()
        new_size = (max(1, round(w * scale)), max(1, round(h * scale)))
        return pygame.transform.scale(surf, new_size)

    def pcc_surface_half_height(self, name):
        '''Skaliert NUR die Hoehe um 0.5, Breite bleibt nativ -- fuer die
        Titelbild-Quadranten KELL256A/B/C/D (160x240 -> 160x120). ACHTUNG:
        das ist KEINE gleichmaessige Skalierung wie bei den Rauser-Assets --
        per Pixel-Rekonstruktion gegen eine echte DOSBox-Aufnahme verifiziert
        (siehe Docstring ganz oben). Nearest-Neighbor (pygame.transform.scale)
        statt smoothscale aus dem gleichen Grund wie bei den Rauser-Assets
        (siehe pcc_surface_scaled) -- konsistent scharf.'''
        surf = self.pcc_surface(name)
        w, h = surf.get_size()
        new_size = (w, max(1, round(h * 0.5)))
        return pygame.transform.scale(surf, new_size)


def fit_scale(src_w, src_h, max_w, max_h):
    return min(max_w / src_w, max_h / src_h)


def blit_scaled_centered(screen, surface, window_w, window_h, bg_color=BG_COLOR):
    scale = fit_scale(surface.get_width(), surface.get_height(), window_w, window_h)
    new_size = (max(1, int(surface.get_width() * scale)), max(1, int(surface.get_height() * scale)))
    scaled = pygame.transform.scale(surface, new_size)
    x = (window_w - new_size[0]) // 2
    y = (window_h - new_size[1]) // 2
    screen.fill(bg_color)
    screen.blit(scaled, (x, y))
    return scale, x, y


def wait_or_skip(clock, seconds, screen_update_fn, fps=30):
    '''Laeuft seconds lang, ruft pro Frame screen_update_fn(t_norm 0..1) auf.
    Bricht per ESC/Klick vorzeitig ab (gibt True zurueck wenn User quit will).'''
    start = time.monotonic()
    while True:
        now = time.monotonic()
        t = (now - start) / seconds if seconds > 0 else 1.0
        if t >= 1.0:
            screen_update_fn(1.0)
            pygame.display.flip()
            return False
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                return True
            if event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE:
                return True
        screen_update_fn(t)
        pygame.display.flip()
        clock.tick(fps)


def render_audio_with_loading(screen, clock, container, name, song_index, label):
    '''Rendert einen TFMX-Song per tfmx_audio.render_wav in einem Hintergrund-
    Thread, waehrend der Hauptthread weiter Events pumpt und einen simplen
    Ladebildschirm zeichnet.

    FIX (23.07.2026 -- Stefans Meldung "kommt nur noch ein schwarzes Bild,
    verschiebe ich das Fenster wird der Desktop reingerendert, das Programm
    haengt"): Ursache gefunden und per Test verifiziert, nicht nur vermutet --
    render_wav() ruft render_tfmx als BLOCKIERENDEN subprocess.run() auf, ohne
    dass die pygame-Eventloop weiterlaeuft. Bei einem frischen, noch nicht
    gecachten Song (v.a. TITEL, ~109s Musik) dauert dieser eine Call
    reproduzierbar mehrere -zig Sekunden (per Xvfb-Testlauf auf aria-wohnung
    gemessen: ~14s Totalblockade direkt nach Factor5, bevor das Kellogg's-Logo
    kommt). Ohne pygame.event.get()/display.flip() waehrend dieser Zeit stuft
    das Betriebssystem das Fenster als "nicht reagierend" ein -- exakt das
    beschriebene Bild (Inhalt bleibt schwarz stehen, beim Verschieben des
    Fensters blitzt der darunterliegende Desktop durch, weil nichts neu
    gezeichnet wird).

    Fix: render_wav laeuft in einem Thread; der Haupt-Thread bleibt die ganze
    Zeit responsiv (Events pumpen, Ladehinweis zeichnen, display.flip()) --
    egal wie lange das Rendering dauert (auch beim allerersten Start ohne
    Cache oder wenn build.sh den render_tfmx-Binary neu kompilieren muss).

    Gibt (wav_path_or_None, quit_requested) zurueck. wav_path ist None wenn
    das Rendering fehlschlaegt (z.B. kein C-Compiler verfuegbar) -- die
    Sequenz laeuft dann wie bisher an dieser Stelle stumm weiter, kein
    Absturz.'''
    result = {}

    def worker():
        try:
            result['path'] = tfmx_audio.render_wav(container, name, song_index=song_index)
        except Exception as exc:
            result['error'] = exc

    thread = threading.Thread(target=worker, daemon=True)
    thread.start()

    try:
        font = pygame.font.SysFont(None, 28)
    except Exception:
        font = None

    dots = 0
    last_dot_tick = time.monotonic()
    while thread.is_alive():
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                return None, True
            if event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE:
                return None, True
        now = time.monotonic()
        if now - last_dot_tick > 0.4:
            dots = (dots + 1) % 4
            last_dot_tick = now
        screen.fill(BG_COLOR)
        if font is not None:
            text = font.render(f'Lade {label}{"." * dots}', True, (200, 200, 200))
            screen.blit(text, (WINDOW_W // 2 - text.get_width() // 2, WINDOW_H // 2 - text.get_height() // 2))
        pygame.display.flip()
        clock.tick(30)

    thread.join()
    if 'error' in result:
        print(f'[intro_sequence] {label} konnte nicht gerendert werden: {result["error"]}', file=sys.stderr)
        return None, False
    return result.get('path'), False


def preload_all_audio(screen, clock, assets):
    '''Rendert VOR der eigentlichen Sequenz alle drei benoetigten TFMX-Songs
    einmalig (siehe render_audio_with_loading-Docstring) -- danach ist jeder
    run_once()-Durchlauf (auch der allererste!) garantiert freeze-frei, weil
    zur Laufzeit nur noch aus dem WAV-Cache abgespielt wird statt live zu
    rendern. Ueberspringt das Preloading komplett, wenn gar kein Audio-Device
    verfuegbar ist (dann bliebe es sowieso stumm, siehe main()).'''
    if not pygame.mixer.get_init():
        return {}, False
    jobs = [
        (('TITEL2', 0), 'Rauser-Sound'),
        (('TITEL2', 1), 'Factor5-Sound'),
        (('TITEL', None), 'Titelmusik'),
    ]
    audio_paths = {}
    for key, label in jobs:
        name, song_index = key
        path, quit_requested = render_audio_with_loading(screen, clock, assets.container, name, song_index, label)
        audio_paths[key] = path
        if quit_requested:
            return audio_paths, True
    return audio_paths, False


def build_letterboxed(surface, canvas_w=320, canvas_h=240):
    '''Zentriert ein kleineres PCC-Bild (z.B. 320x199/200) auf eine 320x240-
    Leinwand mit schwarzen Balken -- wie im echten DOSBox-Fenster beobachtet.'''
    canvas = pygame.Surface((canvas_w, canvas_h))
    canvas.fill((0, 0, 0))
    x = (canvas_w - surface.get_width()) // 2
    y = (canvas_h - surface.get_height()) // 2
    canvas.blit(surface, (x, y))
    return canvas


def build_kelloggs_frame(w, h, indices, palette, fade_t):
    '''fade_t: 0..1, faedet PRAESENTIERT_FADE_INDICES von blass (Richtung
    Hintergrundweiss) zu ihrer echten Palettenfarbe.'''
    dyn_pal = list(palette)
    for idx in PRAESENTIERT_FADE_INDICES:
        r, g, b = palette[idx]
        # start: sehr blass (nah am weissen Logo-Hintergrund), Ende: echte Farbe
        pale = (255, 245, 245)
        nr = int(pale[0] + (r - pale[0]) * fade_t)
        ng = int(pale[1] + (g - pale[1]) * fade_t)
        nb = int(pale[2] + (b - pale[2]) * fade_t)
        dyn_pal[idx] = (nr, ng, nb)
    return indices_to_surface(w, h, indices, dyn_pal)


# ---------------------------------------------------------------------------
# Rauser: Slide-in-Animation + Sound-Sting (siehe Docstring-Korrektur)
# ---------------------------------------------------------------------------

def build_rauser_parts(assets):
    '''Liefert die drei (bei 0.5x Nearest-Neighbor-Groesse, siehe Docstring-
    Korrektur v3) Rauser-Teile + ihre Endpositionen auf einer 320x240-
    Leinwand (RAUSER1 links, RAUSER3 rechts daneben, RAUSER2 darunter
    mittig).'''
    # cut_border_bg=True (Fix 22.07.2026 abends): macht den papierweissen
    # Hintergrund um jede Box transparent (nur die vom Bildrand aus
    # zusammenhaengende Flaeche, siehe make_border_bg_transparent) -- vorher
    # blieb hier ein harter weisser Kasten stehen, der beim Fade zu Grau nicht
    # mitgefadet ist (Stefans Meldung "Text bleibt hart umrandet stehen").
    r1 = assets.pcc_surface_scaled('RAUSER1', cut_border_bg=True)  # "RAUSER"-Box
    r2 = assets.pcc_surface_scaled('RAUSER2', cut_border_bg=True)  # "ADVERTAINMENT"
    r3 = assets.pcc_surface_scaled('RAUSER3', cut_border_bg=True)  # "!" auf blauer Box

    total_w = r1.get_width() + r3.get_width()
    total_h = r1.get_height() + r2.get_height()
    x0 = (320 - total_w) // 2
    y0 = (240 - total_h) // 2

    r1_final = (x0, y0)
    r3_final = (x0 + r1.get_width(), y0 - (r3.get_height() - r1.get_height()) // 2)
    r2_final = (x0, y0 + r1.get_height())
    return {
        'r1': r1, 'r2': r2, 'r3': r3,
        'r1_final': r1_final, 'r2_final': r2_final, 'r3_final': r3_final,
    }


def render_rauser_slide(canvas, parts, slide_t):
    '''slide_t: 0..1. r1 faehrt von links, r3 von oben ein; r2 steht fest.
    Bei slide_t>=1 sind alle Teile an ihrer Endposition.'''
    r1, r2, r3 = parts['r1'], parts['r2'], parts['r3']
    ease = slide_t * slide_t * (3 - 2 * slide_t)  # smoothstep, wirkt weniger linear/robotisch

    r2_final = parts['r2_final']
    canvas.blit(r2, r2_final)

    r1_final_x, r1_final_y = parts['r1_final']
    r1_start_x = -r1.get_width()
    r1_x = int(r1_start_x + (r1_final_x - r1_start_x) * ease)
    canvas.blit(r1, (r1_x, r1_final_y))

    r3_final_x, r3_final_y = parts['r3_final']
    r3_start_y = -r3.get_height()
    r3_y = int(r3_start_y + (r3_final_y - r3_start_y) * ease)
    canvas.blit(r3, (r3_final_x, r3_y))


def run_rauser(screen, clock, assets, audio_paths):
    '''Rauser-Karte: weisser Grund, Slide-in, Sound-Sting bei Ankunft, Fade zu
    grauem Grund, kurz halten. Gibt True zurueck wenn der User abbrechen will.
    audio_paths kommt aus preload_all_audio() -- das Audio wird hier NICHT
    mehr live gerendert (siehe render_audio_with_loading-Docstring zum
    Hang-Bug, den das behebt), sondern nur noch aus dem fertigen WAV-Pfad
    geladen (schnell, nie blockierend).'''
    parts = build_rauser_parts(assets)
    sting = None
    # TITEL2 Song 0 -- der kuerzere der beiden TITEL2-Songs (~2.04s), Song 1
    # ist der Factor5-Sound (siehe Docstring).
    sting_path = audio_paths.get(('TITEL2', 0))
    if pygame.mixer.get_init() and sting_path:
        try:
            sting = pygame.mixer.Sound(sting_path)
        except Exception as exc:
            print(f'[intro_sequence] Rauser-Sound-Sting konnte nicht geladen werden: {exc}', file=sys.stderr)

    sting_played = False

    def show_slide(t):
        nonlocal sting_played
        canvas = pygame.Surface((320, 240))
        canvas.fill(RAUSER_WHITE)
        render_rauser_slide(canvas, parts, t)
        if t >= 1.0 and not sting_played:
            sting_played = True
            if sting is not None:
                sting.play()
        blit_scaled_centered(screen, canvas, WINDOW_W, WINDOW_H, bg_color=RAUSER_WHITE)
    quit_requested = wait_or_skip(clock, RAUSER_SLIDE_SECONDS, show_slide)
    if quit_requested:
        return True

    # falls die Slide-Phase zu kurz war um sting_played zu triggern (sollte
    # durch t>=1.0 im letzten Frame von wait_or_skip immer der Fall sein)
    if not sting_played and sting is not None:
        sting.play()
        sting_played = True

    def show_hold_white(_t):
        canvas = pygame.Surface((320, 240))
        canvas.fill(RAUSER_WHITE)
        render_rauser_slide(canvas, parts, 1.0)
        blit_scaled_centered(screen, canvas, WINDOW_W, WINDOW_H, bg_color=RAUSER_WHITE)
    quit_requested = wait_or_skip(clock, RAUSER_HOLD_SECONDS, show_hold_white)
    if quit_requested:
        return True

    def show_fade_grey(t):
        bg = tuple(int(RAUSER_WHITE[i] + (RAUSER_GREY[i] - RAUSER_WHITE[i]) * t) for i in range(3))
        canvas = pygame.Surface((320, 240))
        canvas.fill(bg)
        render_rauser_slide(canvas, parts, 1.0)
        blit_scaled_centered(screen, canvas, WINDOW_W, WINDOW_H, bg_color=bg)
    quit_requested = wait_or_skip(clock, RAUSER_FADE_TO_GREY_SECONDS, show_fade_grey)
    if quit_requested:
        return True

    def show_hold_grey(_t):
        canvas = pygame.Surface((320, 240))
        canvas.fill(RAUSER_GREY)
        render_rauser_slide(canvas, parts, 1.0)
        blit_scaled_centered(screen, canvas, WINDOW_W, WINDOW_H, bg_color=RAUSER_GREY)
    return wait_or_skip(clock, RAUSER_GREY_HOLD_SECONDS, show_hold_grey)


# ---------------------------------------------------------------------------
# Titelbild: 2x2-Raster aus halbskalierten Quadranten + Scanline-Reveal
# (siehe Docstring-Korrektur -- NICHT 320x480!)
# ---------------------------------------------------------------------------

def build_title_quadrants(assets):
    '''Liefert die 4 Titelbild-Quadranten bei halber Hoehe (160x120 statt
    160x240 nativ) -- A/B oben, C/D unten. Per Pixel-Rekonstruktion gegen
    eine echte DOSBox-Aufnahme verifiziert.'''
    return {
        'A': assets.pcc_surface_half_height('KELL256A'),
        'B': assets.pcc_surface_half_height('KELL256B'),
        'C': assets.pcc_surface_half_height('KELL256C'),
        'D': assets.pcc_surface_half_height('KELL256D'),
    }


def build_title_image(quads):
    '''Komplettes, ruhendes Titelbild (320x240) aus den 4 halbskalierten
    Quadranten im 2x2-Raster.'''
    a, b, c, d = quads['A'], quads['B'], quads['C'], quads['D']
    w = a.get_width() + b.get_width()
    h = a.get_height() + c.get_height()
    canvas = pygame.Surface((w, h))
    canvas.blit(a, (0, 0))
    canvas.blit(b, (a.get_width(), 0))
    canvas.blit(c, (0, a.get_height()))
    canvas.blit(d, (a.get_width(), a.get_height()))
    return canvas


def _scanline_desaturate(surface, intensity):
    '''intensity 0..1: 0 = unveraendert, 1 = komplett entsaettigt + jede
    zweite Zeile abgedunkelt (Stefans "Scanlines drueber"-Beobachtung waehrend
    des Titelbild-Reveals, siehe Docstring). Braucht numpy; ohne numpy wird
    intensity ignoriert (Bild bleibt scharf) statt abzustuerzen.'''
    if intensity <= 0 or not HAVE_NUMPY:
        return surface
    arr = pygame.surfarray.array3d(surface).astype(np.float32)  # (w,h,3)
    gray = arr.mean(axis=2, keepdims=True)
    out = arr * (1 - intensity) + gray * intensity
    scan = np.ones((1, arr.shape[1], 1), dtype=np.float32)
    scan[0, ::2, 0] = 1.0 - 0.5 * intensity
    out = np.clip(out * scan, 0, 255).astype(np.uint8)
    return pygame.surfarray.make_surface(out)


def render_title_wipe_frame(quads, t):
    '''t: 0..1 ueber die gesamte Wipe-Dauer. Erste TITLE_HEIGHT_REVEAL_FRACTION
    waechst Hoehe (oben 0->120 von oben, unten 0->120 von unten). Danach loest
    sich der Scanline/Graustufen-Effekt auf der unteren Haelfte auf (Stefans
    Screenshot4->5->6-Beobachtung).'''
    a, b, c, d = quads['A'], quads['B'], quads['C'], quads['D']
    half_h = a.get_height()  # 120
    full_w = a.get_width() + b.get_width()  # 320

    canvas = pygame.Surface((full_w, half_h * 2))
    canvas.fill((0, 0, 0))

    if t < TITLE_HEIGHT_REVEAL_FRACTION:
        reveal = t / TITLE_HEIGHT_REVEAL_FRACTION
        h = max(0, int(half_h * reveal))
        scan_intensity = 1.0
    else:
        h = half_h
        scan_intensity = 1.0 - (t - TITLE_HEIGHT_REVEAL_FRACTION) / (1 - TITLE_HEIGHT_REVEAL_FRACTION)

    if h > 0:
        top_row = pygame.Surface((full_w, half_h))
        top_row.blit(a, (0, 0))
        top_row.blit(b, (a.get_width(), 0))
        top_slice = top_row.subsurface((0, 0, full_w, h))
        canvas.blit(top_slice, (0, 0))

        bot_row = pygame.Surface((full_w, half_h))
        bot_row.blit(c, (0, 0))
        bot_row.blit(d, (a.get_width(), 0))
        bot_slice = bot_row.subsurface((0, half_h - h, full_w, h)).copy()
        bot_slice = _scanline_desaturate(bot_slice, scan_intensity)
        canvas.blit(bot_slice, (0, half_h * 2 - h))

    return canvas


def run_once(screen, clock, assets, audio_paths):
    quit_requested = run_rauser(screen, clock, assets, audio_paths)
    if quit_requested:
        return True

    # 2) schwarze Pause
    screen.fill(BG_COLOR)
    pygame.display.flip()
    quit_requested = wait_or_skip(clock, 0.5, lambda t: screen.fill(BG_COLOR))
    if quit_requested:
        return True

    # 3) Factor5 (statisches Bild). KORREKTUR (22.07.2026 abends, siehe
    # Docstring): NICHT mehr ONGAME2 (das war falsch zugeordnete Level-
    # Musik), sondern TITEL2-Song 1 -- ein kurzer "Brausen"-Sound (~2.4s),
    # der zur Beschreibung "nur ein bisschen laenger als der Rauser-Sound"
    # passt. Screen-Dauer ist jetzt FEST (~5s), nicht mehr an die (falsche)
    # 123s-Songlaenge gekoppelt.
    factor5 = build_letterboxed(assets.pcc_surface('FACTOR5'))
    factor5_path = audio_paths.get(('TITEL2', 1))
    if pygame.mixer.get_init() and factor5_path:
        try:
            factor5_sound = pygame.mixer.Sound(factor5_path)
            factor5_sound.play()
        except Exception as exc:
            print(f'[intro_sequence] Factor5-Sound konnte nicht geladen werden: {exc}', file=sys.stderr)

    def show_factor5(_t):
        blit_scaled_centered(screen, factor5, WINDOW_W, WINDOW_H)
    quit_requested = wait_or_skip(clock, FACTOR5_HOLD_SECONDS, show_factor5)
    if quit_requested:
        return True

    # 4) schwarze Pause
    quit_requested = wait_or_skip(clock, 0.5, lambda t: screen.fill(BG_COLOR))
    if quit_requested:
        return True

    # 5) Kellogg's-Logo + "praesentiert"-Palettenfade
    # Titelmusik (TITEL Song 0, ~109s) setzt hier ein und laeuft geloopt
    # weiter bis zum Ende der Sequenz (Original hat vermutlich durchgehende
    # Titelmusik ueber Logo+Titelbild+Karte -- noch nicht per Referenz-
    # Sichtung bestaetigt, aber plausibelste Annahme fuers erste v1).
    titel_path = audio_paths.get(('TITEL', None))
    if pygame.mixer.get_init() and titel_path:
        try:
            pygame.mixer.music.load(titel_path)
            pygame.mixer.music.play(-1)
        except Exception as exc:
            print(f'[intro_sequence] Titelmusik konnte nicht geladen werden: {exc}', file=sys.stderr)

    kw, kh, kidx, kpal = assets.pcc('KELLOGGS')

    def show_kelloggs(t):
        frame = build_kelloggs_frame(kw, kh, kidx, kpal, t)
        letterboxed = build_letterboxed(frame)
        blit_scaled_centered(screen, letterboxed, WINDOW_W, WINDOW_H)
    quit_requested = wait_or_skip(clock, PRAESENTIERT_FADE_SECONDS, show_kelloggs)
    if quit_requested:
        return True
    # kurz mit vollem Text stehen lassen
    quit_requested = wait_or_skip(clock, 1.0, lambda t: show_kelloggs(1.0))
    if quit_requested:
        return True

    # 6) schwarze Pause
    quit_requested = wait_or_skip(clock, 0.5, lambda t: screen.fill(BG_COLOR))
    if quit_requested:
        return True

    # 7) Titelbild -- 2x2-Quadranten-Reveal mit Scanline-Aufloesung (siehe
    # Docstring-Korrektur)
    quads = build_title_quadrants(assets)

    def show_title_wipe(t):
        canvas = render_title_wipe_frame(quads, t)
        blit_scaled_centered(screen, canvas, WINDOW_W, WINDOW_H)
    quit_requested = wait_or_skip(clock, TITLE_WIPE_SECONDS, show_title_wipe)
    if quit_requested:
        return True

    # danach: fertig zusammengesetztes Titelbild (320x240, siehe Korrektur) halten
    title_img = build_title_image(quads)

    def show_title_full(_t):
        blit_scaled_centered(screen, title_img, WINDOW_W, WINDOW_H)
    quit_requested = wait_or_skip(clock, 3.0, show_title_full)
    return quit_requested


def main():
    once = '--once' in sys.argv
    pygame.mixer.pre_init(44100, -16, 2, 512)
    pygame.init()
    try:
        pygame.mixer.init()
    except pygame.error as exc:
        # Kein Audio-Device vorhanden (z.B. headless VM/Container ohne
        # Soundkarte) -- Sequenz laeuft stumm weiter statt abzustuerzen.
        print(f'[intro_sequence] Audio nicht verfuegbar, laeuft stumm: {exc}', file=sys.stderr)
    screen = pygame.display.set_mode((WINDOW_W, WINDOW_H))
    pygame.display.set_caption("Tony & Friends in Kellogg's Land -- Intro (Remake)")
    clock = pygame.time.Clock()

    assets = Assets(DAT_PATH)

    # Alle TFMX-Songs EINMALIG vor der eigentlichen Sequenz rendern (mit
    # sichtbarem, responsivem Ladebildschirm) -- siehe render_audio_with_loading
    # zum Hang-Bug, den das behebt. Danach greifen alle run_once()-Durchlaeufe
    # nur noch auf fertige WAV-Pfade zu, nie mehr live-render im Hauptloop.
    audio_paths, quit_requested = preload_all_audio(screen, clock, assets)

    if not quit_requested:
        quit_requested = run_once(screen, clock, assets, audio_paths)
        while not quit_requested and not once:
            quit_requested = run_once(screen, clock, assets, audio_paths)

    if pygame.mixer.get_init():
        pygame.mixer.music.stop()
    pygame.quit()


if __name__ == '__main__':
    main()
