from __future__ import annotations

import math
from collections.abc import Callable, Iterable
from dataclasses import dataclass, field
from pathlib import Path

import pyray as rl

from grim.color import RGBA
from grim.fonts.grim_mono import (
    GrimMonoFont,
    draw_grim_mono_text,
    load_grim_mono_font,
)
from grim.fonts.small import (
    SmallFontData,
    draw_small_text,
    load_small_font,
    measure_small_text_width,
)
from grim.geom import Vec2
from grim.math import clamp

from . import paq

CONSOLE_LOG_NAME = "console.log"
MAX_CONSOLE_LINES = 0x1000
MAX_CONSOLE_INPUT = 0x3FF
DEFAULT_CONSOLE_HEIGHT = 300
EXTENDED_CONSOLE_HEIGHT = 480
CONSOLE_VERSION_TEXT = "Crimsonland 1.9.93"
CONSOLE_ANIM_SPEED = 3.5
CONSOLE_BLINK_SPEED = 3.0
CONSOLE_LINE_HEIGHT = 16.0
CONSOLE_MONO_SCALE = 0.5
CONSOLE_SMALL_SCALE = 1.0
CONSOLE_TEXT_X = 10.0
CONSOLE_INPUT_X_MONO = 26.0
CONSOLE_VERSION_OFFSET_X = 210.0
CONSOLE_VERSION_OFFSET_Y = 18.0
CONSOLE_BORDER_HEIGHT = 4.0
CONSOLE_BG_COLOR = (0.140625, 0.1875, 0.2890625)
CONSOLE_BORDER_COLOR = (0.21875, 0.265625, 0.3671875)
CONSOLE_PROMPT_MONO = ">"
CONSOLE_PROMPT_SMALL_FMT = ">%s"
CONSOLE_CARET_TEXT = "_"
SCRIPT_PAQ_NAMES = ("music.paq", "crimson.paq", "sfx.paq")

CommandHandler = Callable[[list[str]], None]


def game_build_path(base_dir: Path, name: str) -> Path:
    return base_dir / name


def _parse_float(value: str) -> float:
    try:
        return float(value)
    except ValueError:
        return 0.0


def _normalize_script_path(name: str) -> Path:
    raw = name.strip().strip("\"'")
    normalized = raw.replace("\\", "/")
    return Path(normalized)


def _resolve_script_path(console: "ConsoleState", target: Path) -> Path | None:
    if target.is_absolute():
        return target if target.is_file() else None
    for base in console.script_dirs:
        candidate = base / target
        if candidate.is_file():
            return candidate
    return None


def _primary_script_dirs(console: "ConsoleState") -> tuple[Path, ...]:
    dirs: list[Path] = [console.base_dir]
    if console.assets_dir is not None and console.assets_dir not in dirs:
        dirs.append(console.assets_dir)
    return tuple(dirs)


def _resolve_script_path_in(target: Path, roots: Iterable[Path]) -> Path | None:
    if target.is_absolute():
        return target if target.is_file() else None
    for base in roots:
        candidate = base / target
        if candidate.is_file():
            return candidate
    return None


def _iter_script_paq_paths(console: "ConsoleState") -> Iterable[Path]:
    roots: list[Path] = []
    if console.assets_dir is not None:
        roots.append(console.assets_dir)
    if console.base_dir not in roots:
        roots.append(console.base_dir)
    for root in roots:
        for name in SCRIPT_PAQ_NAMES:
            path = root / name
            if path.is_file():
                yield path


def _load_script_from_paq(console: "ConsoleState", target: Path) -> str | None:
    if target.is_absolute():
        return None
    normalized = target.as_posix().replace("\\", "/")
    normalized_lower = normalized.lower()
    for paq_path in _iter_script_paq_paths(console):
        try:
            for name, data in paq.iter_entries(paq_path):
                entry_name = name.replace("\\", "/")
                if entry_name == normalized or entry_name.lower() == normalized_lower:
                    return data.decode("utf-8", errors="ignore")
        except OSError:
            continue
        except Exception:
            continue
    return None


def _rgba(r: float, g: float, b: float, a: float) -> rl.Color:
    c = RGBA(r, g, b, a).clamped()
    return rl.Color(
        int(c.r * 255.0),
        int(c.g * 255.0),
        int(c.b * 255.0),
        int(c.a * 255.0),
    )


@dataclass(slots=True)
class ConsoleCvar:
    name: str
    value: str
    value_f: float

    @classmethod
    def from_value(cls, name: str, value: str) -> "ConsoleCvar":
        return cls(name=name, value=value, value_f=_parse_float(value))


@dataclass(slots=True)
class ConsoleLog:
    base_dir: Path
    lines: list[str] = field(default_factory=list)
    flushed_index: int = 0

    def log(self, message: str) -> None:
        self.lines.append(message)
        if len(self.lines) > MAX_CONSOLE_LINES:
            overflow = len(self.lines) - MAX_CONSOLE_LINES
            del self.lines[:overflow]
            self.flushed_index = max(0, self.flushed_index - overflow)

    def clear(self) -> None:
        self.lines.clear()
        self.flushed_index = 0

    def flush(self) -> None:
        if self.flushed_index >= len(self.lines):
            return
        path = game_build_path(self.base_dir, CONSOLE_LOG_NAME)
        path.parent.mkdir(parents=True, exist_ok=True)
        with path.open("a", encoding="utf-8") as handle:
            for line in self.lines[self.flushed_index :]:
                handle.write(line.rstrip() + "\n")
        self.flushed_index = len(self.lines)


@dataclass(slots=True)
class ConsoleState:
    base_dir: Path
    log: ConsoleLog
    assets_dir: Path | None = None
    script_dirs: tuple[Path, ...] = field(default_factory=tuple)
    commands: dict[str, CommandHandler] = field(default_factory=dict)
    cvars: dict[str, ConsoleCvar] = field(default_factory=dict)
    open_flag: bool = False
    input_enabled: bool = False
    input_ready: bool = False
    input_buffer: str = ""
    input_caret: int = 0
    history: list[str] = field(default_factory=list)
    history_index: int | None = None
    history_pending: str = ""
    scroll_offset: int = 0
    height_px: int = DEFAULT_CONSOLE_HEIGHT
    echo_enabled: bool = True
    quit_requested: bool = False
    prompt_string: str = "> %s"
    _mono_font: GrimMonoFont | None = field(default=None, init=False, repr=False)
    _small_font: SmallFontData | None = field(default=None, init=False, repr=False)
    _slide_t: float = 1.0
    _offset_y: float = field(default=0.0, init=False)
    _blink_time: float = 0.0

    def register_command(self, name: str, handler: CommandHandler) -> None:
        self.commands[name] = handler

    def register_cvar(self, name: str, value: str) -> None:
        self.cvars[name] = ConsoleCvar.from_value(name, value)

    def add_script_dir(self, path: Path | None) -> None:
        if path is None:
            return
        if path in self.script_dirs:
            return
        self.script_dirs = (*self.script_dirs, path)

    def set_open(self, open_flag: bool) -> None:
        self.open_flag = open_flag
        self.input_enabled = open_flag
        self.input_ready = False
        self.history_index = None
        self._flush_input_queue()

    def toggle_open(self) -> None:
        self.set_open(not self.open_flag)

    def handle_hotkey(self) -> None:
        if rl.is_key_pressed(rl.KeyboardKey.KEY_GRAVE):
            self.toggle_open()

    def exec_line(self, line: str) -> None:
        tokens = self._tokenize_line(line)
        if not tokens:
            return
        name, args = tokens[0], tokens[1:]
        cvar = self.cvars.get(name)
        if cvar is not None:
            if args:
                value = " ".join(args)
                cvar.value = value
                cvar.value_f = _parse_float(value)
                self.log.log(f"\"{cvar.name}\" set to \"{cvar.value}\" ({cvar.value_f:.6f})")
            else:
                self.log.log(f"\"{cvar.name}\" is \"{cvar.value}\" ({cvar.value_f:.6f})")
            return
        handler = self.commands.get(name)
        if handler is not None:
            handler(args)
            return
        self.log.log(f"Unknown command \"{name}\"")

    def update(self, dt: float) -> None:
        frame_dt = min(dt, 0.1)
        self._blink_time += frame_dt
        self._update_slide(frame_dt)
        if not self.open_flag or not self.input_enabled:
            return
        ctrl_down = rl.is_key_down(rl.KeyboardKey.KEY_LEFT_CONTROL) or rl.is_key_down(rl.KeyboardKey.KEY_RIGHT_CONTROL)
        if rl.is_key_pressed(rl.KeyboardKey.KEY_UP):
            if ctrl_down:
                self._scroll_lines(1)
            else:
                self._history_prev()
        if rl.is_key_pressed(rl.KeyboardKey.KEY_DOWN):
            if ctrl_down:
                self._scroll_lines(-1)
            else:
                self._history_next()
        if rl.is_key_pressed(rl.KeyboardKey.KEY_PAGE_UP):
            self._scroll_lines(2)
        if rl.is_key_pressed(rl.KeyboardKey.KEY_PAGE_DOWN):
            self._scroll_lines(-2)
        if rl.is_key_pressed(rl.KeyboardKey.KEY_LEFT):
            self.input_caret = max(0, self.input_caret - 1)
        if rl.is_key_pressed(rl.KeyboardKey.KEY_RIGHT):
            self.input_caret = min(len(self.input_buffer), self.input_caret + 1)
        if rl.is_key_pressed(rl.KeyboardKey.KEY_HOME):
            self._scroll_lines(0x14)
        if rl.is_key_pressed(rl.KeyboardKey.KEY_END):
            self.scroll_offset = 0
        if rl.is_key_pressed(rl.KeyboardKey.KEY_TAB):
            self._autocomplete()
        if rl.is_key_pressed(rl.KeyboardKey.KEY_BACKSPACE):
            if self.input_caret > 0:
                self._exit_history_edit()
                self.input_buffer = (
                    self.input_buffer[: self.input_caret - 1] + self.input_buffer[self.input_caret :]
                )
                self.input_caret -= 1
        if rl.is_key_pressed(rl.KeyboardKey.KEY_DELETE):
            if self.input_caret < len(self.input_buffer):
                self._exit_history_edit()
                self.input_buffer = (
                    self.input_buffer[: self.input_caret] + self.input_buffer[self.input_caret + 1 :]
                )
        if rl.is_key_pressed(rl.KeyboardKey.KEY_ENTER):
            self._submit_input()
        self._poll_text_input()

    def draw(self) -> None:
        height = float(self.height_px)
        if height <= 0.0:
            return
        ratio = self._open_ratio(height)
        if ratio <= 0.0:
            return
        screen_w = float(rl.get_screen_width())
        offset_y = self._offset_y
        rl.draw_rectangle(
            0,
            int(offset_y),
            int(screen_w),
            int(height),
            _rgba(*CONSOLE_BG_COLOR, ratio),
        )
        border_y = int(offset_y + height - CONSOLE_BORDER_HEIGHT)
        rl.draw_rectangle(
            0,
            border_y,
            int(screen_w),
            int(CONSOLE_BORDER_HEIGHT),
            _rgba(*CONSOLE_BORDER_COLOR, ratio),
        )

        version_x = screen_w - CONSOLE_VERSION_OFFSET_X
        version_y = offset_y + height - CONSOLE_VERSION_OFFSET_Y
        self._draw_version_text(Vec2(version_x, version_y), _rgba(1.0, 1.0, 1.0, ratio * 0.3))

        visible, visible_count = self._visible_log_block(height)
        input_y = offset_y + (visible_count + 1) * CONSOLE_LINE_HEIGHT
        text_color = _rgba(1.0, 1.0, 1.0, ratio)
        use_mono = self._use_mono_font()
        if use_mono:
            self._draw_mono_text(CONSOLE_PROMPT_MONO, Vec2(CONSOLE_TEXT_X, input_y), text_color)
            self._draw_mono_text(self.input_buffer, Vec2(CONSOLE_INPUT_X_MONO, input_y), text_color)
        else:
            prompt = CONSOLE_PROMPT_SMALL_FMT.replace("%s", self.input_buffer)
            self._draw_small_text(prompt, Vec2(CONSOLE_TEXT_X, input_y), text_color)

        log_color = _rgba(0.6, 0.6, 0.7, ratio)
        y = offset_y + CONSOLE_LINE_HEIGHT
        for line in visible:
            if use_mono:
                self._draw_mono_text(line, Vec2(CONSOLE_TEXT_X, y), log_color)
            else:
                self._draw_small_text(line, Vec2(CONSOLE_TEXT_X, y), log_color)
            y += CONSOLE_LINE_HEIGHT

        caret_alpha = ratio * self._caret_blink_alpha()
        caret_color = _rgba(1.0, 1.0, 1.0, caret_alpha)
        caret_y = input_y + 2.0
        if use_mono:
            caret_x = CONSOLE_INPUT_X_MONO + float(self.input_caret) * 8.0
            self._draw_mono_text(CONSOLE_CARET_TEXT, Vec2(caret_x, caret_y), caret_color)
        else:
            caret_x = self._small_caret_x()
            self._draw_small_text(CONSOLE_CARET_TEXT, Vec2(caret_x, caret_y), caret_color)

    def close(self) -> None:
        if self._mono_font is not None:
            rl.unload_texture(self._mono_font.texture)
            self._mono_font = None
        if self._small_font is not None:
            rl.unload_texture(self._small_font.texture)
            self._small_font = None

    def _tokenize_line(self, line: str) -> list[str]:
        stripped = line.strip()
        if not stripped:
            return []
        if stripped.startswith("//"):
            return []
        return stripped.split()

    def _prompt_text(self) -> str:
        if "%s" in self.prompt_string:
            return self.prompt_string.replace("%s", self.input_buffer)
        return f"{self.prompt_string}{self.input_buffer}"

    def _history_prev(self) -> None:
        if not self.history:
            return
        if self.history_index is None:
            self.history_index = len(self.history) - 1
            self.history_pending = self.input_buffer
        elif self.history_index > 0:
            self.history_index -= 1
        self.input_buffer = self.history[self.history_index]
        self.input_caret = len(self.input_buffer)

    def _history_next(self) -> None:
        if self.history_index is None:
            return
        if self.history_index < len(self.history) - 1:
            self.history_index += 1
            self.input_buffer = self.history[self.history_index]
        else:
            self.history_index = None
            self.input_buffer = self.history_pending
        self.input_caret = len(self.input_buffer)

    def _exit_history_edit(self) -> None:
        if self.history_index is not None:
            self.history_index = None
            self.history_pending = self.input_buffer

    def _submit_input(self) -> None:
        line = self.input_buffer.strip()
        self.input_ready = True
        self.input_buffer = ""
        self.input_caret = 0
        self.history_index = None
        if not line:
            return
        if self.echo_enabled:
            if "%s" in self.prompt_string:
                self.log.log(self.prompt_string.replace("%s", line))
            else:
                self.log.log(f"{self.prompt_string}{line}")
        if not self.history or self.history[-1] != line:
            self.history.append(line)
        self.exec_line(line)
        self.input_ready = False
        self.scroll_offset = 0

    def _poll_text_input(self) -> None:
        while True:
            value = rl.get_char_pressed()
            if value == 0:
                break
            if value < 0x20 or value > 0xFF:
                continue
            if len(self.input_buffer) >= MAX_CONSOLE_INPUT:
                continue
            char = chr(value)
            self._exit_history_edit()
            self.input_buffer = (
                self.input_buffer[: self.input_caret] + char + self.input_buffer[self.input_caret :]
            )
            self.input_caret += 1

    def _autocomplete(self) -> None:
        if not self.input_buffer:
            return
        token_start = len(self.input_buffer) - len(self.input_buffer.lstrip())
        if token_start >= len(self.input_buffer):
            return
        token_end = self.input_buffer.find(" ", token_start)
        if token_end == -1:
            token_end = len(self.input_buffer)
        if self.input_caret > token_end:
            return
        prefix = self.input_buffer[token_start:self.input_caret]
        if not prefix:
            return
        match = self._autocomplete_name(prefix, self.cvars.keys())
        if match is None:
            match = self._autocomplete_name(prefix, self.commands.keys())
        if match is None:
            return
        self.input_buffer = self.input_buffer[:token_start] + match + self.input_buffer[token_end:]
        self.input_caret = token_start + len(match)

    def _autocomplete_name(self, prefix: str, names: Iterable[str]) -> str | None:
        for name in names:
            if name == prefix:
                return name
        for name in names:
            if name.startswith(prefix):
                return name
        return None

    def _scroll_lines(self, delta: int) -> None:
        max_offset = self._max_scroll_offset()
        if max_offset <= 0:
            self.scroll_offset = 0
            return
        self.scroll_offset = max(0, min(max_offset, self.scroll_offset + int(delta)))

    def _max_visible_lines(self, height: float | None = None) -> int:
        use_height = height if height is not None else float(self.height_px)
        if use_height <= 0.0:
            return 0
        return max(int(use_height // CONSOLE_LINE_HEIGHT) - 2, 0)

    def _max_scroll_offset(self) -> int:
        max_lines = self._max_visible_lines()
        log_count = len(self.log.lines)
        visible = min(log_count, max_lines)
        return max(0, log_count - visible)

    def _visible_log_block(self, height: float) -> tuple[list[str], int]:
        max_lines = self._max_visible_lines(height)
        log_count = len(self.log.lines)
        visible_count = min(log_count, max_lines)
        if visible_count <= 0:
            return [], 0
        max_offset = max(0, log_count - visible_count)
        if self.scroll_offset > max_offset:
            self.scroll_offset = max_offset
        start = max(0, log_count - visible_count - self.scroll_offset)
        end = min(log_count, start + visible_count)
        return self.log.lines[start:end], visible_count

    def _update_slide(self, dt: float) -> None:
        if self.open_flag:
            self._slide_t = max(0.0, self._slide_t - dt * CONSOLE_ANIM_SPEED)
        else:
            self._slide_t = min(1.0, self._slide_t + dt * CONSOLE_ANIM_SPEED)
        height = float(self.height_px)
        if height <= 0.0:
            self._offset_y = -height
            return
        eased = math.sin((1.0 - self._slide_t) * math.pi / 2.0)
        self._offset_y = eased * height - height

    def _open_ratio(self, height: float) -> float:
        if height <= 0.0:
            return 0.0
        return clamp((height + self._offset_y) / height, 0.0, 1.0)

    def _caret_blink_alpha(self) -> float:
        pulse = math.sin(self._blink_time * CONSOLE_BLINK_SPEED)
        value = max(0.2, abs(pulse) ** 2)
        return clamp(value, 0.0, 1.0)

    def _use_mono_font(self) -> bool:
        cvar = self.cvars.get("con_monoFont")
        if cvar is None:
            return False
        return bool(cvar.value_f)

    def _ensure_mono_font(self) -> GrimMonoFont | None:
        if self._mono_font is not None:
            return self._mono_font
        if self.assets_dir is None:
            raise FileNotFoundError("missing assets dir")
        self._mono_font = load_grim_mono_font(self.assets_dir)
        return self._mono_font

    def _ensure_small_font(self) -> SmallFontData | None:
        if self._small_font is not None:
            return self._small_font
        if self.assets_dir is None:
            raise FileNotFoundError("missing assets dir")
        self._small_font = load_small_font(self.assets_dir)
        return self._small_font

    def _draw_mono_text(self, text: str, pos: Vec2, color: rl.Color) -> None:
        font = self._ensure_mono_font()
        if font is None:
            rl.draw_text(text, int(pos.x), int(pos.y), int(16 * CONSOLE_MONO_SCALE), color)
            return
        advance = font.advance * CONSOLE_MONO_SCALE
        draw_grim_mono_text(font, text, Vec2(pos.x - advance, pos.y), CONSOLE_MONO_SCALE, color)

    def _draw_small_text(self, text: str, pos: Vec2, color: rl.Color) -> None:
        font = self._ensure_small_font()
        if font is None:
            rl.draw_text(text, int(pos.x), int(pos.y), int(16 * CONSOLE_SMALL_SCALE), color)
            return
        draw_small_text(font, text, pos, CONSOLE_SMALL_SCALE, color)

    def _draw_version_text(self, pos: Vec2, color: rl.Color) -> None:
        font = self._ensure_small_font()
        if font is None:
            self._draw_mono_text(CONSOLE_VERSION_TEXT, pos, color)
            return
        draw_small_text(font, CONSOLE_VERSION_TEXT, pos, CONSOLE_SMALL_SCALE, color)

    def _small_caret_x(self) -> float:
        font = self._ensure_small_font()
        if font is None:
            return CONSOLE_TEXT_X + 16.0 + float(self.input_caret) * 8.0
        prompt_w = measure_small_text_width(font, CONSOLE_PROMPT_SMALL_FMT.replace("%s", ""), CONSOLE_SMALL_SCALE)
        input_w = measure_small_text_width(font, self.input_buffer[: self.input_caret], CONSOLE_SMALL_SCALE)
        return CONSOLE_TEXT_X + prompt_w + input_w

    def _flush_input_queue(self) -> None:
        while rl.get_char_pressed():
            pass
        while rl.get_key_pressed():
            pass


def create_console(base_dir: Path, assets_dir: Path | None = None) -> ConsoleState:
    script_dirs: tuple[Path, ...] = (base_dir,)
    if assets_dir is not None and assets_dir != base_dir:
        script_dirs = (*script_dirs, assets_dir)
    console = ConsoleState(
        base_dir=base_dir,
        log=ConsoleLog(base_dir=base_dir),
        assets_dir=assets_dir,
        script_dirs=script_dirs,
    )
    console.register_cvar("version", CONSOLE_VERSION_TEXT)
    console.register_cvar("con_monoFont", "1")
    if console.open_flag:
        console._slide_t = 0.0
        console._offset_y = 0.0
    else:
        console._slide_t = 1.0
        console._offset_y = -float(console.height_px)
    register_core_commands(console)
    return console


def _make_noop_command(console: ConsoleState, name: str) -> CommandHandler:
    def _handler(args: list[str]) -> None:
        console.log.log(f"command {name} called with {len(args)} args")

    return _handler


def register_boot_commands(
    console: ConsoleState, handlers: dict[str, CommandHandler] | None = None,
) -> None:
    resolved = handlers or {}
    commands = (
        "setGammaRamp",
        "snd_addGameTune",
        "generateterrain",
        "telltimesurvived",
        "setresourcepaq",
        "loadtexture",
        "openurl",
        "sndfreqadjustment",
    )
    for name in commands:
        handler = resolved.get(name)
        if handler is None:
            handler = _make_noop_command(console, name)
        console.register_command(name, handler)


def register_core_cvars(console: ConsoleState, width: int, height: int) -> None:
    # Defaults match `register_core_cvars` (0x00402c00) in `crimsonland.exe`.
    console.register_cvar("cv_silentloads", "1")
    console.register_cvar("cv_terrainFilter", "1")
    console.register_cvar("cv_bodiesFade", "1")
    console.register_cvar("cv_uiTransparency", "1")
    console.register_cvar("cv_uiPointFilterPanels", "0")
    console.register_cvar("cv_enableMousePointAndClickMovement", "0")
    console.register_cvar("cv_verbose", "0")
    console.register_cvar("cv_terrainBodiesTransparency", "0")
    console.register_cvar("cv_uiSmallIndicators", "0")
    console.register_cvar("cv_aimEnhancementFade", "0.7")
    console.register_cvar("cv_friendlyFire", "0")
    console.register_cvar("cv_lanLockstepEnabled", "0")
    console.register_cvar("cv_lanPlayerRings", "0")
    console.register_cvar("cv_showFPS", "0")
    console.register_cvar("cv_padAimDistMul", "96")
    console.register_cvar("v_width", str(width))
    console.register_cvar("v_height", str(height))


def register_core_commands(console: ConsoleState) -> None:
    def cmdlist(_args: list[str]) -> None:
        for name in console.commands.keys():
            console.log.log(name)
        console.log.log(f"{len(console.commands)} commands")

    def vars_cmd(_args: list[str]) -> None:
        for name in console.cvars.keys():
            console.log.log(name)
        console.log.log(f"{len(console.cvars)} variables")

    def cmd_set(args: list[str]) -> None:
        if len(args) < 2:
            console.log.log("Usage: set <var> <value>")
            return
        name = args[0]
        value = " ".join(args[1:])
        console.register_cvar(name, value)
        console.log.log(f"'{name}' set to '{value}'")

    def cmd_echo(args: list[str]) -> None:
        if not args:
            console.log.log(f"echo is {'on' if console.echo_enabled else 'off'}")
            return
        mode = args[0].lower()
        if mode in {"on", "off"}:
            console.echo_enabled = mode == "on"
            console.log.log(f"echo {mode}")
            return
        console.log.log(" ".join(args))

    def cmd_quit(_args: list[str]) -> None:
        console.quit_requested = True

    def cmd_clear(_args: list[str]) -> None:
        console.log.clear()
        console.scroll_offset = 0

    def cmd_extend(_args: list[str]) -> None:
        console.height_px = EXTENDED_CONSOLE_HEIGHT

    def cmd_minimize(_args: list[str]) -> None:
        console.height_px = DEFAULT_CONSOLE_HEIGHT

    def cmd_exec(args: list[str]) -> None:
        if not args:
            console.log.log("exec <script>")
            return
        target = _normalize_script_path(args[0])
        try:
            script_text: str | None = None
            primary_dirs = _primary_script_dirs(console)
            path = _resolve_script_path_in(target, primary_dirs)
            if path is None:
                script_text = _load_script_from_paq(console, target)
            if path is None and script_text is None:
                fallback_dirs = [path for path in console.script_dirs if path not in primary_dirs]
                path = _resolve_script_path_in(target, fallback_dirs)
            if path is None and script_text is None:
                console.log.log(f"Cannot open file '{args[0]}'")
                return
            console.log.log(f"Executing '{args[0]}'")
            if script_text is None:
                assert path is not None
                script_text = path.read_text(encoding="utf-8", errors="ignore")
            for raw_line in script_text.splitlines():
                line = raw_line.strip()
                if line:
                    console.exec_line(line)
        except OSError:
            console.log.log(f"Cannot open file '{args[0]}'")

    console.register_command("cmdlist", cmdlist)
    console.register_command("vars", vars_cmd)
    console.register_command("set", cmd_set)
    console.register_command("echo", cmd_echo)
    console.register_command("quit", cmd_quit)
    console.register_command("clear", cmd_clear)
    console.register_command("extendconsole", cmd_extend)
    console.register_command("minimizeconsole", cmd_minimize)
    console.register_command("exec", cmd_exec)
