/// Terminal renderer that encodes engine frames as Sixel graphics.
library;

import 'dart:async';
import 'dart:io';
import 'dart:math' as math;
import 'dart:typed_data';

import 'package:wolf_3d_dart/src/input/cli_input.dart';
import 'package:wolf_3d_dart/src/menu/menu_manager.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
import 'package:wolf_3d_dart/wolf_3d_menu.dart';

import 'cli_renderer_backend.dart';
import 'menu_font.dart';

/// Renders the game into an indexed off-screen buffer and emits Sixel output.
///
/// The renderer adapts the engine framebuffer to the current terminal size,
/// preserving a 4:3 presentation while falling back to size warnings when the
/// terminal is too small.
class SixelRenderer extends CliRendererBackend<String> {
  static const double _targetAspectRatio = 4 / 3;
  static const int _defaultLineHeightPx = 16;
  static const double _defaultCellWidthToHeight = 0.55;
  static const int _minimumTerminalColumns = 117;
  static const int _minimumTerminalRows = 34;
  static const double _terminalViewportSafety = 0.90;
  static const int _terminalRowSafetyMargin = 1;
  static const int _compactMenuMinWidthPx = 200;
  static const int _compactMenuMinHeightPx = 130;
  static const int _maxRenderWidth = 320;
  static const int _maxRenderHeight = 240;
  static const int _menuFooterBottomMargin = 1;
  static const int _headerHeadingY = 24;

  late Uint8List _screen;
  List<int>? _mainMenuBandFirstColumn;
  int _offsetColumns = 0;
  int _offsetRows = 0;
  int _outputWidth = 1;
  int _outputHeight = 1;
  bool _needsBackgroundClear = true;

  /// Flag to determine if Sixel should actually be encoded and rendered.
  /// This should be updated by calling [checkTerminalSixelSupport] before the game loop.
  bool isSixelSupported = true;

  @override
  bool isTerminalSizeSupported(int columns, int rows) {
    return columns >= _minimumTerminalColumns && rows >= _minimumTerminalRows;
  }

  @override
  String get terminalSizeRequirement =>
      'Sixel renderer requires a minimum resolution of '
      '${_minimumTerminalColumns}x$_minimumTerminalRows.';

  // ===========================================================================
  // TERMINAL AUTODETECT
  // ===========================================================================

  /// Asynchronously checks if the current terminal emulator supports Sixel graphics.
  /// Ports the device attribute query logic from lsix.
  static Future<bool> checkTerminalSixelSupport({
    Stream<List<int>>? inputStream,
  }) async {
    // Match lsix behavior: allow explicit user override for misreporting terminals.
    if (Platform.environment.containsKey('LSIX_FORCE_SIXEL_SUPPORT')) {
      return true;
    }

    // YAFT is vt102 compatible and cannot respond to the vt220 sequence, but supports Sixel.
    final term = Platform.environment['TERM'] ?? '';
    if (term.startsWith('yaft')) {
      return true;
    }

    if (!stdin.hasTerminal) return false;

    bool terminalModesChanged = false;
    bool originalLineMode = true;
    bool originalEchoMode = true;

    try {
      // Some runtimes report hasTerminal=true but still reject mode mutation
      // with EBADF (bad file descriptor). Treat that as unsupported.
      originalLineMode = stdin.lineMode;
      originalEchoMode = stdin.echoMode;

      // Don't show escape sequences the terminal doesn't understand[cite: 24].
      stdin.lineMode = false;
      stdin.echoMode = false;
      terminalModesChanged = true;

      // Send Device Attributes query[cite: 25].
      stdout.write('\x1b[c');
      await stdout.flush();

      final responseBytes = <int>[];
      final completer = Completer<bool>();

      final sub = (inputStream ?? stdin).listen((List<int> data) {
        for (var byte in data) {
          responseBytes.add(byte);
          // Wait for the 'c' terminating character[cite: 25].
          if (byte == 99) {
            if (!completer.isCompleted) completer.complete(true);
          }
        }
      });

      try {
        // Wait up to 1 second for the terminal to respond[cite: 25].
        await completer.future.timeout(const Duration(seconds: 1));
      } catch (_) {
        // Timeout occurred
      } finally {
        sub.cancel();
      }

      final response = String.fromCharCodes(responseBytes);

      // Some terminals include full CSI sequences like "\x1b[?62;4;6c".
      // Keep only numeric attribute codes to mirror lsix's shell splitting behavior.
      final attributeCodes = RegExp(
        r'\d+',
      ).allMatches(response).map((m) => m.group(0)).whereType<String>().toSet();

      // Split by ';' or '?' or 'c' to parse attributes[cite: 25].
      final parts = response.split(RegExp(r'[;?c]'));

      // Code "4" indicates Sixel support[cite: 26].
      return parts.contains('4') || attributeCodes.contains('4');
    } catch (e) {
      return false;
    } finally {
      // Restore standard terminal settings
      if (terminalModesChanged) {
        try {
          stdin.lineMode = originalLineMode;
          stdin.echoMode = originalEchoMode;
        } catch (_) {
          // Ignore restoration failures to avoid crashing shutdown paths.
        }
      }
    }
  }

  // ===========================================================================
  // RENDERING ENGINE
  // ===========================================================================

  /// Builds a temporary framebuffer sized to the drawable terminal region.
  FrameBuffer _createScaledBuffer(FrameBuffer terminalBuffer) {
    final int previousOffsetColumns = _offsetColumns;
    final int previousOffsetRows = _offsetRows;
    final int previousOutputWidth = _outputWidth;
    final int previousOutputHeight = _outputHeight;

    // Fit the sixel output inside the full terminal viewport while preserving
    // a 4:3 presentation.
    final int terminalColumns = math.max(1, terminalBuffer.width);
    final int terminalRows = math.max(1, terminalBuffer.height);
    final double cellPixelWidth =
        _defaultLineHeightPx * _defaultCellWidthToHeight;

    final int boundsPixelWidth = math.max(
      1,
      (terminalColumns * cellPixelWidth).floor(),
    );
    final int boundsPixelHeight = math.max(
      1,
      terminalRows * _defaultLineHeightPx,
    );

    // Terminal emulators can report approximate cell metrics, so reserve a
    // safety margin to avoid right/bottom clipping and drift.
    final int safePixelWidth = math.max(
      1,
      (boundsPixelWidth * _terminalViewportSafety).floor(),
    );
    final int safePixelHeight = math.max(
      1,
      (boundsPixelHeight * _terminalViewportSafety).floor(),
    );

    // Then translate terminal cells into approximate pixels so the Sixel image
    // lands on a 4:3 surface inside the available bounds.
    final double boundsAspect = safePixelWidth / safePixelHeight;
    if (boundsAspect > _targetAspectRatio) {
      _outputHeight = safePixelHeight;
      _outputWidth = math.max(1, (_outputHeight * _targetAspectRatio).floor());
    } else {
      _outputWidth = safePixelWidth;
      _outputHeight = math.max(1, (_outputWidth / _targetAspectRatio).floor());
    }

    // Horizontal: cell-width estimates vary by terminal/font and can cause
    // right-shift clipping, so keep the image anchored at column 0.
    // Vertical: force top anchoring to keep repaint location stable across
    // terminals that still scroll when rendering near the bottom edge.
    _offsetColumns = 0;
    _offsetRows = 0;

    // Clamp output height to a conservative terminal-row budget so Sixel data
    // cannot extend beyond the viewport and trigger implicit scroll.
    final int maxImageRows = math.max(
      1,
      terminalRows - _terminalRowSafetyMargin,
    );
    final int maxOutputHeight = maxImageRows * _defaultLineHeightPx;
    if (_outputHeight > maxOutputHeight) {
      _outputHeight = maxOutputHeight;
      _outputWidth = math.max(1, (_outputHeight * _targetAspectRatio).floor());
    }

    if (_offsetColumns != previousOffsetColumns ||
        _offsetRows != previousOffsetRows ||
        _outputWidth != previousOutputWidth ||
        _outputHeight != previousOutputHeight) {
      _needsBackgroundClear = true;
    }

    final double renderScale = math.min(
      1.0,
      math.min(
        _maxRenderWidth / _outputWidth,
        _maxRenderHeight / _outputHeight,
      ),
    );
    final int renderWidth = math.max(1, (_outputWidth * renderScale).floor());
    final int renderHeight = math.max(1, (_outputHeight * renderScale).floor());

    return FrameBuffer(renderWidth, renderHeight);
  }

  @override
  /// Renders using a temporary scaled framebuffer sized for Sixel output,
  /// then restores the original terminal-sized framebuffer.
  String render(WolfEngine engine) {
    final FrameBuffer originalBuffer = engine.frameBuffer;
    final FrameBuffer scaledBuffer = _createScaledBuffer(originalBuffer);

    // Sixel output references palette indices directly, so there is no need to
    // materialize a 32-bit RGBA buffer during the rendering pass.
    _screen = Uint8List(scaledBuffer.width * scaledBuffer.height);
    engine.frameBuffer = scaledBuffer;
    try {
      return super.render(engine);
    } finally {
      engine.frameBuffer = originalBuffer;
    }
  }

  @override
  void prepareFrame(WolfEngine engine) {
    // Top half is ceiling color index (25), bottom half is floor color index (29)
    for (int y = 0; y < viewHeight; y++) {
      int colorIndex = (y < viewHeight / 2) ? 25 : 29;
      for (int x = 0; x < width; x++) {
        _screen[y * width + x] = colorIndex;
      }
    }
  }

  @override
  void drawWallColumn(
    int x,
    int drawStart,
    int drawEnd,
    int columnHeight,
    Sprite texture,
    int texX,
    double perpWallDist,
    int side,
  ) {
    for (int y = drawStart; y < drawEnd; y++) {
      int texY = wallTexY(y, columnHeight);

      int colorByte = texture.pixels[texX * 64 + texY];
      _screen[y * width + x] = colorByte;
    }
  }

  @override
  void drawSpriteStripe(
    int stripeX,
    int drawStartY,
    int drawEndY,
    int spriteHeight,
    Sprite texture,
    int texX,
    double transformY,
  ) {
    for (
      int y = math.max(0, drawStartY);
      y < math.min(viewHeight, drawEndY);
      y++
    ) {
      int texY = spriteTexY(y, drawStartY, spriteHeight);

      int colorByte = texture.pixels[texX * 64 + texY];

      // 255 is the "transparent" color index
      if (colorByte != 255) {
        _screen[y * width + stripeX] = colorByte;
      }
    }
  }

  @override
  void drawWeapon(WolfEngine engine) {
    int spriteIndex = engine.player.currentWeapon.getCurrentSpriteIndex(
      engine.data.sprites.length,
    );
    Sprite weaponSprite = engine.data.sprites[spriteIndex];

    final bounds = weaponScreenBounds(engine);
    final int weaponWidth = bounds.weaponWidth;
    final int weaponHeight = bounds.weaponHeight;
    final int startX = bounds.startX;
    final int startY = bounds.startY;

    for (int dy = 0; dy < weaponHeight; dy++) {
      for (int dx = 0; dx < weaponWidth; dx++) {
        int texX = (dx * 64 ~/ weaponWidth).clamp(0, 63);
        int texY = (dy * 64 ~/ weaponHeight).clamp(0, 63);

        int colorByte = weaponSprite.pixels[texX * 64 + texY];
        if (colorByte != 255) {
          int drawX = startX + dx;
          int drawY = startY + dy;
          if (drawX >= 0 && drawX < width && drawY >= 0 && drawY < viewHeight) {
            _screen[drawY * width + drawX] = colorByte;
          }
        }
      }
    }
  }

  @override
  /// Delegates the canonical Wolf3D VGA HUD draw sequence to the base class.
  void drawHud(WolfEngine engine) {
    drawStandardVgaHud(engine);
  }

  @override
  void drawFpsOverlay(WolfEngine engine) {
    const int panelColor = 0;
    const int textColor = 15;
    final String label = fpsLabel(engine);
    final int textWidth = WolfMenuFont.measureTextWidth(label, 1);
    final int panelWidth = (textWidth + 8).clamp(16, 96);

    _fillRect320(2, 2, panelWidth, 10, panelColor);
    _drawMenuText(label, 4, 3, textColor, scale: 1);
  }

  @override
  /// Blits a VGA image into the Sixel index buffer HUD space (320x200).
  void blitHudVgaImage(VgaImage image, int startX320, int startY200) {
    _blitVgaImage(image, startX320, startY200);
  }

  @override
  void drawMenu(WolfEngine engine) {
    final int bgColor = _rgbToPaletteIndex(
      engine.menuManager.menuBackgroundRgb,
    );
    final int panelColor = _rgbToPaletteIndex(engine.menuPanelRgb);
    final int headingIndex = WolfMenuPalette.headerTextIndex;
    final int selectedTextIndex = WolfMenuPalette.selectedTextIndex;
    final int unselectedTextIndex = WolfMenuPalette.unselectedTextIndex;
    final int disabledTextIndex = WolfMenuPalette.disabledTextIndex;

    for (int i = 0; i < _screen.length; i++) {
      _screen[i] = bgColor;
    }

    final art = WolfClassicMenuArt(engine.data);
    final optionsLabel = art.optionsLabel;
    if (optionsLabel != null) {
      _mainMenuBandFirstColumn = _cacheFirstColumn(optionsLabel);
    }
    // Draw footer first so menu panels can clip overlap in the center.
    _drawMenuFooterArt(art);

    if (engine.menuManager.activeMenu == WolfMenuScreen.introSplash) {
      _drawIntroSplash(engine, art);
      return;
    }

    if (engine.menuManager.activeMenu == WolfMenuScreen.mainMenu) {
      _fillRect320(68, 52, 178, 136, panelColor);

      final optionsLabel = art.optionsLabel;
      if (optionsLabel != null) {
        final int optionsX = ((320 - optionsLabel.width) ~/ 2).clamp(0, 319);
        _drawMainMenuOptionsSideBars(optionsLabel, optionsX);
        _blitVgaImage(optionsLabel, optionsX, 0);
      } else {
        _drawHeaderBarStack(
          headingY200: _headerHeadingY,
          backgroundColor: bgColor,
          barColor: 0,
        );
        _drawMenuTextCentered(
          'OPTIONS',
          _headerHeadingY,
          headingIndex,
          scale: 2,
        );
      }

      final cursor = art.mappedPic(
        engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
      );
      const int rowYStart = 55;
      const int rowStep = 13;
      final entries = engine.menuManager.mainMenuEntries;
      for (int i = 0; i < entries.length; i++) {
        final bool isSelected = i == engine.menuManager.selectedMainIndex;
        if (isSelected && cursor != null) {
          _blitVgaImage(cursor, 72, (rowYStart + (i * rowStep)) - 2);
        }
        _drawMenuText(
          entries[i].label,
          100,
          rowYStart + (i * rowStep),
          entries[i].isEnabled
              ? (isSelected ? selectedTextIndex : unselectedTextIndex)
              : disabledTextIndex,
          scale: 1,
        );
      }
      _applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
      return;
    }

    if (engine.menuManager.activeMenu == WolfMenuScreen.gameSelect) {
      _drawHeaderBarStack(
        headingY200: _headerHeadingY,
        backgroundColor: bgColor,
        barColor: 0,
      );
      _fillRect320(28, 58, 264, 104, panelColor);
      _drawMenuTextCentered(
        'SELECT GAME',
        _headerHeadingY,
        headingIndex,
        scale: 2,
      );
      final cursor = art.mappedPic(
        engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
      );
      const int rowYStart = 84;
      const int rowStep = 18;
      for (int i = 0; i < engine.availableGames.length; i++) {
        final bool isSelected = i == engine.menuManager.selectedGameIndex;
        if (isSelected && cursor != null) {
          _blitVgaImage(cursor, 38, (rowYStart + (i * rowStep)) - 2);
        }
        _drawMenuText(
          _gameTitle(engine.availableGames[i].version),
          70,
          rowYStart + (i * rowStep),
          isSelected ? selectedTextIndex : unselectedTextIndex,
          scale: 1,
        );
      }
      _applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
      return;
    }

    if (engine.menuManager.activeMenu == WolfMenuScreen.episodeSelect) {
      _drawHeaderBarStack(
        headingY200: _headerHeadingY,
        backgroundColor: bgColor,
        barColor: 0,
      );
      _fillRect320(12, 20, 296, 158, panelColor);
      _drawMenuTextCentered(
        'WHICH EPISODE TO PLAY?',
        _headerHeadingY,
        headingIndex,
        scale: 2,
      );
      final cursor = art.mappedPic(
        engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
      );
      const int rowYStart = 30;
      const int rowStep = 24;
      for (int i = 0; i < engine.data.episodes.length; i++) {
        final int y = rowYStart + (i * rowStep);
        final bool isSelected = i == engine.menuManager.selectedEpisodeIndex;
        if (isSelected && cursor != null) {
          _blitVgaImage(cursor, 16, y + 2);
        }
        final image = art.episodeOption(i);
        if (image != null) {
          _blitVgaImage(image, 40, y);
        }
        final parts = engine.data.episodes[i].name.split('\n');
        if (parts.isNotEmpty) {
          _drawMenuText(
            parts.first,
            98,
            y + 1,
            isSelected ? selectedTextIndex : unselectedTextIndex,
            scale: 1,
          );
        }
        if (parts.length > 1) {
          _drawMenuText(
            parts.sublist(1).join(' '),
            98,
            y + 12,
            isSelected ? selectedTextIndex : unselectedTextIndex,
            scale: 1,
          );
        }
      }
      _applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
      return;
    }

    if (engine.menuManager.activeMenu == WolfMenuScreen.changeView) {
      _drawCustomizeMenuHeader(art, headingIndex, bgColor);
      final cursor = art.mappedPic(
        engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
      );
      final selectedMarker = art.selectedMarker;
      final unselectedMarker = art.unselectedMarker;
      const int rowYStart = 66;
      const int rowStep = 18;
      const int cursorX = 62;
      const int markerX = 92;
      const int textX = 122;
      final entries = engine.menuManager.changeViewEntries;
      final optionEntries = engine.menuManager.rendererOptionEntries;
      final int modeCount = entries.length;
      final int optionCount = optionEntries.length;

      const int modesPanelY = 52;
      final int modesContentHeight = modeCount <= 0
          ? 0
          : ((modeCount - 1) * rowStep) + 12;
      final int modesPanelHeight = math.max(56, modesContentHeight + 14);
      final int sectionHeaderY = modesPanelY + modesPanelHeight + 6;
      final int optionsPanelY = sectionHeaderY + 14;
      final int optionsContentHeight = optionCount <= 0
          ? 0
          : ((optionCount - 1) * 15) + 12;
      final int optionsPanelHeight = math.max(30, optionsContentHeight + 10);

      _fillRect320(46, modesPanelY, 228, modesPanelHeight, panelColor);
      _fillRect320(46, optionsPanelY, 228, optionsPanelHeight, panelColor);
      for (int i = 0; i < entries.length; i++) {
        final int y = rowYStart + (i * rowStep);
        final bool isSelected = i == engine.menuManager.selectedChangeViewIndex;
        if (isSelected && cursor != null) {
          _blitVgaImage(cursor, cursorX, y - 2);
        }

        final entry = entries[i];
        final marker = entry.isChecked ? selectedMarker : unselectedMarker;
        if (marker != null) {
          _blitVgaImage(marker, markerX, y);
        }
        _drawMenuText(
          entry.label,
          textX,
          y + 1,
          entry.isEnabled
              ? (isSelected ? selectedTextIndex : unselectedTextIndex)
              : disabledTextIndex,
          scale: 1,
        );
      }

      _drawMenuSectionHeader(
        text: engine.menuManager.rendererOptionsTitle,
        y200: sectionHeaderY,
      );

      const int optionsRowStep = 15;
      final int optionsRowsHeight = optionCount <= 0
          ? 0
          : ((optionCount - 1) * optionsRowStep) + 10;
      final int optionsRowStart =
          optionsPanelY +
          ((optionsPanelHeight - optionsRowsHeight) ~/ 2).clamp(0, 200);
      for (int i = 0; i < optionEntries.length; i++) {
        final int optionIndex = modeCount + i;
        final bool isSelected =
            optionIndex == engine.menuManager.selectedChangeViewIndex;
        final int y = optionsRowStart + (i * optionsRowStep);
        if (isSelected && cursor != null) {
          _blitVgaImage(cursor, cursorX, y - 2);
        }
        final entry = optionEntries[i];
        final marker = entry.isChecked ? selectedMarker : unselectedMarker;
        if (marker != null) {
          _blitVgaImage(marker, markerX, y);
        }
        _drawMenuText(
          entry.label,
          textX,
          y + 1,
          entry.isEnabled
              ? (isSelected ? selectedTextIndex : unselectedTextIndex)
              : disabledTextIndex,
          scale: 1,
        );
      }
      _applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
      return;
    }

    if (engine.menuManager.activeMenu == WolfMenuScreen.rendererOptions) {
      _drawCustomizeMenuHeader(art, headingIndex, bgColor);
      _fillRect320(56, 52, 208, 120, panelColor);
      _drawMenuTextCentered(
        engine.menuManager.rendererOptionsTitle,
        46,
        headingIndex,
        scale: 1,
      );

      final cursor = art.mappedPic(
        engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
      );
      final selectedMarker = art.selectedMarker;
      final unselectedMarker = art.unselectedMarker;
      const int rowYStart = 68;
      const int rowStep = 20;
      const int cursorX = 62;
      const int markerX = 92;
      const int textX = 122;
      final entries = engine.menuManager.rendererOptionEntries;
      for (int i = 0; i < entries.length; i++) {
        final int y = rowYStart + (i * rowStep);
        final bool isSelected =
            i == engine.menuManager.selectedRendererOptionIndex;
        final entry = entries[i];
        if (isSelected && cursor != null) {
          _blitVgaImage(cursor, cursorX, y - 2);
        }
        final marker = entry.isChecked ? selectedMarker : unselectedMarker;
        if (marker != null) {
          _blitVgaImage(marker, markerX, y);
        }
        _drawMenuText(
          entry.label,
          textX,
          y + 1,
          entry.isEnabled
              ? (isSelected ? selectedTextIndex : unselectedTextIndex)
              : disabledTextIndex,
          scale: 1,
        );
      }
      _applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
      return;
    }

    final int selectedDifficultyIndex =
        engine.menuManager.selectedDifficultyIndex;
    _drawHeaderBarStack(
      headingY200: _headerHeadingY,
      backgroundColor: bgColor,
      barColor: 0,
    );
    _fillRect320(28, 70, 264, 82, panelColor);
    if (_useCompactMenuLayout) {
      _drawCompactMenu(selectedDifficultyIndex, headingIndex, panelColor);
      _applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
      return;
    }

    _drawMenuTextCentered(
      Difficulty.menuText,
      _headerHeadingY,
      headingIndex,
      scale: _menuHeadingScale,
    );

    final face = art.difficultyOption(
      Difficulty.values[selectedDifficultyIndex],
    );
    if (face != null) {
      _blitVgaImage(face, 28 + 264 - face.width - 10, 92);
    }

    final cursor = art.mappedPic(
      engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
    );
    const rowYStart = 86;
    const rowStep = 15;
    const textX = 70;
    for (int i = 0; i < Difficulty.values.length; i++) {
      final y = rowYStart + (i * rowStep);
      final isSelected = i == selectedDifficultyIndex;

      if (isSelected && cursor != null) {
        _blitVgaImage(cursor, 38, y - 2);
      }

      _drawMenuText(
        Difficulty.values[i].title,
        textX,
        y,
        isSelected ? selectedTextIndex : unselectedTextIndex,
        scale: _menuOptionScale,
      );
    }

    _applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
  }

  void _drawCustomizeMenuHeader(
    WolfClassicMenuArt art,
    int headingIndex,
    int backgroundColor,
  ) {
    _drawHeaderBarStack(
      headingY200: _headerHeadingY,
      backgroundColor: backgroundColor,
      barColor: 0,
    );

    final VgaImage? heading = art.customizeLabel ?? art.optionsLabel;
    if (heading != null) {
      final int headingX = ((320 - heading.width) ~/ 2).clamp(0, 319);
      _blitVgaImage(heading, headingX, 0);
      return;
    }

    _drawMenuTextCentered('CUSTOMIZE', _headerHeadingY, headingIndex, scale: 2);
  }

  void _drawMenuSectionHeader({
    required String text,
    required int y200,
  }) {
    final int textW = WolfMenuFont.measureTextWidth(text, 1);
    final int boxW = (textW + 28).clamp(110, 250);
    final int x = ((320 - boxW) ~/ 2).clamp(0, 319);
    final int outer = _rgbToPaletteIndex(0x727272);
    final int inner = _rgbToPaletteIndex(0x8E8E8E);
    final int fill = _rgbToPaletteIndex(0xC9C9C9);
    final int textColor = _rgbToPaletteIndex(0x5A5A5A);

    _fillRect320(x, y200, boxW, 12, outer);
    _fillRect320(x + 1, y200 + 1, boxW - 2, 10, inner);
    _fillRect320(x + 2, y200 + 2, boxW - 4, 8, fill);
    _drawMenuTextCentered(text, y200 + 2, textColor, scale: 1);
  }

  void _drawMenuFooterArt(WolfClassicMenuArt art) {
    final bottom = art.mappedPic(15);
    if (bottom == null) {
      return;
    }
    _blitVgaImage(
      bottom,
      (320 - bottom.width) ~/ 2,
      200 - bottom.height - _menuFooterBottomMargin,
    );
  }

  String _gameTitle(GameVersion version) {
    switch (version) {
      case GameVersion.shareware:
        return 'SHAREWARE';
      case GameVersion.retail:
        return 'RETAIL';
      case GameVersion.spearOfDestiny:
        return 'SPEAR OF DESTINY';
      case GameVersion.spearOfDestinyDemo:
        return 'SOD DEMO';
    }
  }

  void _drawIntroSplash(WolfEngine engine, WolfClassicMenuArt art) {
    final image = switch (engine.menuManager.currentIntroSlide) {
      WolfIntroSlide.retailWarning => null,
      WolfIntroSlide.pg13 => art.mappedPic(WolfMenuPic.pg13),
      WolfIntroSlide.title => art.mappedPic(WolfMenuPic.title),
    };

    int splashBg = _rgbToPaletteIndex(engine.menuManager.menuBackgroundRgb);
    if (engine.menuManager.isIntroPg13Slide &&
        image != null &&
        image.pixels.isNotEmpty) {
      splashBg = _dominantPaletteIndex(image);
    }

    for (int i = 0; i < _screen.length; i++) {
      _screen[i] = splashBg;
    }

    if (engine.menuManager.isIntroRetailWarningSlide) {
      _drawRetailWarningIntro(splashBg);
    }

    if (image != null) {
      final int x = engine.menuManager.isIntroPg13Slide
          ? (320 - image.width).clamp(0, 319)
          : ((320 - image.width) ~/ 2).clamp(0, 319);
      final int y = engine.menuManager.isIntroPg13Slide
          ? (200 - image.height).clamp(0, 199)
          : ((200 - image.height) ~/ 2).clamp(0, 199);
      _blitVgaImage(image, x, y);
    }

    _applyMenuFade(
      engine.menuManager.introOverlayAlpha,
      _rgbToPaletteIndex(0x000000),
    );
  }

  void _drawRetailWarningIntro(int backgroundColor) {
    const int black = 0;
    const int yellow = 14;
    const int white = 15;

    _drawHeaderBarStack(
      headingY200: _headerHeadingY,
      backgroundColor: backgroundColor,
      barColor: black,
    );
    _drawMenuTextCentered('Attention', _headerHeadingY, yellow, scale: 2);

    _drawMenuText('This game is NOT shareware.', 40, 56, white, scale: 1);
    _drawMenuText('Please do not distribute it.', 40, 68, white, scale: 1);
    _drawMenuText('Thanks.', 40, 80, white, scale: 1);
    _drawMenuTextCentered('Id Software', 106, white, scale: 1);

    _fillRect320(0, 196, 320, 4, backgroundColor);
  }

  int _dominantPaletteIndex(VgaImage image) {
    final List<int> histogram = List<int>.filled(256, 0);
    for (final int colorIndex in image.pixels) {
      if (colorIndex >= 0 && colorIndex < histogram.length) {
        histogram[colorIndex]++;
      }
    }

    int bestIndex = 0;
    int bestCount = -1;
    for (int i = 0; i < histogram.length; i++) {
      if (histogram[i] > bestCount) {
        bestCount = histogram[i];
        bestIndex = i;
      }
    }
    return bestIndex;
  }

  void _applyMenuFade(double alpha, int bgColor) {
    if (alpha <= 0.0) {
      return;
    }
    final int threshold = (alpha * 3).round().clamp(1, 3);
    for (int y = 0; y < height; y++) {
      final int rowOffset = y * width;
      for (int x = 0; x < width; x++) {
        if (((x + y) % 3) < threshold) {
          _screen[rowOffset + x] = bgColor;
        }
      }
    }
  }

  void _drawHeaderBarStack({
    required int headingY200,
    required int backgroundColor,
    required int barColor,
  }) {
    final List<int>? cachedColumn = _mainMenuBandFirstColumn;
    if (cachedColumn != null && cachedColumn.isNotEmpty) {
      _drawScaledColumnBand(cachedColumn);
      return;
    }

    final int mainBarTop = (headingY200 - 4).clamp(0, 199);
    _fillRect320(0, mainBarTop, 320, 22, barColor);
  }

  void _drawMainMenuOptionsSideBars(VgaImage optionsLabel, int optionsX320) {
    _mainMenuBandFirstColumn = _cacheFirstColumn(optionsLabel);
    _drawScaledColumnBand(_mainMenuBandFirstColumn!);
  }

  List<int> _cacheFirstColumn(VgaImage image) {
    final List<int> column = List<int>.filled(image.height, 0);
    for (int y = 0; y < image.height; y++) {
      column[y] = image.decodePixel(0, y);
    }
    return column;
  }

  void _drawScaledColumnBand(List<int> column) {
    if (column.isEmpty) {
      return;
    }

    final double scaleY = height / 200.0;
    final int destHeight = math.max(1, (column.length * scaleY).toInt());

    for (int dy = 0; dy < destHeight; dy++) {
      final int drawY = dy;
      if (drawY < 0 || drawY >= height) {
        continue;
      }

      final int srcY = ((dy / destHeight) * column.length).toInt().clamp(
        0,
        column.length - 1,
      );
      final int paletteIndex = column[srcY];
      final int fillIndex = paletteIndex == 0 ? 0 : paletteIndex;
      final int rowStart = drawY * width;
      for (int drawX = 0; drawX < width; drawX++) {
        _screen[rowStart + drawX] = fillIndex;
      }
    }
  }

  bool get _useCompactMenuLayout =>
      width < _compactMenuMinWidthPx || height < _compactMenuMinHeightPx;

  int get _menuHeadingScale => width < 250 ? 1 : 2;

  int get _menuOptionScale => width < 220 ? 1 : 1;

  /// Draws a compact fallback menu layout for narrow render targets.
  void _drawCompactMenu(
    int selectedDifficultyIndex,
    int headingIndex,
    int panelColor,
  ) {
    _fillRect320(16, 52, 288, 112, panelColor);
    _drawMenuTextCentered(Difficulty.menuText, 60, headingIndex, scale: 1);

    const int rowYStart = 86;
    const int rowStep = 13;
    for (int i = 0; i < Difficulty.values.length; i++) {
      final bool isSelected = i == selectedDifficultyIndex;
      final String prefix = isSelected ? '> ' : '  ';
      _drawMenuText(
        prefix + Difficulty.values[i].title,
        42,
        rowYStart + (i * rowStep),
        isSelected
            ? WolfMenuPalette.selectedTextIndex
            : WolfMenuPalette.unselectedTextIndex,
        scale: 1,
      );
    }
  }

  /// Draws bitmap menu text into the indexed Sixel back buffer.
  void _drawMenuText(
    String text,
    int startX,
    int startY,
    int colorIndex, {
    int scale = 1,
  }) {
    int x320 = startX;
    for (final rune in text.runes) {
      final char = String.fromCharCode(rune).toUpperCase();
      final pattern = WolfMenuFont.glyphFor(char);

      for (int row = 0; row < pattern.length; row++) {
        final bits = pattern[row];
        for (int col = 0; col < bits.length; col++) {
          if (bits[col] != '1') continue;

          for (int sy = 0; sy < scale; sy++) {
            for (int sx = 0; sx < scale; sx++) {
              final int px320 = x320 + (col * scale) + sx;
              final int py200 = startY + (row * scale) + sy;
              _fillRect320(px320, py200, 1, 1, colorIndex);
            }
          }
        }
      }

      x320 += WolfMenuFont.glyphAdvance(char, scale);
    }
  }

  /// Draws bitmap menu text centered in 320x200 menu space.
  void _drawMenuTextCentered(
    String text,
    int y,
    int colorIndex, {
    int scale = 1,
  }) {
    if (y == _headerHeadingY) {
      y = _centerHeaderTitleInBlackBand(defaultY: y, scale: scale);
    }
    final int textWidth = WolfMenuFont.measureTextWidth(text, scale);
    final int x = ((320 - textWidth) ~/ 2).clamp(0, 319);
    _drawMenuText(text, x, y, colorIndex, scale: scale);
  }

  int _centerHeaderTitleInBlackBand({
    required int defaultY,
    required int scale,
  }) {
    final List<int>? column = _mainMenuBandFirstColumn;
    if (column == null || column.isEmpty) {
      return defaultY;
    }

    int bestStart = -1;
    int bestLength = 0;
    int runStart = -1;

    for (int i = 0; i <= column.length; i++) {
      final bool isBlack = i < column.length && column[i] == 0;
      if (isBlack) {
        runStart = runStart == -1 ? i : runStart;
        continue;
      }
      if (runStart != -1) {
        final int runLength = i - runStart;
        if (runLength > bestLength) {
          bestLength = runLength;
          bestStart = runStart;
        }
        runStart = -1;
      }
    }

    if (bestStart == -1 || bestLength == 0) {
      return defaultY;
    }

    final int textHeight = 7 * scale;
    final int centered = bestStart + ((bestLength - textHeight) ~/ 2);
    return centered.clamp(0, 199 - textHeight);
  }

  @override
  String finalizeFrame() {
    if (!isSixelSupported) {
      return _renderNotSupportedMessage();
    }

    final String clearPrefix = _needsBackgroundClear ? '\x1b[0m\x1b[2J' : '';
    _needsBackgroundClear = false;
    return '$clearPrefix\x1b[${_offsetRows + 1};${_offsetColumns + 1}H${toSixelString()}';
  }

  // ===========================================================================
  // UI FALLBACK
  // ===========================================================================

  /// Returns an ANSI fallback UI when Sixel capability is unavailable.
  String _renderNotSupportedMessage() {
    int cols = 80;
    int rows = 24;
    try {
      if (stdout.hasTerminal) {
        cols = stdout.terminalColumns;
        rows = stdout.terminalLines;
      }
    } catch (_) {}

    const String msg1 = "Terminal does not support Sixel.";
    final String shortcutLabel = engine.input is CliInput
        ? (engine.input as CliInput).rendererToggleKeyLabel
        : 'TAB';
    final String msg2 = "Press $shortcutLabel to switch renderers.";

    final int boxWidth = math.max(msg1.length, msg2.length) + 6;
    const int boxHeight = 5;

    final int startX = math.max(1, (cols - boxWidth) ~/ 2);
    final int startY = math.max(1, (rows - boxHeight) ~/ 2);

    final StringBuffer sb = StringBuffer();
    if (_needsBackgroundClear) {
      sb.write('\x1b[0m\x1b[2J');
      _needsBackgroundClear = false;
    }

    String center(String text) {
      final int padLeft = (boxWidth - 2 - text.length) ~/ 2;
      final int padRight = boxWidth - 2 - text.length - padLeft;
      return (' ' * padLeft) + text + (' ' * padRight);
    }

    // Draw centered box
    sb.write('\x1b[$startY;${startX}H┌${'─' * (boxWidth - 2)}┐');
    sb.write('\x1b[${startY + 1};${startX}H│${center(msg1)}│');
    sb.write('\x1b[${startY + 2};${startX}H│${center("")}│');
    sb.write('\x1b[${startY + 3};${startX}H│${center(msg2)}│');
    sb.write('\x1b[${startY + 4};${startX}H└${'─' * (boxWidth - 2)}┘');

    // Park the cursor at the bottom out of the way
    sb.write('\x1b[$rows;1H');

    return sb.toString();
  }

  // ===========================================================================
  // SIXEL ENCODER
  // ===========================================================================

  /// Encodes the current indexed frame buffer as a Sixel image stream.
  String toSixelString() {
    StringBuffer sb = StringBuffer();
    sb.write('\x1bPq');
    sb.write('"1;1;$_outputWidth;$_outputHeight');

    double damageIntensity = engine.difficulty == null
        ? 0.0
        : engine.player.damageFlash;
    int redBoost = (150 * damageIntensity).toInt();
    double colorDrop = 1.0 - (0.5 * damageIntensity);

    for (int i = 0; i < 256; i++) {
      int color = ColorPalette.vga32Bit[i];
      int r = color & 0xFF;
      int g = (color >> 8) & 0xFF;
      int b = (color >> 16) & 0xFF;

      if (damageIntensity > 0) {
        r = (r + redBoost).clamp(0, 255);
        g = (g * colorDrop).toInt().clamp(0, 255);
        b = (b * colorDrop).toInt().clamp(0, 255);
      }

      int sixelR = (r * 100) ~/ 255;
      int sixelG = (g * 100) ~/ 255;
      int sixelB = (b * 100) ~/ 255;

      sb.write('#$i;2;$sixelR;$sixelG;$sixelB');
    }

    for (int band = 0; band < _outputHeight; band += 6) {
      Map<int, Uint8List> colorMap = {};

      for (int x = 0; x < _outputWidth; x++) {
        for (int yOffset = 0; yOffset < 6; yOffset++) {
          int y = band + yOffset;
          if (y >= _outputHeight) break;

          int colorIdx = _sampleScaledPixel(x, y);
          if (!colorMap.containsKey(colorIdx)) {
            colorMap[colorIdx] = Uint8List(_outputWidth);
          }
          colorMap[colorIdx]![x] |= (1 << yOffset);
        }
      }

      bool firstColor = true;
      for (var entry in colorMap.entries) {
        if (!firstColor) {
          sb.write('\$');
        }
        firstColor = false;
        sb.write('#${entry.key}');

        Uint8List cols = entry.value;
        int currentVal = -1;
        int runLength = 0;

        for (int x = 0; x < _outputWidth; x++) {
          int val = cols[x];
          if (val == currentVal) {
            runLength++;
          } else {
            if (runLength > 0) _writeSixelRle(sb, currentVal, runLength);
            currentVal = val;
            runLength = 1;
          }
        }
        if (runLength > 0) _writeSixelRle(sb, currentVal, runLength);
      }

      if (band + 6 < _outputHeight) {
        sb.write('-');
      }
    }

    sb.write('\x1b\\');
    return sb.toString();
  }

  /// Samples the render buffer at output-space coordinates using nearest-neighbor
  /// mapping from Sixel output pixels to source pixels.
  int _sampleScaledPixel(int outX, int outY) {
    final int srcX = ((((outX + 0.5) * width) / _outputWidth) - 0.5)
        .round()
        .clamp(
          0,
          width - 1,
        );
    final int srcY = ((((outY + 0.5) * height) / _outputHeight) - 0.5)
        .round()
        .clamp(
          0,
          height - 1,
        );
    return _screen[srcY * width + srcX];
  }

  /// Emits run-length encoded Sixel data for one repeated column mask value.
  void _writeSixelRle(StringBuffer sb, int value, int runLength) {
    String char = String.fromCharCode(value + 63);
    if (runLength > 3) {
      sb.write('!$runLength$char');
    } else {
      sb.write(char * runLength);
    }
  }

  // ===========================================================================
  // PRIVATE HUD HELPERS
  // ===========================================================================

  /// Blits a VGA image into the Sixel back buffer with 320x200 scaling.
  void _blitVgaImage(VgaImage image, int startX, int startY) {
    final double scaleX = width / 320.0;
    final double scaleY = height / 200.0;

    final int destStartX = (startX * scaleX).toInt();
    final int destStartY = (startY * scaleY).toInt();
    final int destWidth = math.max(1, (image.width * scaleX).toInt());
    final int destHeight = math.max(1, (image.height * scaleY).toInt());

    for (int dy = 0; dy < destHeight; dy++) {
      for (int dx = 0; dx < destWidth; dx++) {
        int drawX = destStartX + dx;
        int drawY = destStartY + dy;

        if (drawX >= 0 && drawX < width && drawY >= 0 && drawY < height) {
          int srcX = ((dx / destWidth) * image.width).toInt().clamp(
            0,
            image.width - 1,
          );
          int srcY = ((dy / destHeight) * image.height).toInt().clamp(
            0,
            image.height - 1,
          );

          int colorByte = image.decodePixel(srcX, srcY);
          if (colorByte != 255) {
            _screen[drawY * width + drawX] = colorByte;
          }
        }
      }
    }
  }

  /// Fills a rectangle in 320x200 virtual space into the scaled Sixel buffer.
  void _fillRect320(int startX, int startY, int w, int h, int colorIndex) {
    final double scaleX = width / 320.0;
    final double scaleY = height / 200.0;

    final int destStartX = (startX * scaleX).floor();
    final int destEndX = ((startX + w) * scaleX).ceil();
    final int destStartY = (startY * scaleY).floor();
    final int destEndY = ((startY + h) * scaleY).ceil();

    for (int drawY = destStartY; drawY < destEndY; drawY++) {
      for (int drawX = destStartX; drawX < destEndX; drawX++) {
        if (drawX >= 0 && drawX < width && drawY >= 0 && drawY < height) {
          _screen[drawY * width + drawX] = colorIndex;
        }
      }
    }
  }

  /// Maps an RGB color to the nearest VGA palette index.
  int _rgbToPaletteIndex(int rgb) {
    return ColorPalette.findClosestPaletteIndex(rgb);
  }
}
