# Solar v1.0.3 - A huge update from v1.0.2
from __future__ import annotations

import argparse
import ast
import tkinter as tk  # still used for StringVar/IntVar (customtkinter uses tkinter variables)
from dataclasses import dataclass
from typing import Any, Callable, Optional

# --- customtkinter import with helpful error ---
try:
    import customtkinter as ctk
except ModuleNotFoundError as e:
    raise ModuleNotFoundError(
        "customtkinter is not installed.\n"
        "Install it with:\n"
        "  pip3 install --user customtkinter\n"
        "or inside a venv.\n"
    ) from e


# ---------------- Tokenizer (keeps whether token was quoted) ----------------
Token = tuple[str, bool]  # (text, was_quoted)


def tokenize_line(line: str) -> list[Token]:
    """
    Returns [(token, was_quoted), ...]
    - Supports "..." and '...'
    - Supports backslash escapes inside strings (simple)
    - Splits on whitespace outside strings
    """
    tokens: list[Token] = []
    i = 0
    n = len(line)

    while i < n:
        while i < n and line[i].isspace():
            i += 1
        if i >= n:
            break

        ch = line[i]
        if ch in ('"', "'"):
            q = ch
            i += 1
            out: list[str] = []
            while i < n:
                if line[i] == "\\" and i + 1 < n:
                    out.append(line[i + 1])
                    i += 2
                    continue
                if line[i] == q:
                    break
                out.append(line[i])
                i += 1
            if i >= n or line[i] != q:
                raise SyntaxError("Oh noes! Solar threw an error: unclosed string literal")
            i += 1
            tokens.append(("".join(out), True))
        else:
            start = i
            while i < n and not line[i].isspace():
                i += 1
            tokens.append((line[start:i], False))

    return tokens


# ---------------- Python passthrough detection ----------------
def starts_python_block(raw_line: str) -> bool:
    s = raw_line.lstrip()
    if not s:
        return False

    # Only start passthrough blocks for *real* Python blocks.
    # Solar has `if ...:` too, but also supports `if ... then ...` (no trailing ':'),
    # so we avoid classifying plain "if " as python.
    if s.startswith(("def ", "class ", "@")):
        return True

    # Any Python block header ends with ':'
    return s.rstrip().endswith(":")


def looks_like_python_single_line(raw_line: str) -> bool:
    """
    Single-line python that should NOT be parsed as Solar.
    """
    s = raw_line.lstrip()
    if not s:
        return False

    # Never classify Solar keywords / DSL lines as python, even if they contain '='
    if s.startswith(("contain ", "let ", "print ", "call ", "ui ", "pg ", "solar_def ", "if ", "while ", "for ", "return ")):
        return False

    if s.startswith(("import ", "from ")):
        return True

    # treat assignments as python unless Solar's `let ... = ...`
    if "=" in s and not s.strip().startswith("let "):
        return True

    # common in your use-case
    if s.startswith(("funcs[", "vars_[", "vars_[")):
        return True

    return False


# ---------------- AST nodes ----------------
@dataclass
class Stmt:
    pass


@dataclass
class PyRaw(Stmt):
    raw: str


@dataclass
class ContainImport(Stmt):
    module: str
    alias: Optional[str] = None  # contain X as Y


@dataclass
class Let(Stmt):
    name: str
    expr: "ExprNode"


@dataclass
class Print(Stmt):
    items: list["ExprNode"]


@dataclass
class CallStmt(Stmt):
    func_name: str
    args: list["ExprNode"]


@dataclass
class Return(Stmt):
    expr: Optional["ExprNode"]


@dataclass
class If(Stmt):
    cond: "ExprNode"
    then_body: list[Stmt]
    elifs: list[tuple["ExprNode", list[Stmt]]]
    else_body: list[Stmt]


@dataclass
class While(Stmt):
    cond: "ExprNode"
    body: list[Stmt]


@dataclass
class ForRange(Stmt):
    var: str
    start: "ExprNode"
    end: "ExprNode"
    body: list[Stmt]


@dataclass
class SolarDef(Stmt):
    name: str
    params: list[str]
    body: list[Stmt]


# --- UI nodes (customtkinter) ---
@dataclass
class UiWindow(Stmt):
    name: str


@dataclass
class UiTitle(Stmt):
    win: str
    title: "ExprNode"


@dataclass
class UiSize(Stmt):
    win: str
    w: "ExprNode"
    h: "ExprNode"


@dataclass
class UiBg(Stmt):
    win: str
    color: "ExprNode"


@dataclass
class UiFg(Stmt):
    win: str
    color: "ExprNode"


@dataclass
class UiLabel(Stmt):
    win: str
    name: str
    text: "ExprNode"
    x: "ExprNode"
    y: "ExprNode"


@dataclass
class UiButton(Stmt):
    win: str
    name: str
    text: "ExprNode"
    x: "ExprNode"
    y: "ExprNode"
    on_call: CallStmt


@dataclass
class UiEntry(Stmt):
    win: str
    name: str
    x: "ExprNode"
    y: "ExprNode"


@dataclass
class UiSlider(Stmt):
    win: str
    name: str
    minv: "ExprNode"
    maxv: "ExprNode"
    x: "ExprNode"
    y: "ExprNode"


@dataclass
class UiCheckbox(Stmt):
    win: str
    name: str
    text: "ExprNode"
    x: "ExprNode"
    y: "ExprNode"


@dataclass
class UiBind(Stmt):
    win: str
    key: str
    call: CallStmt


@dataclass
class UiRun(Stmt):
    win: str


@dataclass
class UiText(Stmt):
    widget: str
    text: "ExprNode"


@dataclass
class UiSet(Stmt):
    varname: str
    value: "ExprNode"


@dataclass
class UiGetInto(Stmt):
    varname: str
    target: str


# --- Pygame DSL nodes ---
@dataclass
class PgInit(Stmt):
    pass


@dataclass
class PgWindow(Stmt):
    w: "ExprNode"
    h: "ExprNode"
    title: Optional["ExprNode"] = None


@dataclass
class PgFps(Stmt):
    fps: "ExprNode"


@dataclass
class PgVar(Stmt):
    name: str
    value: "ExprNode"


@dataclass
class PgRect(Stmt):
    name: str
    x: "ExprNode"
    y: "ExprNode"
    w: "ExprNode"
    h: "ExprNode"
    r: "ExprNode"
    g: "ExprNode"
    b: "ExprNode"


@dataclass
class PgClear(Stmt):
    r: "ExprNode"
    g: "ExprNode"
    b: "ExprNode"


@dataclass
class PgDraw(Stmt):
    rect_name: str


@dataclass
class PgFlip(Stmt):
    pass


@dataclass
class PgMove(Stmt):
    rect_name: str
    dx: "ExprNode"
    dy: "ExprNode"


@dataclass
class PgSet(Stmt):
    name: str
    value: "ExprNode"


@dataclass
class PgAdd(Stmt):
    name: str
    delta: "ExprNode"


@dataclass
class PgExit(Stmt):
    pass


@dataclass
class PgOnQuit(Stmt):
    body: list[Stmt]


@dataclass
class PgKeyHeld(Stmt):
    key: str
    body: list[Stmt]


@dataclass
class PgLoop(Stmt):
    body: list[Stmt]


# ---------------- Expression AST ----------------
@dataclass
class ExprNode:
    pass


@dataclass
class ENum(ExprNode):
    value: float


@dataclass
class EStr(ExprNode):
    value: str


@dataclass
class EVar(ExprNode):
    name: str


@dataclass
class EUnary(ExprNode):
    op: str
    rhs: ExprNode


@dataclass
class EBin(ExprNode):
    op: str
    lhs: ExprNode
    rhs: ExprNode


@dataclass
class ECall(ExprNode):
    name: str
    args: list[ExprNode]


@dataclass
class EIndex(ExprNode):
    base: ExprNode
    index: ExprNode


# ---------------- Expression parser (Pratt) ----------------
_OP_PRECEDENCE = {
    "or": 1,
    "and": 2,
    "==": 3, "!=": 3, "<": 3, "<=": 3, ">": 3, ">=": 3,
    "+": 4, "-": 4,
    "*": 5, "/": 5, "%": 5,
}


def _is_number(tok: str) -> bool:
    try:
        float(tok)
        return True
    except Exception:
        return False


def _tok_text(t: Token) -> str:
    return t[0]


class _ExprStream:
    def __init__(self, tokens: list[Token]):
        self.tokens = tokens
        self.i = 0

    def peek(self) -> Optional[Token]:
        if self.i >= len(self.tokens):
            return None
        return self.tokens[self.i]

    def next(self) -> Optional[Token]:
        t = self.peek()
        if t is None:
            return None
        self.i += 1
        return t

    def expect(self, text: str) -> None:
        t = self.next()
        if t is None or _tok_text(t) != text:
            raise SyntaxError(f"Oh noes! Solar threw an error: expected `{text}`")

    def done(self) -> bool:
        return self.i >= len(self.tokens)


def parse_expr_tokens(tokens: list[Token]) -> ExprNode:
    st = _ExprStream(tokens)
    expr = _parse_expr_bp(st, 0)
    if not st.done():
        raise SyntaxError("Oh noes! Solar threw an error: unexpected tokens in expression")
    return expr


def _parse_primary(st: _ExprStream) -> ExprNode:
    t = st.next()
    if t is None:
        raise SyntaxError("Oh noes! Solar threw an error: expected expression")

    text, quoted = t

    if quoted:
        return EStr(text)

    if text == "(":
        e = _parse_expr_bp(st, 0)
        st.expect(")")
        return e

    if text in ("-", "not"):
        rhs = _parse_expr_bp(st, 6)
        return EUnary(text, rhs)

    if _is_number(text):
        return ENum(float(text))

    # identifier
    if text.isidentifier():
        node: ExprNode = EVar(text)

        # call: name( ... )
        if st.peek() is not None and _tok_text(st.peek()) == "(":
            st.next()  # '('
            args: list[ExprNode] = []
            if st.peek() is not None and _tok_text(st.peek()) != ")":
                while True:
                    args.append(_parse_expr_bp(st, 0))
                    if st.peek() is not None and _tok_text(st.peek()) == ",":
                        st.next()
                        continue
                    break
            st.expect(")")
            node = ECall(text, args)

        # index: x[expr]
        while st.peek() is not None and _tok_text(st.peek()) == "[":
            st.next()
            idx = _parse_expr_bp(st, 0)
            st.expect("]")
            node = EIndex(node, idx)

        return node

    # treat anything else as string (for backwards compatibility)
    return EStr(text)


def _parse_expr_bp(st: _ExprStream, min_bp: int) -> ExprNode:
    lhs = _parse_primary(st)

    while True:
        op_tok = st.peek()
        if op_tok is None:
            break
        op = _tok_text(op_tok)

        # allow keywords as operators
        if op not in _OP_PRECEDENCE:
            break

        prec = _OP_PRECEDENCE[op]
        lbp = prec
        rbp = prec + 1
        if lbp < min_bp:
            break

        st.next()  # consume op
        rhs = _parse_expr_bp(st, rbp)
        lhs = EBin(op, lhs, rhs)

    return lhs


def expr_to_py(expr: ExprNode) -> str:
    if isinstance(expr, ENum):
        # keep ints nicer
        if float(expr.value).is_integer():
            return repr(int(expr.value))
        return repr(expr.value)
    if isinstance(expr, EStr):
        return repr(expr.value)
    if isinstance(expr, EVar):
        return f"__get({expr.name!r})"
    if isinstance(expr, EUnary):
        if expr.op == "not":
            return f"(not {expr_to_py(expr.rhs)})"
        return f"({expr.op}{expr_to_py(expr.rhs)})"
    if isinstance(expr, EBin):
        op = expr.op
        if op in ("and", "or"):
            return f"({expr_to_py(expr.lhs)} {op} {expr_to_py(expr.rhs)})"
        return f"({expr_to_py(expr.lhs)} {op} {expr_to_py(expr.rhs)})"
    if isinstance(expr, ECall):
        args = ", ".join(expr_to_py(a) for a in expr.args)
        return f"__call({expr.name!r}, {args})"
    if isinstance(expr, EIndex):
        return f"({expr_to_py(expr.base)}[{expr_to_py(expr.index)}])"
    raise TypeError(f"Oh noes! Solar threw an error: Unknown expression: {expr}")


# ---------------- Parsing (Solar + blocks) ----------------
def _split_inline_comment(line: str) -> str:
    # very simple: if a # appears and it's not inside quotes, treat it as comment start
    in_q = None
    out = []
    i = 0
    while i < len(line):
        ch = line[i]
        if in_q is None and ch in ("'", '"'):
            in_q = ch
            out.append(ch)
            i += 1
            continue
        if in_q is not None and ch == in_q:
            in_q = None
            out.append(ch)
            i += 1
            continue
        if in_q is None and ch == "#":
            break
        out.append(ch)
        i += 1
    return "".join(out).rstrip()


def parse_prog(src: str) -> list[Stmt]:
    lines = src.splitlines()
    i = 0
    program: list[Stmt] = []

    in_py_block = False

    # block stack for Solar blocks; each entry: (kind, container)
    # container is list[Stmt] to append into
    stack: list[tuple[str, Any]] = [("root", program)]

    def cur_list() -> list[Stmt]:
        return stack[-1][1]

    while i < len(lines):
        raw = lines[i].rstrip("\n")
        i += 1

        if not raw.strip():
            if in_py_block:
                cur_list().append(PyRaw(raw))
            continue

        if raw.lstrip().startswith("#"):
            if in_py_block:
                cur_list().append(PyRaw(raw))
            continue

        # inside python block: keep indented lines
        if in_py_block:
            if raw.startswith((" ", "\t")):
                cur_list().append(PyRaw(raw))
                continue
            in_py_block = False  # dedent ends python block

        line = _split_inline_comment(raw).strip()
        if not line:
            continue

        # contain import
        if line.startswith("contain "):
            rest = line.split(None, 1)[1].strip()
            # allow: contain pygame as pg
            alias = None
            if " as " in rest:
                mod, alias = rest.split(" as ", 1)
                mod = mod.strip()
                alias = alias.strip()
            else:
                mod = rest.strip()

            mod = mod.rstrip(" ;,")
            if not mod or not all(part.isidentifier() for part in mod.split(".")):
                raise SyntaxError(f"Oh noes! Solar threw an error: `contain <module>` expects a valid module name")

            if alias is not None and not alias.isidentifier():
                raise SyntaxError("Oh noes! Solar threw an error: invalid alias name in `contain X as Y`")

            cur_list().append(ContainImport(mod, alias))
            continue

        # python passthrough block start?
        if starts_python_block(raw):
            cur_list().append(PyRaw(raw))
            in_py_block = True
            continue

        # python passthrough single line?
        if looks_like_python_single_line(raw):
            cur_list().append(PyRaw(raw))
            continue

        # block endings
        if line == "end":
            if len(stack) <= 1:
                raise SyntaxError("Oh noes! Solar threw an error: `end` without a matching block")
            stack.pop()
            continue

        # solar_def start
        if line.startswith("solar_def "):
            # solar_def name (a, b):
            # or solar_def name(a,b):
            rest = line[len("solar_def "):].strip()
            if not rest.endswith(":"):
                raise SyntaxError("Oh noes! Solar threw an error: `solar_def` must end with ':'")
            rest = rest[:-1].strip()
            # find name and params
            m = None
            if "(" in rest and rest.endswith(")"):
                name = rest[:rest.index("(")].strip()
                params_raw = rest[rest.index("(")+1:-1].strip()
                params = [p.strip() for p in params_raw.split(",") if p.strip()] if params_raw else []
            else:
                # allow: solar_def foo (a,b)
                parts = rest.split(None, 1)
                if len(parts) != 2:
                    raise SyntaxError("Oh noes! Solar threw an error: solar_def syntax is `solar_def name(params...):`")
                name = parts[0]
                pr = parts[1].strip()
                if not (pr.startswith("(") and pr.endswith(")")):
                    raise SyntaxError("Oh noes! Solar threw an error: solar_def params must be in parentheses")
                params_raw = pr[1:-1].strip()
                params = [p.strip() for p in params_raw.split(",") if p.strip()] if params_raw else []

            if not name.isidentifier():
                raise SyntaxError("Oh noes! Solar threw an error: invalid function name in solar_def")
            for p in params:
                if not p.isidentifier():
                    raise SyntaxError("Oh noes! Solar threw an error: invalid parameter name in solar_def")

            body: list[Stmt] = []
            cur_list().append(SolarDef(name, params, body))
            stack.append(("solar_def", body))
            continue

        # if/elif/else blocks (Solar-style)
        if line.startswith("if "):
            if not line.endswith(":"):
                # allow old-style: if expr then <stmt>
                if " then " in line:
                    cond_raw, then_raw = line[3:].split(" then ", 1)
                    cond = parse_expr_tokens(_expr_tokenize(cond_raw.strip()))
                    then_stmt = parse_solar_stmt(then_raw.strip())
                    cur_list().append(If(cond, [then_stmt], [], []))
                    continue
                raise SyntaxError("Oh noes! Solar threw an error: `if` must end with ':' or use `then`")
            cond_raw = line[3:-1].strip()
            cond = parse_expr_tokens(_expr_tokenize(cond_raw))
            then_body: list[Stmt] = []
            node = If(cond, then_body, [], [])
            cur_list().append(node)
            stack.append(("if_then", then_body))
            continue

        if line.startswith("elif "):
            if not line.endswith(":"):
                raise SyntaxError("Oh noes! Solar threw an error: `elif` must end with ':'")
            if len(stack) < 2 or not stack[-2][0].startswith("if_"):
                raise SyntaxError("Oh noes! Solar threw an error: `elif` outside of `if` block")
            # pop current branch body list
            stack.pop()
            cond_raw = line[5:-1].strip()
            cond = parse_expr_tokens(_expr_tokenize(cond_raw))
            # find owning If node: it is last statement in parent list
            parent_list = stack[-1][1]
            if not parent_list or not isinstance(parent_list[-1], If):
                raise SyntaxError("Oh noes! Solar threw an error: internal if/elif mismatch")
            ifnode: If = parent_list[-1]
            body: list[Stmt] = []
            ifnode.elifs.append((cond, body))
            stack.append(("if_elif", body))
            continue

        if line == "else:":
            if len(stack) < 2 or not stack[-2][0].startswith("if_"):
                raise SyntaxError("Oh noes! Solar threw an error: `else` outside of `if` block")
            stack.pop()
            parent_list = stack[-1][1]
            if not parent_list or not isinstance(parent_list[-1], If):
                raise SyntaxError("Oh noes! Solar threw an error: internal if/else mismatch")
            ifnode: If = parent_list[-1]
            body: list[Stmt] = []
            ifnode.else_body = body
            stack.append(("if_else", body))
            continue

        # while
        if line.startswith("while "):
            if not line.endswith(":"):
                raise SyntaxError("Oh noes! Solar threw an error: `while` must end with ':'")
            cond_raw = line[6:-1].strip()
            cond = parse_expr_tokens(_expr_tokenize(cond_raw))
            body: list[Stmt] = []
            cur_list().append(While(cond, body))
            stack.append(("while", body))
            continue

        # for i in a..b:
        if line.startswith("for "):
            if not line.endswith(":"):
                raise SyntaxError("Oh noes! Solar threw an error: `for` must end with ':'")
            inner = line[4:-1].strip()
            # expected: <name> in <a>..<b>
            if " in " not in inner:
                raise SyntaxError("Oh noes! Solar threw an error: for syntax: `for i in 0..10:`")
            var, rest = inner.split(" in ", 1)
            var = var.strip()
            rest = rest.strip()
            if not var.isidentifier():
                raise SyntaxError("Oh noes! Solar threw an error: invalid for loop variable name")
            if ".." not in rest:
                raise SyntaxError("Oh noes! Solar threw an error: for range must be `a..b`")
            a_raw, b_raw = rest.split("..", 1)
            start = parse_expr_tokens(_expr_tokenize(a_raw.strip()))
            end = parse_expr_tokens(_expr_tokenize(b_raw.strip()))
            body: list[Stmt] = []
            cur_list().append(ForRange(var, start, end, body))
            stack.append(("for", body))
            continue

        # pygame loop (block)
        if line == "pg loop:":
            body: list[Stmt] = []
            cur_list().append(PgLoop(body))
            stack.append(("pg_loop", body))
            continue

        # pygame on quit (block) inside pg loop
        if line == "pg event quit ->":
            if stack[-1][0] != "pg_loop":
                raise SyntaxError("Oh noes! Solar threw an error: `pg event quit ->` must be inside `pg loop:`")
            body: list[Stmt] = []
            cur_list().append(PgOnQuit(body))
            stack.append(("pg_on_quit", body))
            continue

        # pygame key held (block) inside pg loop
        if line.startswith("pg key held ") and line.endswith("->"):
            if stack[-1][0] != "pg_loop":
                raise SyntaxError("Oh noes! Solar threw an error: `pg key held ... ->` must be inside `pg loop:`")
            key = line[len("pg key held "):-2].strip()
            if not key:
                raise SyntaxError("Oh noes! Solar threw an error: missing key name in `pg key held ... ->`")
            body: list[Stmt] = []
            cur_list().append(PgKeyHeld(key, body))
            stack.append(("pg_key_held", body))
            continue

        # return
        if line == "return":
            cur_list().append(Return(None))
            continue
        if line.startswith("return "):
            expr = parse_expr_tokens(_expr_tokenize(line[len("return "):].strip()))
            cur_list().append(Return(expr))
            continue

        # single-line Solar statement parse
        cur_list().append(parse_solar_stmt(line))

    if len(stack) != 1:
        raise SyntaxError("Oh noes! Solar threw an error: missing `end` for a block")

    return program


def _expr_tokenize(expr_src: str) -> list[Token]:
    # Tokenize expressions with punctuation separated: ( ) [ ] , operators.
    # We reuse tokenize_line for quoted strings, then split punctuation in unquoted tokens.
    base = tokenize_line(expr_src)
    out: list[Token] = []
    punct = set("()[],")
    # operators we want to isolate
    ops = ["<=", ">=", "==", "!=", "+", "-", "*", "/", "%", "<", ">", "[", "]", "(", ")", ","]
    # We'll do a simple char scan for unquoted tokens.
    for text, quoted in base:
        if quoted:
            out.append((text, True))
            continue
        s = text
        # split combined operators/punct, e.g. "x+1", "a==b", "arr[0]"
        buf = ""
        j = 0
        while j < len(s):
            # check 2-char ops first
            if j + 1 < len(s) and s[j:j+2] in ("<=", ">=", "==", "!="):
                if buf:
                    out.append((buf, False))
                    buf = ""
                out.append((s[j:j+2], False))
                j += 2
                continue
            ch = s[j]
            if ch in "+-*/%<>()[],[]":
                if buf:
                    out.append((buf, False))
                    buf = ""
                out.append((ch, False))
                j += 1
                continue
            # allow dot in identifiers? no, but keep as token (will become string or error)
            buf += ch
            j += 1
        if buf:
            out.append((buf, False))
    # merge keywords operators: and/or/not are already tokens
    return out


def parse_solar_stmt(line: str) -> Stmt:
    # Use tokenize_line for statements (keeps quoted strings as single tokens).
    parts_q = tokenize_line(line)
    parts = [t for (t, _q) in parts_q]
    quoted = [q for (_t, q) in parts_q]
    if not parts:
        return PyRaw(line)

    head = parts[0]

    # let x = <expr...>
    if head == "let":
        if len(parts) < 4 or parts[2] != "=":
            raise SyntaxError("Oh noes! Solar threw an error: expected `let <name> = <expr>`")
        name = parts[1]
        if not name.isidentifier():
            raise SyntaxError("Oh noes! Solar threw an error: invalid variable name in let")
        expr_tokens = _expr_tokenize(" ".join(_reconstruct_tokens(parts_q[3:])))
        expr = parse_expr_tokens(expr_tokens)
        return Let(name, expr)

    # print <expr...> [more expr...]
    if head == "print":
        if len(parts) < 2:
            raise SyntaxError("Oh noes! Solar threw an error: expected `print <expr>`")
        # split by whitespace: each token sequence is an expression if it has parens etc.
        # simplest: treat remaining as separate expressions by token groups separated by commas not supported here,
        # but common usage is `print a b "x"` -> treat each as single-token expr.
        items: list[ExprNode] = []
        for (tok, was_q) in parts_q[1:]:
            items.append(parse_expr_tokens(_expr_tokenize(_token_to_expr_src(tok, was_q))))
        return Print(items)

    # call <func> <args...>
    if head == "call":
        if len(parts) < 2:
            raise SyntaxError("Oh noes! Solar threw an error: expected `call <func> [args...]`")
        fn = parts[1]
        args: list[ExprNode] = []
        for (tok, was_q) in parts_q[2:]:
            args.append(parse_expr_tokens(_expr_tokenize(_token_to_expr_src(tok, was_q))))
        return CallStmt(fn, args)

    # UI
    if head == "ui":
        if len(parts) < 3:
            raise SyntaxError("Oh noes! Solar threw an error: bad ui statement")
        cmd = parts[1]
        if cmd == "window":
            return UiWindow(parts[2])
        if cmd == "title":
            return UiTitle(parts[2], parse_expr_tokens(_expr_tokenize(_token_to_expr_src(parts_q[3][0], parts_q[3][1]))))
        if cmd == "size":
            return UiSize(parts[2],
                          parse_expr_tokens(_expr_tokenize(parts[3])),
                          parse_expr_tokens(_expr_tokenize(parts[4])))
        if cmd == "bg":
            return UiBg(parts[2], parse_expr_tokens(_expr_tokenize(_token_to_expr_src(parts_q[3][0], parts_q[3][1]))))
        if cmd == "fg":
            return UiFg(parts[2], parse_expr_tokens(_expr_tokenize(_token_to_expr_src(parts_q[3][0], parts_q[3][1]))))
        if cmd == "label":
            if "at" not in parts:
                raise SyntaxError("Oh noes! Solar threw an error: label needs `at x y`")
            at_i = parts.index("at")
            win = parts[2]
            name = parts[3]
            text = parse_expr_tokens(_expr_tokenize(_token_to_expr_src(parts_q[4][0], parts_q[4][1])))
            x = parse_expr_tokens(_expr_tokenize(parts[at_i + 1]))
            y = parse_expr_tokens(_expr_tokenize(parts[at_i + 2]))
            return UiLabel(win, name, text, x, y)
        if cmd == "entry":
            if "at" not in parts:
                raise SyntaxError("Oh noes! Solar threw an error: entry needs `at x y`")
            at_i = parts.index("at")
            return UiEntry(parts[2], parts[3],
                           parse_expr_tokens(_expr_tokenize(parts[at_i + 1])),
                           parse_expr_tokens(_expr_tokenize(parts[at_i + 2])))
        if cmd == "checkbox":
            if "at" not in parts:
                raise SyntaxError("Oh noes! Solar threw an error: checkbox needs `at x y`")
            at_i = parts.index("at")
            win = parts[2]
            name = parts[3]
            text = parse_expr_tokens(_expr_tokenize(_token_to_expr_src(parts_q[4][0], parts_q[4][1])))
            x = parse_expr_tokens(_expr_tokenize(parts[at_i + 1]))
            y = parse_expr_tokens(_expr_tokenize(parts[at_i + 2]))
            return UiCheckbox(win, name, text, x, y)
        if cmd == "slider":
            if "from" not in parts or "to" not in parts or "at" not in parts:
                raise SyntaxError("Oh noes! Solar threw an error: slider needs `from a to b at x y`")
            from_i = parts.index("from")
            to_i = parts.index("to")
            at_i = parts.index("at")
            return UiSlider(parts[2], parts[3],
                            parse_expr_tokens(_expr_tokenize(parts[from_i + 1])),
                            parse_expr_tokens(_expr_tokenize(parts[to_i + 1])),
                            parse_expr_tokens(_expr_tokenize(parts[at_i + 1])),
                            parse_expr_tokens(_expr_tokenize(parts[at_i + 2])))
        if cmd == "button":
            if "at" not in parts or "do" not in parts:
                raise SyntaxError("Oh noes! Solar threw an error: button needs `at x y do ...`")
            at_i = parts.index("at")
            do_i = parts.index("do")
            win = parts[2]
            name = parts[3]
            text = parse_expr_tokens(_expr_tokenize(_token_to_expr_src(parts_q[4][0], parts_q[4][1])))
            x = parse_expr_tokens(_expr_tokenize(parts[at_i + 1]))
            y = parse_expr_tokens(_expr_tokenize(parts[at_i + 2]))
            fn = parts[do_i + 1]
            args: list[ExprNode] = []
            for (tok, was_q) in parts_q[do_i + 2:]:
                args.append(parse_expr_tokens(_expr_tokenize(_token_to_expr_src(tok, was_q))))
            return UiButton(win, name, text, x, y, CallStmt(fn, args))
        if cmd == "bind":
            if "do" not in parts:
                raise SyntaxError("Oh noes! Solar threw an error: bind needs `do ...`")
            do_i = parts.index("do")
            win = parts[2]
            key = parts[3]
            fn = parts[do_i + 1]
            args: list[ExprNode] = []
            for (tok, was_q) in parts_q[do_i + 2:]:
                args.append(parse_expr_tokens(_expr_tokenize(_token_to_expr_src(tok, was_q))))
            return UiBind(win, key, CallStmt(fn, args))
        if cmd == "text":
            return UiText(parts[2], parse_expr_tokens(_expr_tokenize(_token_to_expr_src(parts_q[3][0], parts_q[3][1]))))
        if cmd == "set":
            return UiSet(parts[2], parse_expr_tokens(_expr_tokenize(_token_to_expr_src(parts_q[3][0], parts_q[3][1]))))
        if cmd == "get":
            if len(parts) < 5 or parts[3] != "into":
                raise SyntaxError("Oh noes! Solar threw an error: expected `ui get <var> into <name>`")
            return UiGetInto(parts[2], parts[4])
        if cmd == "run":
            return UiRun(parts[2])

        raise SyntaxError(f"Oh noes! Solar threw an error: unknown ui command: {cmd}")

    # Pygame DSL
    if head == "pg":
        if len(parts) < 2:
            raise SyntaxError("Oh noes! Solar threw an error: bad pg statement")
        cmd = parts[1]
        if cmd == "init":
            return PgInit()
        if cmd == "window":
            # pg window 800 600 title "X"
            if len(parts) < 4:
                raise SyntaxError("Oh noes! Solar threw an error: pg window <w> <h> [title <text>]")
            w = parse_expr_tokens(_expr_tokenize(parts[2]))
            h = parse_expr_tokens(_expr_tokenize(parts[3]))
            title = None
            if len(parts) >= 6 and parts[4] == "title":
                title = parse_expr_tokens(_expr_tokenize(_token_to_expr_src(parts_q[5][0], parts_q[5][1])))
            return PgWindow(w, h, title)
        if cmd == "fps":
            return PgFps(parse_expr_tokens(_expr_tokenize(parts[2])))
        if cmd == "var":
            # pg var vx 0
            name = parts[2]
            if not name.isidentifier():
                raise SyntaxError("Oh noes! Solar threw an error: invalid pg var name")
            value = parse_expr_tokens(_expr_tokenize(_token_to_expr_src(parts_q[3][0], parts_q[3][1])))
            return PgVar(name, value)
        if cmd == "rect":
            # pg rect player 50 50 40 40 color 255 80 80
            if "color" not in parts or len(parts) < 11:
                raise SyntaxError("Oh noes! Solar threw an error: pg rect <name> x y w h color r g b")
            name = parts[2]
            if not name.isidentifier():
                raise SyntaxError("Oh noes! Solar threw an error: invalid rect name")
            x = parse_expr_tokens(_expr_tokenize(parts[3]))
            y = parse_expr_tokens(_expr_tokenize(parts[4]))
            w = parse_expr_tokens(_expr_tokenize(parts[5]))
            h = parse_expr_tokens(_expr_tokenize(parts[6]))
            ci = parts.index("color")
            r = parse_expr_tokens(_expr_tokenize(parts[ci + 1]))
            g = parse_expr_tokens(_expr_tokenize(parts[ci + 2]))
            b = parse_expr_tokens(_expr_tokenize(parts[ci + 3]))
            return PgRect(name, x, y, w, h, r, g, b)
        if cmd == "clear":
            return PgClear(parse_expr_tokens(_expr_tokenize(parts[2])),
                           parse_expr_tokens(_expr_tokenize(parts[3])),
                           parse_expr_tokens(_expr_tokenize(parts[4])))
        if cmd == "draw":
            return PgDraw(parts[2])
        if cmd == "flip":
            return PgFlip()
        if cmd == "move":
            return PgMove(parts[2],
                          parse_expr_tokens(_expr_tokenize(parts[3])),
                          parse_expr_tokens(_expr_tokenize(parts[4])))
        if cmd == "set":
            return PgSet(parts[2], parse_expr_tokens(_expr_tokenize(_token_to_expr_src(parts_q[3][0], parts_q[3][1]))))
        if cmd == "add":
            return PgAdd(parts[2], parse_expr_tokens(_expr_tokenize(_token_to_expr_src(parts_q[3][0], parts_q[3][1]))))
        if cmd == "exit":
            return PgExit()

        raise SyntaxError(f"Oh noes! Solar threw an error: unknown pg command: {cmd}")

    # default: treat as function call statement: add 1 2
    fn = head
    args: list[ExprNode] = []
    for (tok, was_q) in parts_q[1:]:
        args.append(parse_expr_tokens(_expr_tokenize(_token_to_expr_src(tok, was_q))))
    return CallStmt(fn, args)


def _token_to_expr_src(tok: str, was_quoted: bool) -> str:
    # Keep quotedness by re-quoting for expression tokenizer
    if was_quoted:
        return repr(tok)
    return tok


def _reconstruct_tokens(toks: list[Token]) -> list[str]:
    out = []
    for t, q in toks:
        out.append(_token_to_expr_src(t, q))
    return out


# ---------------- Compilation (AST -> Python source) ----------------
def compile_program(program: list[Stmt]) -> str:
    module_prelude: list[str] = []
    run_lines: list[str] = []
    seen_imports: set[tuple[str, Optional[str]]] = set()

    module_prelude.append("# --- compiled by Solar v1.0.3 ---")

    # helper runtime inside __run
    run_lines.append("def __run(vars_, funcs):")
    run_lines.append("    __scope = []  # stack of local scopes (dict)")
    run_lines.append("    def __get(name):")
    run_lines.append("        for d in reversed(__scope):")
    run_lines.append("            if name in d:")
    run_lines.append("                return d[name]")
    run_lines.append("        return vars_.get(name)")
    run_lines.append("    def __set(name, value):")
    run_lines.append("        if __scope:")
    run_lines.append("            __scope[-1][name] = value")
    run_lines.append("        else:")
    run_lines.append("            vars_[name] = value")
    run_lines.append("        return value")
    run_lines.append("    def __call(fname, *args):")
    run_lines.append("        _fn = funcs.get(fname)")
    run_lines.append("        if _fn is None:")
    run_lines.append("            raise NameError(f'Oh noes! Solar threw an error: Unknown function: {fname}')")
    run_lines.append("        return _fn(*args)")
    run_lines.append("    def __solar_val(v):")
    run_lines.append("        try:")
    run_lines.append("            if hasattr(v, 'get') and callable(v.get):")
    run_lines.append("                return v.get()")
    run_lines.append("        except Exception:")
    run_lines.append("            pass")
    run_lines.append("        return v")
    run_lines.append("    def __print_many(*items):")
    run_lines.append("        out = []")
    run_lines.append("        for it in items:")
    run_lines.append("            out.append(__solar_val(it))")
    run_lines.append("        print(*out)")
    run_lines.append("")
    # UI state
    run_lines.append("    __wins = {}")
    run_lines.append("    __widgets = {}")
    run_lines.append("    __uivars = {}")
    run_lines.append("    __uistyle = {}  # per-window defaults: {'bg': ..., 'fg': ...}")
    run_lines.append("    def __style_defaults(win_name):")
    run_lines.append("        return __uistyle.get(win_name, {'bg': None, 'fg': None})")
    run_lines.append("    def __safe_config(widget, **kwargs):")
    run_lines.append("        for k, v in list(kwargs.items()):")
    run_lines.append("            if v is None:")
    run_lines.append("                kwargs.pop(k, None)")
    run_lines.append("        if not kwargs:")
    run_lines.append("            return")
    run_lines.append("        try:")
    run_lines.append("            widget.configure(**kwargs)")
    run_lines.append("        except Exception:")
    run_lines.append("            pass")
    run_lines.append("    def __apply_style(win_name, widget, kind: str):")
    run_lines.append("        st = __style_defaults(win_name)")
    run_lines.append("        bg = st.get('bg')")
    run_lines.append("        fg = st.get('fg')")
    run_lines.append("        if kind == 'root':")
    run_lines.append("            __safe_config(widget, fg_color=bg)")
    run_lines.append("            return")
    run_lines.append("        try:")
    run_lines.append("            cls = widget.__class__.__name__")
    run_lines.append("        except Exception:")
    run_lines.append("            cls = ''")
    run_lines.append("        if cls == 'CTkButton':")
    run_lines.append("            return")
    run_lines.append("        __safe_config(widget, text_color=fg)")
    run_lines.append("")
    # Pygame runtime state (created lazily when pg init is used)
    run_lines.append("    __pg = {'enabled': False}")
    run_lines.append("")

    # Compile statements
    def emit(stmt: Stmt, indent: int) -> None:
        pre = "    " * indent
        if isinstance(stmt, ContainImport):
            key = (stmt.module, stmt.alias)
            if key not in seen_imports:
                if stmt.alias:
                    module_prelude.append(f"import {stmt.module} as {stmt.alias}")
                else:
                    module_prelude.append(f"import {stmt.module}")
                seen_imports.add(key)
            return

        if isinstance(stmt, PyRaw):
            run_lines.append(pre + stmt.raw)
            return

        if isinstance(stmt, Let):
            run_lines.append(pre + f"__set({stmt.name!r}, {expr_to_py(stmt.expr)})")
            return

        if isinstance(stmt, Print):
            args = ", ".join(expr_to_py(e) for e in stmt.items)
            run_lines.append(pre + f"__print_many({args})")
            return

        if isinstance(stmt, CallStmt):
            args = ", ".join(expr_to_py(e) for e in stmt.args)
            run_lines.append(pre + f"__call({stmt.func_name!r}{', ' if args else ''}{args})")
            return

        if isinstance(stmt, Return):
            if stmt.expr is None:
                run_lines.append(pre + "return None")
            else:
                run_lines.append(pre + f"return {expr_to_py(stmt.expr)}")
            return

        if isinstance(stmt, If):
            run_lines.append(pre + f"if {expr_to_py(stmt.cond)}:")
            if stmt.then_body:
                for s in stmt.then_body:
                    emit(s, indent + 1)
            else:
                run_lines.append(pre + "    pass")
            for cond, body in stmt.elifs:
                run_lines.append(pre + f"elif {expr_to_py(cond)}:")
                if body:
                    for s in body:
                        emit(s, indent + 1)
                else:
                    run_lines.append(pre + "    pass")
            if stmt.else_body:
                run_lines.append(pre + "else:")
                for s in stmt.else_body:
                    emit(s, indent + 1)
            return

        if isinstance(stmt, While):
            run_lines.append(pre + f"while {expr_to_py(stmt.cond)}:")
            if stmt.body:
                for s in stmt.body:
                    emit(s, indent + 1)
            else:
                run_lines.append(pre + "    pass")
            return

        if isinstance(stmt, ForRange):
            run_lines.append(pre + f"for __i in range(int({expr_to_py(stmt.start)}), int({expr_to_py(stmt.end)}) + 1):")
            run_lines.append(pre + f"    __set({stmt.var!r}, __i)")
            if stmt.body:
                for s in stmt.body:
                    emit(s, indent + 1)
            else:
                run_lines.append(pre + "    pass")
            return

        if isinstance(stmt, SolarDef):
            # defines a Solar function and registers it in funcs
            fn_name = stmt.name
            params = stmt.params[:]
            py_params = ", ".join(params)
            run_lines.append(pre + f"def __solar_fn_{fn_name}({py_params}):")
            run_lines.append(pre + "    __scope.append({})")
            # set params into local scope
            for p in params:
                run_lines.append(pre + f"    __set({p!r}, {p})")
            # execute body
            if stmt.body:
                for s in stmt.body:
                    emit(s, indent + 2)
            else:
                run_lines.append(pre + "    pass")
            run_lines.append(pre + "    __scope.pop()")
            run_lines.append(pre + "    return None")
            run_lines.append(pre + f"funcs[{fn_name!r}] = __solar_fn_{fn_name}")
            return

        # ---- UI ----
        if isinstance(stmt, UiWindow):
            run_lines.append(pre + f"__wins[{stmt.name!r}] = ctk.CTk()")
            run_lines.append(pre + f"__uistyle[{stmt.name!r}] = {{'bg': None, 'fg': None}}")
            run_lines.append(pre + f"__apply_style({stmt.name!r}, __wins[{stmt.name!r}], 'root')")
            return

        if isinstance(stmt, UiTitle):
            run_lines.append(pre + f"__wins[{stmt.win!r}].title({expr_to_py(stmt.title)})")
            return

        if isinstance(stmt, UiSize):
            run_lines.append(pre + f"__wins[{stmt.win!r}].geometry(str({expr_to_py(stmt.w)}) + 'x' + str({expr_to_py(stmt.h)}))")
            return

        if isinstance(stmt, UiBg):
            run_lines.append(pre + f"__uistyle.setdefault({stmt.win!r}, {{'bg': None, 'fg': None}})['bg'] = {expr_to_py(stmt.color)}")
            run_lines.append(pre + f"__apply_style({stmt.win!r}, __wins[{stmt.win!r}], 'root')")
            run_lines.append(pre + "for __n, __w in list(__widgets.items()):")
            run_lines.append(pre + "    try:")
            run_lines.append(pre + f"        if getattr(__w, 'master', None) is __wins[{stmt.win!r}]:")
            run_lines.append(pre + f"            __apply_style({stmt.win!r}, __w, 'widget')")
            run_lines.append(pre + "    except Exception:")
            run_lines.append(pre + "        pass")
            return

        if isinstance(stmt, UiFg):
            run_lines.append(pre + f"__uistyle.setdefault({stmt.win!r}, {{'bg': None, 'fg': None}})['fg'] = {expr_to_py(stmt.color)}")
            run_lines.append(pre + "for __n, __w in list(__widgets.items()):")
            run_lines.append(pre + "    try:")
            run_lines.append(pre + f"        if getattr(__w, 'master', None) is __wins[{stmt.win!r}]:")
            run_lines.append(pre + f"            __apply_style({stmt.win!r}, __w, 'widget')")
            run_lines.append(pre + "    except Exception:")
            run_lines.append(pre + "        pass")
            return

        if isinstance(stmt, UiLabel):
            run_lines.append(pre + f"__widgets[{stmt.name!r}] = ctk.CTkLabel(__wins[{stmt.win!r}], text={expr_to_py(stmt.text)})")
            run_lines.append(pre + f"__apply_style({stmt.win!r}, __widgets[{stmt.name!r}], 'widget')")
            run_lines.append(pre + f"__widgets[{stmt.name!r}].place(x={expr_to_py(stmt.x)}, y={expr_to_py(stmt.y)})")
            return

        if isinstance(stmt, UiButton):
            cb_name = f"__cb_{stmt.name}"
            run_lines.append(pre + f"def {cb_name}():")
            # call into Solar function registry
            args = ", ".join(expr_to_py(e) for e in stmt.on_call.args)
            run_lines.append(pre + f"    __call({stmt.on_call.func_name!r}{', ' if args else ''}{args})")
            run_lines.append(pre + f"__st = __style_defaults({stmt.win!r})")
            run_lines.append(pre + "__btn_tc = __st.get('fg')")
            run_lines.append(pre + f"__widgets[{stmt.name!r}] = ctk.CTkButton(__wins[{stmt.win!r}], text={expr_to_py(stmt.text)}, command={cb_name}, text_color=__btn_tc if __btn_tc is not None else None)")
            run_lines.append(pre + f"__widgets[{stmt.name!r}].place(x={expr_to_py(stmt.x)}, y={expr_to_py(stmt.y)})")
            return

        if isinstance(stmt, UiEntry):
            run_lines.append(pre + f"__uivars[{stmt.name!r}] = tk.StringVar()")
            run_lines.append(pre + f"__widgets[{stmt.name!r}] = ctk.CTkEntry(__wins[{stmt.win!r}], textvariable=__uivars[{stmt.name!r}])")
            run_lines.append(pre + f"__apply_style({stmt.win!r}, __widgets[{stmt.name!r}], 'widget')")
            run_lines.append(pre + f"__widgets[{stmt.name!r}].place(x={expr_to_py(stmt.x)}, y={expr_to_py(stmt.y)})")
            run_lines.append(pre + f"__set({stmt.name!r}, __uivars[{stmt.name!r}])")
            return

        if isinstance(stmt, UiSlider):
            run_lines.append(pre + f"__uivars[{stmt.name!r}] = tk.IntVar()")
            run_lines.append(pre + f"__widgets[{stmt.name!r}] = ctk.CTkSlider(__wins[{stmt.win!r}], from_={expr_to_py(stmt.minv)}, to={expr_to_py(stmt.maxv)}, variable=__uivars[{stmt.name!r}])")
            run_lines.append(pre + f"__apply_style({stmt.win!r}, __widgets[{stmt.name!r}], 'widget')")
            run_lines.append(pre + f"__widgets[{stmt.name!r}].place(x={expr_to_py(stmt.x)}, y={expr_to_py(stmt.y)})")
            run_lines.append(pre + f"__set({stmt.name!r}, __uivars[{stmt.name!r}])")
            return

        if isinstance(stmt, UiCheckbox):
            run_lines.append(pre + f"__uivars[{stmt.name!r}] = tk.IntVar()")
            run_lines.append(pre + f"__widgets[{stmt.name!r}] = ctk.CTkCheckBox(__wins[{stmt.win!r}], text={expr_to_py(stmt.text)}, variable=__uivars[{stmt.name!r}])")
            run_lines.append(pre + f"__apply_style({stmt.win!r}, __widgets[{stmt.name!r}], 'widget')")
            run_lines.append(pre + f"__widgets[{stmt.name!r}].place(x={expr_to_py(stmt.x)}, y={expr_to_py(stmt.y)})")
            run_lines.append(pre + f"__set({stmt.name!r}, __uivars[{stmt.name!r}])")
            return

        if isinstance(stmt, UiBind):
            bind_name = f"__bind_{stmt.call.func_name}_{stmt.key.replace('-', '_')}"
            run_lines.append(pre + f"def {bind_name}(event=None):")
            args = ", ".join(expr_to_py(e) for e in stmt.call.args)
            run_lines.append(pre + f"    __call({stmt.call.func_name!r}{', ' if args else ''}{args})")
            run_lines.append(pre + f"__wins[{stmt.win!r}].bind('<{stmt.key}>', {bind_name})")
            return

        if isinstance(stmt, UiText):
            run_lines.append(pre + f"__w = __widgets.get({stmt.widget!r})")
            run_lines.append(pre + f"if __w is None: raise NameError('Oh noes! Solar threw an error: Unknown widget: {stmt.widget}')")
            run_lines.append(pre + "try:")
            run_lines.append(pre + f"    __w.configure(text={expr_to_py(stmt.text)})")
            run_lines.append(pre + "except Exception:")
            run_lines.append(pre + "    pass")
            return

        if isinstance(stmt, UiSet):
            run_lines.append(pre + f"__v = __get({stmt.varname!r})")
            run_lines.append(pre + f"if __v is None: raise NameError('Oh noes! Solar threw an error: Unknown ui var: {stmt.varname}')")
            run_lines.append(pre + "try:")
            run_lines.append(pre + f"    __v.set({expr_to_py(stmt.value)})")
            run_lines.append(pre + "except Exception:")
            run_lines.append(pre + f"    __set({stmt.varname!r}, {expr_to_py(stmt.value)})")
            return

        if isinstance(stmt, UiGetInto):
            run_lines.append(pre + f"__v = __get({stmt.varname!r})")
            run_lines.append(pre + f"if __v is None: raise NameError('Oh noes! Solar threw an error: Unknown ui var: {stmt.varname}')")
            run_lines.append(pre + "try:")
            run_lines.append(pre + f"    __set({stmt.target!r}, __v.get())")
            run_lines.append(pre + "except Exception:")
            run_lines.append(pre + f"    __set({stmt.target!r}, __v)")
            return

        if isinstance(stmt, UiRun):
            run_lines.append(pre + f"__wins[{stmt.win!r}].mainloop()")
            return

        # ---- Pygame ----
        if isinstance(stmt, PgInit):
            run_lines.append(pre + "if not __pg.get('enabled'):")
            run_lines.append(pre + "    __pg['enabled'] = True")
            run_lines.append(pre + "    __pg['pygame'] = __import__('pygame')")
            run_lines.append(pre + "    __pg['pygame'].init()")
            run_lines.append(pre + "    __pg['clock'] = __pg['pygame'].time.Clock()")
            run_lines.append(pre + "    __pg['screen'] = None")
            run_lines.append(pre + "    __pg['fps'] = 60")
            run_lines.append(pre + "    __pg['rects'] = {}")
            run_lines.append(pre + "    __pg['colors'] = {}")
            run_lines.append(pre + "    __pg['running'] = True")
            return

        if isinstance(stmt, PgWindow):
            run_lines.append(pre + "if not __pg.get('enabled'): raise RuntimeError('pg init must be called before pg window')")
            run_lines.append(pre + f"__pg['screen'] = __pg['pygame'].display.set_mode((int({expr_to_py(stmt.w)}), int({expr_to_py(stmt.h)})))")
            if stmt.title is not None:
                run_lines.append(pre + f"__pg['pygame'].display.set_caption(str({expr_to_py(stmt.title)}))")
            return

        if isinstance(stmt, PgFps):
            run_lines.append(pre + "if not __pg.get('enabled'): raise RuntimeError('pg init must be called before pg fps')")
            run_lines.append(pre + f"__pg['fps'] = int({expr_to_py(stmt.fps)})")
            return

        if isinstance(stmt, PgVar):
            run_lines.append(pre + f"__set({stmt.name!r}, {expr_to_py(stmt.value)})")
            return

        if isinstance(stmt, PgRect):
            run_lines.append(pre + "if not __pg.get('enabled'): raise RuntimeError('pg init must be called before pg rect')")
            run_lines.append(pre + f"__pg['rects'][{stmt.name!r}] = __pg['pygame'].Rect(int({expr_to_py(stmt.x)}), int({expr_to_py(stmt.y)}), int({expr_to_py(stmt.w)}), int({expr_to_py(stmt.h)}))")
            run_lines.append(pre + f"__pg['colors'][{stmt.name!r}] = (int({expr_to_py(stmt.r)}), int({expr_to_py(stmt.g)}), int({expr_to_py(stmt.b)}))")
            return

        if isinstance(stmt, PgClear):
            run_lines.append(pre + "if not __pg.get('enabled'): raise RuntimeError('pg init must be called before pg clear')")
            run_lines.append(pre + f"__pg['screen'].fill((int({expr_to_py(stmt.r)}), int({expr_to_py(stmt.g)}), int({expr_to_py(stmt.b)})))")
            return

        if isinstance(stmt, PgDraw):
            run_lines.append(pre + "if not __pg.get('enabled'): raise RuntimeError('pg init must be called before pg draw')")
            run_lines.append(pre + f"__pg['pygame'].draw.rect(__pg['screen'], __pg['colors'].get({stmt.rect_name!r}, (255,255,255)), __pg['rects'].get({stmt.rect_name!r}))")
            return

        if isinstance(stmt, PgFlip):
            run_lines.append(pre + "if not __pg.get('enabled'): raise RuntimeError('pg init must be called before pg flip')")
            run_lines.append(pre + "__pg['pygame'].display.flip()")
            run_lines.append(pre + "__pg['clock'].tick(__pg.get('fps', 60))")
            return

        if isinstance(stmt, PgMove):
            run_lines.append(pre + "if not __pg.get('enabled'): raise RuntimeError('pg init must be called before pg move')")
            run_lines.append(pre + f"__r = __pg['rects'].get({stmt.rect_name!r})")
            run_lines.append(pre + "if __r is not None:")
            run_lines.append(pre + f"    __r.x += int({expr_to_py(stmt.dx)})")
            run_lines.append(pre + f"    __r.y += int({expr_to_py(stmt.dy)})")
            return

        if isinstance(stmt, PgSet):
            run_lines.append(pre + f"__set({stmt.name!r}, {expr_to_py(stmt.value)})")
            return

        if isinstance(stmt, PgAdd):
            run_lines.append(pre + f"__set({stmt.name!r}, (__get({stmt.name!r}) or 0) + ({expr_to_py(stmt.delta)}))")
            return

        if isinstance(stmt, PgExit):
            run_lines.append(pre + "__pg['running'] = False")
            return

        if isinstance(stmt, PgOnQuit):
            run_lines.append(pre + "# pg event quit ->")
            run_lines.append(pre + "for __ev in __pg['pygame'].event.get():")
            run_lines.append(pre + "    if __ev.type == __pg['pygame'].QUIT:")
            if stmt.body:
                for s in stmt.body:
                    emit(s, indent + 2)
            else:
                run_lines.append(pre + "        __pg['running'] = False")
            return

        if isinstance(stmt, PgKeyHeld):
            # allow common keys: A, D, W, S, LEFT, RIGHT, UP, DOWN, SPACE, ESC
            run_lines.append(pre + "if not __pg.get('enabled'): raise RuntimeError('pg init must be called before pg key')")
            run_lines.append(pre + "__keys = __pg['pygame'].key.get_pressed()")
            run_lines.append(pre + f"__k = {stmt.key!r}.upper()")
            run_lines.append(pre + "__keymap = {")
            run_lines.append(pre + "    'A': __pg['pygame'].K_a, 'D': __pg['pygame'].K_d, 'W': __pg['pygame'].K_w, 'S': __pg['pygame'].K_s,")
            run_lines.append(pre + "    'LEFT': __pg['pygame'].K_LEFT, 'RIGHT': __pg['pygame'].K_RIGHT, 'UP': __pg['pygame'].K_UP, 'DOWN': __pg['pygame'].K_DOWN,")
            run_lines.append(pre + "    'SPACE': __pg['pygame'].K_SPACE, 'ESC': __pg['pygame'].K_ESCAPE,")
            run_lines.append(pre + "}")
            run_lines.append(pre + "__kc = __keymap.get(__k)")
            run_lines.append(pre + "if __kc is not None and __keys[__kc]:")
            if stmt.body:
                for s in stmt.body:
                    emit(s, indent + 1)
            else:
                run_lines.append(pre + "    pass")
            return

        if isinstance(stmt, PgLoop):
            run_lines.append(pre + "if not __pg.get('enabled'): raise RuntimeError('pg init must be called before pg loop')")
            run_lines.append(pre + "while __pg.get('running', True):")
            if stmt.body:
                for s in stmt.body:
                    emit(s, indent + 1)
            else:
                run_lines.append(pre + "    pass")
            return

        raise TypeError(f"Oh noes! Solar threw an error: Unknown statement: {stmt}")

    for st in program:
        emit(st, 1)

    run_lines.append("")
    run_lines.append("# end")

    return "\n".join(module_prelude) + "\n\n" + "\n".join(run_lines)


# ---------------- Runtime ----------------
def run_compiled(py_src: str, funcs: dict[str, Callable[..., Any]]) -> None:
    tree = ast.parse(py_src, mode="exec")

    safe_globals = {
        "__builtins__": {
            "__import__": __import__,
            "print": print,

            # exceptions
            "NameError": NameError,
            "TypeError": TypeError,
            "ValueError": ValueError,
            "Exception": Exception,
            "RuntimeError": RuntimeError,

            # basic types / helpers
            "str": str,
            "int": int,
            "float": float,
            "len": len,
            "range": range,
            "list": list,
            "dict": dict,
            "set": set,
            "tuple": tuple,

            # reflection helpers (used by generated code & passthrough)
            "getattr": getattr,
            "hasattr": hasattr,
            "callable": callable,
        },
        "tk": tk,
        "ctk": ctk,
    }

    local_env: dict[str, Any] = {}
    exec(compile(tree, filename="<solar_lang>", mode="exec"), safe_globals, local_env)

    vars_: dict[str, Any] = {}
    local_env["__run"](vars_, funcs)


# ---------------- Built-in function registry ----------------
def default_funcs() -> dict[str, Callable[..., Any]]:
    funcs: dict[str, Callable[..., Any]] = {}

    # math
    funcs["add"] = lambda a, b: a + b
    funcs["sub"] = lambda a, b: a - b
    funcs["mul"] = lambda a, b: a * b
    funcs["div"] = lambda a, b: a / b
    funcs["mod"] = lambda a, b: a % b

    # conversions
    funcs["str"] = lambda x: str(x.get() if hasattr(x, "get") and callable(x.get) else x)
    funcs["int"] = lambda x: int(x.get() if hasattr(x, "get") and callable(x.get) else x)
    funcs["float"] = lambda x: float(x.get() if hasattr(x, "get") and callable(x.get) else x)

    # common utils
    funcs["len"] = lambda x: len(x.get() if hasattr(x, "get") and callable(x.get) else x)
    funcs["print"] = lambda *items: print(*[it.get() if hasattr(it, "get") and callable(it.get) else it for it in items])
    funcs["print_many"] = funcs["print"]

    # string helpers
    funcs["upper"] = lambda s: str(s).upper()
    funcs["lower"] = lambda s: str(s).lower()
    funcs["replace"] = lambda s, a, b: str(s).replace(str(a), str(b))
    funcs["contains"] = lambda s, sub: (str(sub) in str(s))

    return funcs


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--file", required=True, help="Path .solar source file")
    args = ap.parse_args()

    with open(args.file, "r", encoding="utf-8") as f:
        src = f.read()

    program = parse_prog(src)
    py_src = compile_program(program)

    funcs = default_funcs()
    run_compiled(py_src, funcs)


if __name__ == "__main__":
    main()
