#BUGS
# pt_urlopen_patch injection throwing off line numbers in bug output


from browser import document, window, aio, timer, console
import sys
from io import StringIO
import traceback
from urllib.parse import unquote, parse_qs
import json
import base64
import datetime
from pt_urlopen_patch import open as patched_open  # optional, but nice to fail fast if missing
import re
import html
import time

PT_OPEN_PROLOGUE_LINE = "from pt_urlopen_patch import open as open"


API_URL = "https://njl9djgkmg.execute-api.us-east-1.amazonaws.com/prod"

code = document["code"]
gutter = document["gutter"]
output_pre = document["output_pre"]
errors_pre = document["errors_pre"]
stdin_field = document["stdin_field"]

stdin_button = document["stdin_submit"]
# tab_spinner = document["tabWidth"]


# ----------------------------
#   WORKER CAPABILITY CHECK
# ----------------------------
# Worker pathway (py_worker.js) uses SharedArrayBuffer + Atomics for blocking input().
# That requires cross-origin isolation; in many iframe embeds (e.g., Canvas) it's unavailable.
WORKER_CAPABLE = False
WORKER_REASON = ""

def _js_bool(expr: str) -> bool:
    try:
        return bool(window.eval(expr))
    except Exception:
        return False

def _compute_worker_capability():
    global WORKER_CAPABLE, WORKER_REASON
    has_worker = _js_bool("typeof Worker !== 'undefined'")
    has_sab = _js_bool("typeof SharedArrayBuffer !== 'undefined'")
    coi = _js_bool("self.crossOriginIsolated === true")
    # iframe detection is diagnostic only; capability is based on SAB+COI availability.
    in_iframe = _js_bool("(()=>{try{return self!==top}catch(e){return true}})()")

    if not has_worker:
        WORKER_CAPABLE = False
        WORKER_REASON = "Worker API unavailable"
        return
    if not has_sab:
        WORKER_CAPABLE = False
        WORKER_REASON = "SharedArrayBuffer unavailable"
        return
    if not coi:
        WORKER_CAPABLE = False
        WORKER_REASON = "not crossOriginIsolated" + (" (embedded)" if in_iframe else "")
        return

    WORKER_CAPABLE = True
    WORKER_REASON = ""

def _apply_worker_capability_ui():
    # If worker path isn't viable, disable + hide the checkbox so users don't pick a broken mode.
    if WORKER_CAPABLE:
        return
    try:
        cb = document["force_async"]
        cb.checked = False
        cb.disabled = True
        try:
            cb.parentElement.style.display = "none"
        except Exception:
            pass
    except Exception:
        pass

_compute_worker_capability()
_apply_worker_capability_ui()





################################
# output area support for ANSI colors
################################

ANSI_RE = re.compile(r"\x1b\[([0-9;]*)m")

COLOR_MAP = {
    30: "ansi-black",
    31: "ansi-red",
    32: "ansi-green",
    33: "ansi-yellow",
    34: "ansi-blue",
    35: "ansi-magenta",
    36: "ansi-cyan",
    37: "ansi-white",
    90: "ansi-bright-black",
    91: "ansi-bright-red",
    92: "ansi-bright-green",
    93: "ansi-bright-yellow",
    94: "ansi-bright-blue",
    95: "ansi-bright-magenta",
    96: "ansi-bright-cyan",
    97: "ansi-bright-white",
}

################################
#   ANSI TO HTML               #
################################
# called by run_code_sync() and run_code_async()

def ansi_to_html(text: str) -> str:
    # Escape HTML first so user prints don't become HTML
    text = html.escape(text)

    out = []
    classes = []

    def open_span():
        if classes:
            out.append(f'<span class="{" ".join(classes)}">')

    def close_span():
        if classes:
            out.append("</span>")

    i = 0
    for m in ANSI_RE.finditer(text):
        # plain chunk before escape code
        chunk = text[i:m.start()]
        if chunk:
            out.append(chunk)

        params = m.group(1)
        codes = [int(c) for c in params.split(";") if c.strip() != ""] if params else [0]

        # apply codes
        for code in codes:
            if code == 0:
                close_span()
                classes = []
            elif code == 1:
                if "ansi-bold" not in classes:
                    # if we already have a span open, reopen with new classes
                    close_span()
                    classes.append("ansi-bold")
                    open_span()
                    continue
            elif code in COLOR_MAP:
                close_span()
                # remove any previous color class
                classes = [c for c in classes if not c.startswith("ansi-") or c == "ansi-bold"]
                classes.append(COLOR_MAP[code])
                open_span()
                continue

        # if we got here and had reset, ensure span status consistent
        if codes == [0]:
            # nothing open now
            pass
        else:
            # if no span open yet but we have classes, ensure it's open
            # (only needed in some flows)
            if classes and (not out or not out[-1].startswith("<span")):
                open_span()

        i = m.end()

    # tail
    tail = text[i:]
    if tail:
        out.append(tail)

    # close any open span
    if classes:
        out.append("</span>")

    return "".join(out)


################################
#   debug logging              #
################################

DEBUG = False  # flip True when needed

def dlog(*args):
    if DEBUG:
        console.log(*args)

def dwarn(*args):
    if DEBUG:
        console.warn(*args)


################################
#   MAX CODE TEXT AREA HEIGHT  #
################################

editor_container = document.select(".editor-container")[0]
MAX_EDITOR_HEIGHT_PX = 800

################################
#  ADJUST EDITOR HEIGHT        #
################################

# called by load_code_from_url() and after code changes
def adjust_editor_height():
    code.style.height = "auto"
    gutter.style.height = "auto"

    h_code = code.scrollHeight
    h_gutter = gutter.scrollHeight
    desired = max(h_code, h_gutter)

    if desired > MAX_EDITOR_HEIGHT_PX:
        desired = MAX_EDITOR_HEIGHT_PX

    # --- NEW: snap container height to whole text lines ---
    try:
        lh = float(window.getComputedStyle(code).lineHeight.replace("px", ""))
        if lh > 0:
            # subtract container borders so snapping is stable
            ecs = window.getComputedStyle(editor_container)
            border_top = float(ecs.borderTopWidth.replace("px", ""))
            border_bottom = float(ecs.borderBottomWidth.replace("px", ""))

            inner = max(0.0, desired - border_top - border_bottom)
            inner_snapped = round(inner / lh) * lh
            desired = int(round(inner_snapped + border_top + border_bottom))
    except Exception:
        pass
    # --- END NEW ---

    editor_container.style.height = f"{desired}px"
    code.style.height = "100%"
    gutter.style.height = "100%"


########################
#   TAB WIDTH CHANGE   #
########################

# _TABIFY_INTERNAL_SET = False # true when tabify functions are running

TAB_WIDTH = 2
# _LAST_NUMERIC_TAB = "2"  # tracks last real selection so command options can snap back

# --- Tabify undo stack (format/repair only) ---
_TABIFY_UNDO = []
_TABIFY_REDO = []
_TABIFY_MAX = 20
_TABIFY_POST_TEXT = None  # editor text immediately after last tabify action


def _push_tabify_undo():
    """Call immediately before tabify_format/tabify_repair modifies code.value."""
    try:
        state = {
            "text": code.value,
            "start": int(code.selectionStart),
            "end": int(code.selectionEnd),
            "scroll": int(code.scrollTop),
        }
    except Exception:
        state = {"text": code.value, "start": 0, "end": 0, "scroll": 0}

    if _TABIFY_UNDO and _TABIFY_UNDO[-1]["text"] == state["text"]:
        return

    _TABIFY_UNDO.append(state)
    if len(_TABIFY_UNDO) > _TABIFY_MAX:
        _TABIFY_UNDO.pop(0)

    # any new edit clears redo history
    _TABIFY_REDO.clear()

def undo_tabify():
    if not _TABIFY_UNDO:
        return
    try:
        cur = {
            "text": code.value,
            "start": int(code.selectionStart),
            "end": int(code.selectionEnd),
            "scroll": int(code.scrollTop),
        }
    except Exception:
        cur = {"text": code.value, "start": 0, "end": 0, "scroll": 0}

    state = _TABIFY_UNDO.pop()
    _TABIFY_REDO.append(cur)

    code.value = state["text"]
    try:
        code.selectionStart = state["start"]
        code.selectionEnd = state["end"]
        code.scrollTop = state["scroll"]
    except Exception:
        pass

def redo_tabify():
    if not _TABIFY_REDO:
        return
    try:
        cur = {
            "text": code.value,
            "start": int(code.selectionStart),
            "end": int(code.selectionEnd),
            "scroll": int(code.scrollTop),
        }
    except Exception:
        cur = {"text": code.value, "start": 0, "end": 0, "scroll": 0}

    state = _TABIFY_REDO.pop()
    _TABIFY_UNDO.append(cur)

    code.value = state["text"]
    try:
        code.selectionStart = state["start"]
        code.selectionEnd = state["end"]
        code.scrollTop = state["scroll"]
    except Exception:
        pass




def _leading_indent_columns(s, tabsize):
    """Count leading indentation width in columns, expanding tabs to tab stops."""
    col = 0
    i = 0
    while i < len(s):
        ch = s[i]
        if ch == " ":
            col += 1
        elif ch == "\t":
            step = tabsize - (col % tabsize)
            col += step
        else:
            break
        i += 1
    return col, i


def _compute_paren_depth_changes(line):
    """
    Net paren depth change for this line, ignoring strings and comments.
    Used to avoid reindenting continuation lines inside (...) / [...] / {...}.
    """
    depth = 0
    i = 0
    n = len(line)
    in_squote = False
    in_dquote = False
    escape = False

    while i < n:
        ch = line[i]

        if escape:
            escape = False
            i += 1
            continue

        if not in_squote and not in_dquote and ch == "#":
            break

        if ch == "\\":
            escape = True
            i += 1
            continue

        if not in_dquote and ch == "'":
            in_squote = not in_squote
            i += 1
            continue

        if not in_squote and ch == '"':
            in_dquote = not in_dquote
            i += 1
            continue

        if not in_squote and not in_dquote:
            if ch in "([{":
                depth += 1
            elif ch in ")]}":
                depth -= 1

        i += 1

    return depth


def _detect_indent_unit(lines):
    """
    Infer the indentation unit used for *block* indentation (2/4/6/8).
    Avoid being fooled by alignment/continuation indentation inside (), [], {}.

    Strategy:
      - Collect indentation widths only for lines that are not inside paren-continuations.
      - Score candidates {2,4,6,8} by:
          (10 * count(indent == cand)) + count(indent is multiple of cand)
        Then choose the best-scoring cand.
    """
    # collect indent columns for "block-like" lines
    widths = []
    paren_depth = 0

    for ln in lines:
        if not ln.strip():
            paren_depth += _compute_paren_depth_changes(ln)
            if paren_depth < 0:
                paren_depth = 0
            continue

        # ignore continuation lines (alignment indent)
        if paren_depth > 0:
            paren_depth += _compute_paren_depth_changes(ln)
            if paren_depth < 0:
                paren_depth = 0
            continue

        col, _ = _leading_indent_columns(ln, TAB_WIDTH)
        if col > 0:
            widths.append(col)

        paren_depth += _compute_paren_depth_changes(ln)
        if paren_depth < 0:
            paren_depth = 0

    if not widths:
        return 2

    candidates = (2, 4, 6, 8)
    best = 2
    best_score = -1

    for cand in candidates:
        exact = 0
        mult = 0
        for w in widths:
            if w == cand:
                exact += 1
            if w % cand == 0:
                mult += 1
        score = (10 * exact) + mult
        if score > best_score:
            best_score = score
            best = cand

    return best



def tabify_format():
    """
    FORMAT mode (structure-preserving):
    - Only runs if the current code compiles.
    - Converts leading indentation to spaces.
    - Preserves *indentation depth* as implied by the file's indent stack,
      then re-expresses it using TAB_WIDTH spaces per level.
    - Skips paren-continuation lines to avoid surprising reflow.
    """
    global _TABIFY_POST_TEXT
    _TABIFY_POST_TEXT = code.value
    _push_tabify_undo()
    src = code.value
    try:
        compile(src, "<pylite>", "exec")
    except Exception:
        return  # do nothing if invalid; repair handles that

    lines = src.split("\n")
    out = []

    paren_depth = 0

    # Source indentation stack in "columns" as they appear in the file.
    # This avoids unit-guessing bugs like 3 spaces -> 8 spaces when TAB_WIDTH=4.
    src_stack = [0]

    for ln in lines:
        if not ln.strip():
            out.append(ln)
            paren_depth += _compute_paren_depth_changes(ln)
            if paren_depth < 0:
                paren_depth = 0
            continue

        # Don't touch continuation alignment indentation inside (), [], {}.
        if paren_depth > 0:
            out.append(ln)
            paren_depth += _compute_paren_depth_changes(ln)
            if paren_depth < 0:
                paren_depth = 0
            continue

        col, cut = _leading_indent_columns(ln, TAB_WIDTH)

        # Adjust the source indent stack to match this line's indentation.
        top = src_stack[-1]

        if col > top:
            # New deeper level. Record the exact column width used in the source.
            src_stack.append(col)
        else:
            # Dedent or same level. Pop until we find matching col or the nearest lower.
            if col in src_stack:
                while src_stack and src_stack[-1] != col:
                    src_stack.pop()
            else:
                # Not an exact match (odd but can happen). Pop to nearest lower.
                while len(src_stack) > 1 and src_stack[-1] > col:
                    src_stack.pop()

        depth = len(src_stack) - 1
        new_indent = " " * (depth * TAB_WIDTH)

        out.append(new_indent + ln[cut:])

        paren_depth += _compute_paren_depth_changes(ln)
        if paren_depth < 0:
            paren_depth = 0

    code.value = "\n".join(out)



def tabify_repair():
    """
    REPAIR mode (heuristic):
    Goal: Turn "indentation-noise" into runnable Python by snapping indentation
    to plausible block levels using TAB_WIDTH spaces per indent level.

    Heuristics:
      - Maintain a stack of open blocks: (header_indent, opener_kind).
      - Normal statements default to the current block's *body indent*.
      - Dedent-keywords (elif/else/except/finally) align to matching opener indent.
      - Avoid reindenting parenthesis-continuation lines.
      - Convert leading tabs to spaces.

    This is best-effort; it prioritizes producing valid Python and minimizing
    surprise for common student mistakes.
    """
    global _TABIFY_POST_TEXT
    _TABIFY_POST_TEXT = code.value
    _push_tabify_undo()
    src = code.value
    lines = src.split("\n")

    # keywords that open a new indented suite
    OPENERS = {
        "if", "for", "while", "with", "try", "def", "class",
        "match",  # 3.10+
        "elif", "else", "except", "finally", "case",
    }

    # dedent keywords that must align to a matching opener's header indent
    DEDENT_TO_MATCH = {
        "elif": "if",
        "else": "if",
        "except": "try",
        "finally": "try",
        # "case" handled separately (it aligns to match-body)
    }

    def _first_token(s):
        # s is already stripped-left
        tok = []
        for ch in s:
            if ch.isalnum() or ch == "_":
                tok.append(ch)
            else:
                break
        return "".join(tok)

    def _strip_trailing_comment_naive(s):
        # naive but workable for student code; avoids heavy parsing
        # only strips if there's a '#' and we are not in a quoted string on that line
        # (we do a tiny scan for quotes; not perfect for triple quotes, but ok).
        in_sq = False
        in_dq = False
        esc = False
        for i, ch in enumerate(s):
            if esc:
                esc = False
                continue
            if ch == "\\":
                esc = True
                continue
            if not in_dq and ch == "'":
                in_sq = not in_sq
                continue
            if not in_sq and ch == '"':
                in_dq = not in_dq
                continue
            if not in_sq and not in_dq and ch == "#":
                return s[:i]
        return s

    def _is_block_opener_line(raw_line):
        # Determine if *code* ends with ':' (ignoring a trailing comment)
        s = raw_line.rstrip()
        if not s:
            return False
        s2 = _strip_trailing_comment_naive(s).rstrip()
        if not s2.endswith(":"):
            return False
        head = s2.lstrip()
        kw = _first_token(head)
        return kw in OPENERS

    # block stack elements are (header_indent, opener_kind)
    stack = []

    out = []
    paren_depth = 0

    for raw in lines:
        # keep blank lines exactly
        if not raw.strip():
            out.append(raw)
            paren_depth += _compute_paren_depth_changes(raw)
            if paren_depth < 0:
                paren_depth = 0
            continue

        # If we're in a continuation (...) / [...] / {...}, don't “repair” indentation.
        # (Repairing these reliably needs a real parser.)
        if paren_depth > 0:
            out.append(raw)
            paren_depth += _compute_paren_depth_changes(raw)
            if paren_depth < 0:
                paren_depth = 0
            continue

        # Measure existing indent and split into (indent, content)
        col, cut = _leading_indent_columns(raw, TAB_WIDTH)
        content = raw[cut:]
        lstripped = content.lstrip()
        # preserve internal leading spaces after indentation? In Python, spaces immediately
        # after indentation are part of the statement, so keep them.
        content_after_indent = content

        head = lstripped  # for token detection
        kw = _first_token(head) if head else ""

        # Compute a base expected indent for "normal" statements.
        # If we are inside blocks, expected is top.header + TAB_WIDTH, else 0.
        expected_body = (stack[-1][0] + TAB_WIDTH) if stack else 0

        # Pop stack if we are clearly returning to top-level (a non-dedent keyword at col==0)
        # This is conservative: if a line has no indent, treat it as leaving all blocks.
        if col == 0 and kw not in ("elif", "else", "except", "finally", "case"):
            stack = []
            expected_body = 0

        target_indent = col  # default if we decide to keep

        # Handle dedent keywords
        if kw in DEDENT_TO_MATCH:
            need = DEDENT_TO_MATCH[kw]
            # Pop until we find the matching opener kind
            # (We accept nested blocks on top of it.)
            idx = None
            for j in range(len(stack) - 1, -1, -1):
                if stack[j][1] == need:
                    idx = j
                    break
            if idx is None:
                # No matching opener found — best effort: align to current header if any, else 0
                target_indent = stack[-1][0] if stack else 0
                stack = [] if target_indent == 0 else stack
            else:
                target_indent = stack[idx][0]
                # discard any inner blocks deeper than the matching opener
                stack = stack[:idx+1]

        elif kw == "case":
            # case aligns to match-body indent (one level inside match header)
            # Find nearest match
            idx = None
            for j in range(len(stack) - 1, -1, -1):
                if stack[j][1] == "match":
                    idx = j
                    break
            if idx is None:
                # no match found; treat like a normal statement in current body
                target_indent = expected_body
            else:
                target_indent = stack[idx][0] + TAB_WIDTH
                stack = stack[:idx+1]  # keep match as context

        else:
            # "Normal" statement line.
            # If it has ANY indent but doesn't match a clean multiple, snap to expected_body.
            # If it has no indent, keep at 0.
            if col == 0:
                target_indent = 0
            else:
                # snap to expected body indent if inside a block; else snap to 0 or TAB_WIDTH
                target_indent = expected_body if expected_body > 0 else TAB_WIDTH

        # Rewrite the line with repaired indentation (spaces only)
        repaired = (" " * target_indent) + content_after_indent.lstrip("\t ")  # strip old indent region
        out.append(repaired)

        # Update block stack if this line is a block opener
        # Use *repaired* line for opener detection + token, since we just changed indent.
        if _is_block_opener_line(repaired):
            opener_kw = _first_token(repaired.lstrip())
            # Normalize "elif/else/except/finally/case" as openers too (they open suites)
            stack.append((target_indent, opener_kw))

        paren_depth += _compute_paren_depth_changes(raw)
        if paren_depth < 0:
            paren_depth = 0

    repaired_src = "\n".join(out)

    # If repair produced invalid code, don't apply (avoid making things worse).
    # If prefer: "apply anyway", remove this guard.
    try:
        compile(repaired_src, "<pylite-repair>", "exec")
    except Exception:
        return

    code.value = repaired_src



def tabify():
    """
    Back-compat shim: if  "__tabify__" is in the menu,
    treat it as "format" for now.
    """
    global _TABIFY_POST_TEXT
    _TABIFY_POST_TEXT = code.value
    tabify_format()


#------------------------
#   TAB WIDTH MENU      #
#------------------------

# assumes these already exist in pyscript.py
# TAB_WIDTH, tabify_format, tabify_repair, tabify (shim)

tab_btn  = document["tabMenuBtn"]
tab_menu = document["tabMenuList"]

def _set_tab_btn_label(n: int):
    tab_btn.text = f"Tab {n}"

def _open_menu():
    tab_menu.hidden = False
    tab_btn.attrs["aria-expanded"] = "true"

def _close_menu():
    tab_menu.hidden = True
    tab_btn.attrs["aria-expanded"] = "false"

def _toggle_menu(ev=None):
    if tab_menu.hidden:
        _open_menu()
    else:
        _close_menu()

def _outside_click(ev):
    # close if click is outside the tabmenu container
    if not tab_menu.hidden:
        root = tab_btn.parentElement  # .tabmenu div
        if not root.contains(ev.target):
            _close_menu()

def _on_menu_item_click(ev):
    global TAB_WIDTH
    btn = ev.target
    if not hasattr(btn, "attrs") or "data-value" not in btn.attrs:
        return

    v = str(btn.attrs["data-value"])

    # command options (do not "stick")
    if v == "__tabify__":
        tabify()
        _close_menu()
        _set_tab_btn_label(TAB_WIDTH)
        return

    if v == "__repair__":
        tabify_repair()
        _close_menu()
        _set_tab_btn_label(TAB_WIDTH)
        return

    # numeric option
    try:
        n = int(v)
    except Exception:
        n = TAB_WIDTH

    TAB_WIDTH = max(1, min(8, n))  # allow 1 for testing, but may not be in the UI
    dlog("Tab width set to", TAB_WIDTH)
    _set_tab_btn_label(TAB_WIDTH)
    _close_menu()
    tabify_format()

# open/close
tab_btn.bind("click", _toggle_menu)
document.bind("mousedown", _outside_click)

# delegate clicks inside menu
tab_menu.bind("click", _on_menu_item_click)

# initial label sync on load
_set_tab_btn_label(TAB_WIDTH)


#-- Tab menu keyboard support --


def _on_btn_key(ev):
    k = ev.key
    if k in ("Enter", " "):
        ev.preventDefault()
        _toggle_menu()
    elif k == "Escape":
        _close_menu()

tab_btn.bind("keydown", _on_btn_key)

def _on_menu_key(ev):
    if ev.key == "Escape":
        _close_menu()
        tab_btn.focus()

tab_menu.bind("keydown", _on_menu_key)



#########################
##   LIVE TEXT SINK     #
#########################

class LiveTextSink:
    def __init__(self, el, *, max_chars=20000, flush_ms=50):
        self.el = el
        self.max_chars = max_chars
        self.flush_ms = flush_ms
        self._buf = []
        self._scheduled = False

    def write(self, s):
        if not s:
            return 0
        self._buf.append(str(s))
        if not self._scheduled:
            self._scheduled = True
            timer.set_timeout(self._flush, self.flush_ms)
        return len(str(s))

    def _flush(self):
        self._scheduled = False
        if not self._buf:
            return
        chunk = "".join(self._buf)
        self._buf.clear()

        # Single DOM update per tick
        self.el.text += chunk

        # Trim to last max_chars
        if self.max_chars and len(self.el.text) > self.max_chars:
            self.el.text = self.el.text[-self.max_chars:]

        self.el.scrollTop = self.el.scrollHeight

    def flush(self):
        self._flush()

LIVE_STDOUT = LiveTextSink(output_pre, max_chars=20000, flush_ms=50)
LIVE_STDERR = LiveTextSink(errors_pre,  max_chars=20000, flush_ms=50)


##########################
#   BUFFER TEXT SINK     #
##########################

class BufferSink:
    def __init__(self):
        self._buf = []

    def write(self, s):
        if not s:
            return 0
        self._buf.append(str(s))
        return len(str(s))

    def getvalue(self):
        return "".join(self._buf)

    def flush(self):
        pass


########################
#   ASK AI ABOUT CODE  #
########################

def get_ai_language():
    # Default to English if nothing set yet
    try:
        lang = window.localStorage.getItem("pylite_ai_lang")
        if lang:
            return str(lang)
    except Exception:
        pass
    return "en"

def ask_ai_about_code(ev):

    try:
        output_pre.text = "localStorage pylite_ai_lang = " + str(window.localStorage.getItem("pylite_ai_lang")) + "\n"
    except Exception as e:
        output_pre.text = "localStorage read failed: " + str(e) + "\n"


    src = code.value
    if not src.strip():
        output_pre.text = "No code to analyze."
        return

    output_pre.text = "Asking AI for feedback...\n"

    lang = get_ai_language()

    # NEW: include it in the payload
    payload = {"code": src, "language": lang,  "lang": lang}

    opts = {
        "method": "POST",
        "headers": {
            "Content-Type": "application/json"
        },
        "body": window.JSON.stringify(payload)
    }

    def handle_text(raw):
        try:
            data = json.loads(raw)
        except Exception as e:
            output_pre.text = (
                "Got response from backend, but could not parse JSON.\n\n"
                f"Raw body:\n{raw}\n\n"
                f"Parsing error: {e}"
            )
            return

        if isinstance(data, dict):
            reply = data.get("reply", "(no 'reply' field in JSON)")
        else:
            reply = f"Unexpected JSON structure:\n{data!r}"

        output_pre.text = reply

    def handle_response(resp):
        status = resp.status
        ok = resp.ok

        def process_raw(raw):
            if not ok:
                output_pre.text = (
                    f"Backend error {status}.\n\n"
                    f"Raw response body:\n{raw}"
                )
            else:
                handle_text(raw)

        return resp.text().then(process_raw)

    def handle_error(err):
        output_pre.text = f"Network or fetch error:\n{err}"

    window.fetch(API_URL, opts).then(handle_response).catch(handle_error)

document["ask_ai"].bind("click", ask_ai_about_code)

########################
#   HASH PARAM PARSER  #
########################

def parse_params():
    params = {}

    # Querystring
    q = window.URLSearchParams.new(window.location.search)
    it = q.entries()
    while True:
        nxt = it.next()
        if nxt.done:
            break
        k, v = nxt.value
        params[str(k).lower()] = str(v)

    # Hash fragment (only treat as params when it looks like params)
    frag = window.location.hash or ""
    if frag.startswith("#") and len(frag) > 1:
        body = frag[1:]
        if ("=" in body) or ("&" in body):
            h = window.URLSearchParams.new(body)
            it2 = h.entries()
            while True:
                nxt = it2.next()
                if nxt.done:
                    break
                k, v = nxt.value
                params[str(k).lower()] = str(v)

    return params


def is_paste_block_enabled():
    params = parse_params()
    val = params.get("paste", "").lower()
    if val in ("on", "true", "1", "yes", "allow"):
        return False
    elif val in ("off", "false", "0", "no", "block"):
        return True
    return False

PASTE_BLOCK_ENABLED = is_paste_block_enabled()


################################
#   LIGHT OR DARK THEME        #
################################

theme_button = document["themeToggle"]

def set_theme_button_label(is_dark: bool):
    # label describes the ACTION (what clicking will do)
    label = "Switch to Light Theme" if is_dark else "Switch to Dark Theme"
    theme_button.setAttribute("aria-label", label)
    theme_button.setAttribute("title", label)

def apply_theme(theme: str):
    """Apply 'dark' or 'light' theme on page load."""
    body = document.select("body")[0]
    cls = body.classList

    theme = (theme or "").lower().strip()

    if theme == "light":
        cls.remove("dark-mode")
        set_theme_button_label(False)
    else:
        cls.add("dark-mode")
        set_theme_button_label(True)

# Parse ?theme=dark or ?theme=light
query = window.location.search[1:]
params = parse_qs(query) if query else {}
theme_param = params.get("theme", [""])[0]

apply_theme(theme_param)

def toggle_theme(ev):
    body = document.select("body")[0]
    cls = body.classList

    is_dark = ("dark-mode" in cls)
    if is_dark:
        cls.remove("dark-mode")
        set_theme_button_label(False)
    else:
        cls.add("dark-mode")
        set_theme_button_label(True)

theme_button.bind("click", toggle_theme)




def _line_start_index(src: str, line_num: int) -> int:
    """
    Return the character index of the start of 1-based line_num in src.
    If line_num is out of range, clamps to start/end.
    """
    if line_num <= 1:
        return 0
    # Find the (line_num-1)th newline
    n = line_num - 1
    i = -1
    for _ in range(n):
        i = src.find("\n", i + 1)
        if i == -1:
            return len(src)
    return i + 1




########################
#   LINE HELPERS       #
########################

def scroll_code_to_line_center(line_num: int, select_line: bool = True):
    """
    Scroll the code textarea so line_num is approximately centered.
    Also keeps gutter synced via sync_scroll().
    """
    if line_num is None:
        return
    try:
        line_num = int(line_num)
    except Exception:
        return
    if line_num < 1:
        line_num = 1

    # Compute metrics
    cs = window.getComputedStyle(code)
    lh = float(cs.lineHeight.replace("px", ""))  
    visible_h = float(code.clientHeight)

    target = (line_num - 1) * lh - (visible_h / 2.0) + (lh / 2.0)

    # Clamp
    max_scroll = float(code.scrollHeight) - visible_h
    if max_scroll < 0:
        max_scroll = 0
    if target < 0:
        target = 0
    if target > max_scroll:
        target = max_scroll

    code.scrollTop = target
    sync_scroll(None)  # keep gutter aligned + highlight updated

    if select_line:
        src = code.value.replace("\r\n", "\n").replace("\r", "\n")
        start = _line_start_index(src, line_num)
        end = src.find("\n", start)
        if end == -1:
            end = len(src)

        # Put caret/selection on the error line (optional but nice)
        code.focus()
        code.selectionStart = start
        code.selectionEnd = end
        update_gutter_highlight()


########################
#   LINE NUMBERS       #
########################

def ensure_gutter_highlight():
    # Create once; used as a single moving highlight bar
    try:
        return document["gutter_highlight"]
    except KeyError:
        hl = document.createElement("div")
        hl.id = "gutter_highlight"
        gutter <= hl
        return hl


def update_gutter(ev=None): # Jan 6th, 2025 update gutter fix for line count
    text = code.value
    text = text.replace("\r\n", "\n").replace("\r", "\n")

    line_count = len(text.split("\n"))
    gutter.textContent = "\n".join(str(i) for i in range(1, line_count + 1))

    # IMPORTANT: after content changes, the browser may clamp code.scrollTop
    # without firing a scroll event. Resync on next frame.
    def _resync(_=None):
        sync_scroll(None)

    try:
        window.requestAnimationFrame(_resync)
    except Exception:
        # fallback
        timer.set_timeout(lambda: sync_scroll(None), 0)

    update_gutter_highlight()



def update_gutter_highlight(ev=None):
    hl = ensure_gutter_highlight()

    text = code.value
    text = text.replace("\r\n", "\n").replace("\r", "\n")

    caret = code.selectionStart or 0
    current_line = text[:caret].count("\n") + 1

    # Source of truth: textarea metrics
    lh = float(window.getComputedStyle(code).lineHeight.replace("px", ""))
    pad_top = float(window.getComputedStyle(gutter).paddingTop.replace("px", ""))

    # Position relative to gutter content; compensate for scroll
    top_px = pad_top + (current_line - 1) * lh - code.scrollTop

    hl.style.top = f"{top_px}px"
    hl.style.height = f"{lh}px"


def sync_scroll(ev=None):
    gutter.scrollTop = code.scrollTop
    update_gutter_highlight()




code.bind("scroll", sync_scroll)


code.bind("input", update_gutter)   # <- CRITICAL: catches paste, cut, delete, etc.
code.bind("scroll", sync_scroll)

# Caret moves (no rebuild; just move highlight bar)
code.bind("click", update_gutter_highlight)
code.bind("keyup", update_gutter_highlight)

def gutter_wheel(ev):
    # Forward wheel scrolling to the textarea so scrollTop stays unified
    code.scrollTop += ev.deltaY
    sync_scroll(None)
    ev.preventDefault()

gutter.bind("wheel", gutter_wheel)

update_gutter()

###############################
#   HIDE DIRTY SHARE URL      #
###############################

share_dirty = False

def invalidate_share_url(ev=None):
    global share_dirty
    share_dirty = True
    try:
        document["share_url_container"].attrs["hidden"] = True
        document["share_url_debug"].value = ""
    except KeyError:
        pass

    # NEW: clear warning and re-enable buttons when code changes
    _set_url_warning("")
    _set_share_disabled(False)


code.bind("input", invalidate_share_url)

###############################
#   LOAD CODE FROM URL HASH   #
###############################

def decode_b64_urlsafe(s: str) -> str:
    try:
        s = unquote(s)
        padding = '=' * (-len(s) % 4)
        return base64.urlsafe_b64decode((s + padding).encode('ascii')).decode('utf-8')
    except Exception:
        return s

def to_urlsafe_b64(s: str) -> str:
    b = s.encode("utf-8")
    encoded = base64.urlsafe_b64encode(b).decode("ascii")
    return encoded.rstrip("=")

def _update_param_string(param_str: str, key: str, new_value: str) -> str:
    if not param_str:
        return f"{key}={new_value}"

    parts = param_str.split("&")
    updated = []
    found = False
    for part in parts:
        if not part:
            continue
        if "=" in part:
            k, _ = part.split("=", 1)
        else:
            k = part
        if k == key:
            updated.append(f"{key}={new_value}")
            found = True
        else:
            updated.append(part)
    if not found:
        updated.append(f"{key}={new_value}")
    return "&".join(updated)

def _has_key_in_param_string(param_str: str, key: str) -> bool:
    if not param_str:
        return False
    for part in param_str.split("&"):
        if not part:
            continue
        k = part.split("=", 1)[0]
        if k == key:
            return True
    return False

def _param_string_has_key(body: str, key: str) -> bool:
    # Use URLSearchParams to correctly detect keys
    if not body:
        return False
    usp = window.URLSearchParams.new(body)
    return usp.has(key)

def build_share_url_for_code(code_b64: str) -> str:
    loc = window.location
    origin = loc.origin
    pathname = loc.pathname

    search = loc.search or ""
    frag = loc.hash or ""

    search_body = search[1:] if search.startswith("?") else search
    frag_body   = frag[1:]   if frag.startswith("#")   else frag

    q = window.URLSearchParams.new(search_body)
    h = window.URLSearchParams.new(frag_body)

    has_code_in_query = q.has("code")
    has_code_in_hash  = h.has("code")

    # Preserve  existing placement policy:
    # - If code is already in hash, update hash
    # - Else if code is in query, update query
    # - Else if hash has *anything*, prefer putting code in hash ( existing behavior)
    # - Else put code in query
    if has_code_in_hash:
        h.set("code", code_b64)
    elif has_code_in_query:
        q.set("code", code_b64)
    else:
        if frag_body:
            h.set("code", code_b64)
        else:
            q.set("code", code_b64)

    new_url = origin + pathname

    qs = q.toString()
    hs = h.toString()

    if qs:
        new_url += "?" + qs
    if hs:
        new_url += "#" + hs

    return new_url


def load_code_from_url():
    params = parse_params()
    raw = params.get("code")
    if raw is None:
        return

    # Try new compressed format first
    try:
        text = window.decodeCodeFromUrl(raw)
    except Exception:
        # Fall back to legacy: plain URL-safe base64 of UTF-8
        text = decode_b64_urlsafe(raw)

    # Normalize newlines from share links (prevents subtle line-count drift)
    text = text.replace("\r\n", "\n").replace("\r", "\n")

    code.value = text
    ensure_pylite_header_present()

    # deterministic start position
    code.scrollTop = 0
    gutter.scrollTop = 0

    update_gutter()
    adjust_editor_height()
    enforce_share_policy_for_current_code()

    # After layout settles, re-zero scroll and then sync (prevents code/gutter divergence)
    timer.set_timeout(lambda: (setattr(code, "scrollTop", 0),
                               setattr(gutter, "scrollTop", 0),
                               sync_scroll(None),
                               update_gutter()), 0)



##################
#   TAIL HELPER  #
##################
# Used to truncate long outputs for display.

MAX_OUTPUT_CHARS = 80_000   # max output (50k to 200k tends to be sane)
TRUNC_MARK = "\n\n...(output truncated; showing last {} chars)...\n"

def tail_text(s, max_chars=MAX_OUTPUT_CHARS):
    dlog("pyscript.py: tail_text() called")#, file=sys.__stdout__)
    if s is None:
        return ""
    if len(s) <= max_chars:
        return s
    return TRUNC_MARK.format(max_chars) + s[-max_chars:]




######################
#   AUTO SAVE        #
######################

LS_KEY_CODE = "pylite_last_code"
LS_KEY_TS   = "pylite_last_code_ts"

def autosave_before_run(src: str):
    dlog("pyscript.py: Autosaving code to localStorage...")
    try:
        window.localStorage.setItem(LS_KEY_CODE, src)
        window.localStorage.setItem(LS_KEY_TS, str(int(time.time())))
    except Exception:
        # localStorage can fail in private mode / quota / etc.
        pass

def try_restore_autosave(default_text: str = "") -> str:
    """
    Returns the code to put into the editor:
      - restores last saved code if available AND current editor text is empty/whitespace
      - otherwise returns default_text
    """
    dlog("pyscript.py: Trying restore of code from localStorage...")
    try:
        saved = window.localStorage.getItem(LS_KEY_CODE)
        if saved is None:
            return default_text
        return saved
    except Exception:
        return default_text



######################
#   PREPARE TO RUN   #
######################

def prepare_execution(src, *, use_live_io=False):
    """
    Build an execution context shared by sync + async runners.

    Returns a ctx dict containing:
      - student_code
      - filename_for_tracebacks
      - student_lines
      - globals_dict / locals_dict
      - exception_policy
      - io_hooks (stdout/stderr capture placeholders + install/restore)

    NOTE (Jan 2026):
      - Pre-worker: "live" stdout should NOT write to the DOM. It should use a
        fast buffer sink (BufferSink) and then the runner renders at the end.
      - We keep stderr buffered always.
    """
    dlog("pyscript.py: prepare_execution() called", use_live_io)

    # ------------------------------------------------------------
    # 1) Detect / hide injected prologue (line-number correction)
    # ------------------------------------------------------------
    lines0 = src.split("\n")
    inj_lines = 0
    for i, ln in enumerate(lines0[:10]):  # scan only the top few lines
        if ln.strip() == PT_OPEN_PROLOGUE_LINE:
            inj_lines = i + 1            # hide everything up through this line
            break

    student_lines = lines0[inj_lines:] if inj_lines else lines0

    filename_for_tracebacks = "pylite code"
    EXEC_FILENAMES = (filename_for_tracebacks, "<string>")

    # Student code *as executed*
    student_code = src

    # ------------------------------------------------------------
    # 2) Sandbox globals/locals
    # ------------------------------------------------------------
    dlog("pyscript.py: prepare_execution() step 2 starting")
    env = {
        "__name__": "__main__",
        "__builtins__": __builtins__,
    }
    globals_dict = env
    locals_dict = env

    # ------------------------------------------------------------
    # 3) IO hooks (buffered in both modes; use BufferSink when "live")
    # ------------------------------------------------------------
    dlog("pyscript.py: prepare_execution() step 3 starting")
    old_stdout = sys.stdout
    old_stderr = getattr(sys, "stderr", None)

    # Sync: StringIO ( existing behavior)
    # Async (pre-worker): BufferSink to avoid slowdown from DOM writes
    out_buffer = BufferSink() if use_live_io else StringIO()
    err_buffer = StringIO()   # keep stderr buffered even in live mode

    stdout_target = out_buffer
    stderr_target = err_buffer

    io_hooks = {
        "old_stdout": old_stdout,
        "old_stderr": old_stderr,
        "out_buffer": out_buffer,     # may be StringIO or BufferSink
        "err_buffer": err_buffer,     # StringIO
        "stdout_target": stdout_target,
        "stderr_target": stderr_target,

        # optional future input bridging
        "input_request_cb": None,
        "pending_input": None,

        "install": None,
        "restore": None,
    }

    def _install_io():
        sys.stdout = io_hooks["stdout_target"]
        if io_hooks["old_stderr"] is not None:
            sys.stderr = io_hooks["stderr_target"]

    def _restore_io():
        sys.stdout = io_hooks["old_stdout"]
        if io_hooks["old_stderr"] is not None:
            sys.stderr = io_hooks["old_stderr"]

    io_hooks["install"] = _install_io
    io_hooks["restore"] = _restore_io

    # ------------------------------------------------------------
    # 4) Exception policy / line fixups
    # ------------------------------------------------------------
    def _map_runtime_line_to_student(runtime_line):
        if runtime_line is None:
            return None
        student_line = runtime_line - inj_lines
        return student_line if student_line >= 1 else None

    exception_policy = {
        "inj_lines": inj_lines,
        "exec_filenames": EXEC_FILENAMES,
        "filename_for_tracebacks": filename_for_tracebacks,
        "map_runtime_line_to_student": _map_runtime_line_to_student,
        "student_lines": student_lines,
    }

    # ------------------------------------------------------------
    # 5) Final ctx
    # ------------------------------------------------------------
    ctx = {
        "student_code": student_code,
        "filename_for_tracebacks": filename_for_tracebacks,
        "student_lines": student_lines,
        "globals_dict": globals_dict,
        "locals_dict": locals_dict,
        "exception_policy": exception_policy,
        "io_hooks": io_hooks,
    }
    return ctx




######################
#   FORMAT EXCEPTION #
######################

def safe_exc_text(ex): # helper

    """Return a safe string for an exception, even if __str__ is broken."""
    dlog("pyscript.py: safe_exc_text() called")#, file=sys.__stdout__)
    try:
        s = str(ex)
        if isinstance(s, str):
            return s
    except Exception:
        pass
    try:
        r = repr(ex)
        if isinstance(r, str):
            return r
    except Exception:
        pass
    return "<unprintable exception>"


def format_exception_for_student(e, tb_full, ctx):
    dlog("pyscript.py: format_exception_for_student() called")#, file=sys.__stdout__)

    ep = ctx["exception_policy"]
    io = ctx.get("io_hooks", {})

    inj_lines = ep.get("inj_lines", 0)
    student_lines = ctx.get("student_lines", [])
    EXEC_FILENAMES = ep.get("exec_filenames", ("pylite code", "<string>"))

    # ------------------------------------------------------------
    # 1) Determine runtime line number from traceback frames
    # ------------------------------------------------------------
    reported_line = None
    tb_obj = getattr(e, "__traceback__", None)

    last_exec_tb = None
    cur = tb_obj
    while cur:
        try:
            fn = cur.tb_frame.f_code.co_filename
        except Exception:
            fn = None

        if fn in EXEC_FILENAMES:
            last_exec_tb = cur

        cur = cur.tb_next

    if last_exec_tb is not None:
        reported_line = last_exec_tb.tb_lineno
    else:
        cur = tb_obj
        while cur and cur.tb_next:
            cur = cur.tb_next
        reported_line = cur.tb_lineno if cur else None

    line_num = None
    if reported_line is not None:
        line_num = reported_line - inj_lines
        if line_num < 1:
            line_num = 1

    # ------------------------------------------------------------
    # 2) Build student-facing traceback text
    # ------------------------------------------------------------
    # Start with a safe minimal fallback (never crashes)
    msg = safe_exc_text(e)
    if isinstance(msg, str) and "<exception str() failed>" in msg:
        msg = ""
    tb_display = f"{type(e).__name__}: {msg}" if msg else f"{type(e).__name__}"

    if isinstance(tb_full, str) and tb_full.strip():
        tb_lines = tb_full.split("\n")

        # Keep only from the first executed-code frame onward
        filtered = []
        started = False
        for line in tb_lines:
            if not started:
                if any(f'File "{name}"' in line for name in EXEC_FILENAMES):
                    started = True
            if started:
                filtered.append(line)

        tb_display = "\n".join(filtered).strip() if filtered else tb_full.strip()

        # Rewrite executed-code "File ..." line numbers (subtract injection)
        try:
            import re

            def _fix_file_line(m):
                try:
                    n = int(m.group(2)) - inj_lines  # subtract injected prologue lines
                except Exception:
                    return m.group(0)
                if n < 1:
                    n = 1
                return m.group(1) + str(n)

            for name in EXEC_FILENAMES:
                tb_display = re.sub(
                    rf'(File "{re.escape(name)}", line )(\d+)',
                    _fix_file_line,
                    tb_display
                )
        except Exception:
            pass

        # --- IMPORTANT FIX ---
        # If the runtime emits "<exception str() failed>", preserve the stack
        # and only normalize the final exception line.
        if "<exception str() failed>" in tb_display:
            lines = [ln for ln in tb_display.split("\n") if ln.strip() != ""]
            if lines:
                # Replace the last non-empty line with just the exception type
                lines[-1] = f"{type(e).__name__}"
                tb_display = "\n".join(lines)

    # ------------------------------------------------------------
    # 3) CODE context (student-only)
    # ------------------------------------------------------------
    if line_num is not None and 1 <= line_num <= len(student_lines):
        max_line = line_num
    else:
        max_line = len(student_lines)

    formatted = []
    for i in range(1, max_line + 1):
        line_text = student_lines[i - 1] if i - 1 < len(student_lines) else ""
        if line_num is not None and i == line_num:
            formatted.append(f">> {i:>3} | {line_text}")
        else:
            formatted.append(f"   {i:>3} | {line_text}")

    code_context = "CODE:\n" + "\n".join(formatted)

    # ------------------------------------------------------------
    # 4) STDERR append (errors-only), if captured
    # ------------------------------------------------------------
    err_text = "ERROR:\n" + tb_display + "\n\n" + code_context

    old_stderr = io.get("old_stderr", None)
    if old_stderr is not None and "err_buffer" in io:
        stderr_text = io["err_buffer"].getvalue()
        if stderr_text.strip():
            err_text = err_text + "\n\nSTDERR:\n" + tail_text(stderr_text)

    err_text = tail_text(err_text)

    return {
        "tb_display": tb_display,
        "line_num": line_num,
        "code_context": code_context,
        "err_text": err_text,
    }





# ##############################
# Worker runner (blocking input())
# ################################

py_worker = None
worker_ready = False
current_input_id = None

console_input_row = document["console_input_row"] # div that contains input field + button
stdin_field = document["stdin_field"]
stdin_button = document["stdin_submit"]
# FORCE hidden on page load
console_input_row.hidden = True

run_watchdog = None
last_worker_activity_ms = 0

# OLD: DELETE: def append_out(s: str):
#     if not s:
#         return
#     # keep it simple: append text and autoscroll
#     output_pre.text += str(s)
#     output_pre.scrollTop = output_pre.scrollHeight



# ---- stdout buffering (main thread) ----
pending_out = ""
out_flush_scheduled = False

def flush_out(*args):
    """Flush pending stdout to the DOM in one shot."""
    global pending_out, out_flush_scheduled
    if not pending_out:
        out_flush_scheduled = False
        return

    output_pre.text += pending_out
    pending_out = ""
    out_flush_scheduled = False

    # one scroll per flush (not per chunk)
    output_pre.scrollTop = output_pre.scrollHeight

def force_flush_out():
    """Use this when you're about to change run-state / show an error / finish."""
    flush_out()

def append_out(s: str):
    """Append stdout with DOM batching to avoid per-message DOM churn."""
    global pending_out, out_flush_scheduled
    if not s:
        return

    pending_out += str(s)

    # Schedule exactly one flush per frame
    if not out_flush_scheduled:
        out_flush_scheduled = True
        window.requestAnimationFrame(flush_out)


def show_console_input():
    console_input_row.hidden = False
    console_input_row.style.display = "flex"   # undo any old display overrides
    stdin_field.focus()

def hide_console_input():
    console_input_row.hidden = True
    console_input_row.style.display = "none"  # force-hide even if hidden got overridden


hide_console_input()

# ################################
#  ENSURE WORKER + MSG HANDLERS  #
# ################################

sab = None
sab_i32 = None
sab_u16 = None


def ensure_worker():
    global py_worker, worker_ready


    dlog("pyscript.py: ensure_worker() called")

    if py_worker is not None and worker_ready:
        return

    # Create worker
    py_worker = window.Worker.new("py_worker.js")
    this_worker = py_worker  # capture for closures

    # --- DIAGNOSTIC: measure cold-start (Worker load + importScripts + stdlib parse) ---
    t0 = window.performance.now()
    first_msg_seen = {"done": False}
    console.warn("WORKER TIMING: created worker at", t0)

    def first_msg_probe(ev):
        if this_worker is not py_worker:
            return
        if first_msg_seen["done"]:
            return
        first_msg_seen["done"] = True

        dt = window.performance.now() - t0
        try:
            m = ev.data
            typ = m["type"] if (m is not None and "type" in m) else "?"
        except Exception:
            typ = "?"
        console.warn("WORKER TIMING: first message after", dt, "ms; type=", typ)

    this_worker.addEventListener("message", first_msg_probe)
    # --- end diagnostic ---

    def on_worker_error(ev):
        console.error("pyscript.py: on_worker_error()WORKER ERROR EVENT:", ev)
        kill_worker("worker error")
        errors_pre.text = "Worker crashed."

    def on_worker_messageerror(ev):
        console.error("pyscript.py: on_worker_messageerror() WORKER MESSAGEERROR EVENT:", ev)
        kill_worker("worker messageerror")
        errors_pre.text = "Internal messaging error."

    py_worker.addEventListener("error", on_worker_error)
    py_worker.addEventListener("messageerror", on_worker_messageerror)

    # Handshake timing: guaranteed to be seen because listeners are attached now
    ping_t0 = window.performance.now()
    console.warn("WORKER TIMING: sending ping at", ping_t0)
    py_worker.postMessage({"type": "ping", "t0": ping_t0})

    worker_ready = False

    def on_worker_message(ev):

        global worker_ready
        global current_input_id
        global stdin_submit_inflight
        dlog("pyscript.py: on_worker_message() WORKER MESSAGE EVENT:", ev)
        dlog("pyscript.py: on_worker_message() MAIN worker instance:", py_worker)

        if this_worker is not py_worker:
            console.warn("pyscript.py: on_worker_message() Ignoring message from stale worker")
            return

        m = ev.data
        global last_worker_activity_ms
        last_worker_activity_ms = int(window.Date.new().getTime())

        # ev.data is a JSObject in Brython; use bracket access
        try:
            t = m["type"]
        except Exception:
            t = None


        dlog("pyscript.py: on_worker_message()WORKER MSG TYPE:", t)

        if t == "debug":
            try:
                dlog("pyscript.py: on_worker_message() WORKER DEBUG message:", m["msg"])
            except Exception:
                dlog("pyscript.py: on_worker_message() WORKER DEBUG: (unreadable)")
            return

        if t == "inited":
            dlog("pyscript.py: on_worker_message() starting init")
            console.warn("WORKER TIMING: got inited after",
                         window.performance.now() - init_t0, "ms")

            global sab, sab_i32, sab_u16

            sab = m["sab"] if ("sab" in m) else None

            if sab is None:
                console.error("pyscript.py: on_worker_message() MAIN: worker inited but no SAB received")
                worker_ready = False
                return
    

            sab_i32 = window.Int32Array.new(sab, 0, 4)
            sab_u16 = window.Uint16Array.new(sab, 16)
            dlog("pyscript.py: on_worker_message() MAIN: SAB ready. u16 chars:", sab_u16.length)

            worker_ready = True
            dlog("pyscript.py: on_worker_message() DONE")
            return

        if t == "stdout":
            dlog("pyscript.py: on_worker_message() starting stdout handling")
            text = m["text"] if "text" in m else ""
            append_out(text)
            dlog("pyscript.py: on_worker_message() DONE")
            return
            
        if t == "input_request":

            stdin_submit_inflight = False
            current_input_id = int(m["id"]) if "id" in m else None

            dlog("pyscript.py: on_worker_message() starting input_request handling   ")

            dlog("pyscript.py: on_worker_message() MAIN got input_request id/type:", current_input_id, window.eval(f"typeof({current_input_id})") if current_input_id is not None else "None")

            prompt = m["prompt"] if "prompt" in m else ""
            if prompt:
                append_out(str(prompt).rstrip("\n"))

            show_console_input()
            dlog("pyscript.py: on_worker_message() DONE")
            return

        if t == "done":

            console.warn("RUN TIMING: done after", window.performance.now() - run_t0, "ms")
            dlog("pyscript.py: on_worker_message() starting done handling    ")
            hide_console_input()

            # NEW: ensure last stdout is rendered before we finalize UI state
            force_flush_out()

            ok = bool(m["ok"]) if "ok" in m else False
            cancelled = bool(m["cancelled"]) if "cancelled" in m else False
            dlog("pyscript.py: on_worker_message() done, so ok/cancelled = ", ok, cancelled)

            if cancelled:
                errors_pre.text = "(Cancelled.)"
                set_run_state("ok")   # treat cancel as a clean stop
                show_errors(False)
            elif ok:
                set_run_state("ok")
                show_errors(False)
            else:
                errors_pre.text = m["err_text"] if "err_text" in m else "(Worker error.)"
                dlog("pyscript.py: on_worker_message() calling set_run_state('error')")
                set_run_state("error")
                reveal_and_scroll_errors()

            try:
                publish_run_status(ok, True, None)
            except Exception:
                pass

            global run_watchdog
            if run_watchdog is not None:
                try:
                    window.clearInterval(run_watchdog)
                except Exception:
                    pass
                run_watchdog = None

            dlog("pyscript.py: on_worker_message() DONE")
            return


        if t == "pong":
            try:
                t0 = float(m["t0"])
                console.warn("WORKER TIMING: pong after",
                             window.performance.now() - t0, "ms")
            except Exception:
                console.warn("WORKER TIMING: pong (unparsed)")
            return

        dlog("pyscript.py: on_worker_message() DONE")


    py_worker.onmessage = on_worker_message


    init_t0 = window.performance.now()
    console.warn("WORKER TIMING: posting init at", init_t0)

    # Ask worker to init and send back SAB (even if we don't directly use it on main thread)
    py_worker.postMessage({"type": "init"})
    dlog("pyscript.py: ensure_worker() done")


# ################################
#  KILL WORKER                   #
# ################################

def kill_worker(reason=""):
    global py_worker, worker_ready, current_input_id, run_watchdog

    console.warn("pyscript.py: kill_worker():", reason, "py_worker =", py_worker)

    hide_console_input()
    current_input_id = None

    # Stop watchdog
    if run_watchdog is not None:
        try:
            window.clearInterval(run_watchdog)
        except Exception:
            pass
        run_watchdog = None

    # Terminate the worker
    if py_worker is not None:
        try:
            py_worker.terminate()
        except Exception as e:
            console.warn("pyscript.py: kill_worker(): terminate failed:", e)

    py_worker = None
    worker_ready = False

    errors_pre.text += "\n(Worker killed: " + reason + ")"

    # UI: treat as clean stop
    set_run_state("error")   
    reveal_and_scroll_errors()

######################
#   SUBMIT HANDLER   #
######################

# guard against double-submit
stdin_submit_inflight = False


def on_console_submit(ev=None):
    global current_input_id, sab_i32, sab_u16, stdin_submit_inflight

    # If something already submitted for this prompt, ignore
    if stdin_submit_inflight:
        return

    if current_input_id is None:
        return

    if sab_i32 is None or sab_u16 is None:
        console.error("SUBMIT: SAB not ready on main thread")
        return

    # Lock immediately so a second Enter / click can't double-submit
    stdin_submit_inflight = True
    req_id = int(current_input_id)
    current_input_id = None

    val = stdin_field.value
    stdin_field.value = ""
    s = str(val)

    # UI: prompt + user text on same line, then newline
    append_out(s)
    append_out("\n")
    force_flush_out()

    # Send raw text (no newline) to Python
    max_len = sab_u16.length
    n = min(len(s), max_len)
    for i in range(n):
        sab_u16[i] = ord(s[i])

    window.Atomics.store(sab_i32, 3, n)   # length
    window.Atomics.store(sab_i32, 0, 0)   # release wait flag
    window.Atomics.notify(sab_i32, 0, 1)  # wake worker

    hide_console_input()




try:
    stdin_button.unbind("click", on_console_submit)
except Exception:
    pass
stdin_button.bind("click", on_console_submit)

def on_stdin_keydown(ev):
    if ev.key == "Enter":
        ev.preventDefault()
        try:
            ev.stopPropagation()
        except Exception:
            pass
        on_console_submit(ev)
        return


try:
    stdin_field.unbind("keydown", on_stdin_keydown)
except Exception:
    pass
stdin_field.bind("keydown", on_stdin_keydown)

######################
#   RUN CODE WORKER  #
######################
# Runs inside the WebWorker (py_worker.js), not the main thread.
# It supports blocking input() by having the worker block itself with Atomics.wait, while the main thread stays responsive and sends back {type:"input_response"}.
# Stdout is streamed back via {type:"stdout"} messages (and we append immediately).

def run_code_worker(src: str):
    dlog("pyscript.py: run_code_worker() called")
    ensure_worker()
    output_pre.text = ""
    errors_pre.text = ""
    hide_console_input()

    # IMPORTANT: worker already installs its own input() override + trace cancel checks
    # so we just send the raw student code
    set_run_state("running")
    show_errors(False)

    # watch dog to kill the process
    def now_ms():
        return int(window.Date.new().getTime())

    global run_watchdog, last_worker_activity_ms
    last_worker_activity_ms = now_ms()

    # Clear any previous watchdog
    if run_watchdog is not None:
        try:
            window.clearInterval(run_watchdog)
        except Exception:
            pass
        run_watchdog = None

    def tick():
        global worker_ready, py_worker
        #dlog("tick(): called")
        # If we're still in run-running too long, force-unlock and reset worker.
        elapsed = now_ms() - last_worker_activity_ms

        if elapsed > 60000:
            console.warn("WATCHDOG: no worker completion after", elapsed, "ms; forcing reset")

            # stop the watchdog FIRST so we can't spam if anything below errors
            global run_watchdog
            if run_watchdog is not None:
                try:
                    window.clearInterval(run_watchdog)
                except Exception:
                    pass
                run_watchdog = None

            hide_console_input()

            secs = int(elapsed / 1000)
            errors_pre.text = f"(Run timed out waiting for input; terminated after {secs} seconds.)"

            set_run_state("error")
            reveal_and_scroll_errors()

            try:
                py_worker.terminate()
            except Exception:
                pass

            worker_ready = False
            py_worker = None
            return


    run_watchdog = window.setInterval(tick, 1000) # check every second

    global run_t0
    run_t0 = window.performance.now()
    console.warn("RUN TIMING: post run at", run_t0)
    py_worker.postMessage({"type": "run", "src": src})
    dlog("pyscript.py: run_code_worker() done")


######################
#   RUN CODE SYNC    #
######################

def run_code_sync(src):
    dlog("pyscript.py:  SYNCH: run_code_sync() called")
    success = True
    env = None

    # Always start clean
    output_pre.text = ""
    errors_pre.text = ""

    # Build the executed source (keep current convention)
    full_src = (
        "__name__ = '__main__'\n"
        + PT_OPEN_PROLOGUE_LINE + "\n"
        + src
    )

    # Shared context (buffers, filenames, student_lines, inj_lines, etc.)
    ctx = prepare_execution(full_src, use_live_io=False)

    # Add PyLite sandbox bindings / status contract
    env = ctx["globals_dict"]
    env.update({
        "open": patched_open,

        # status contract defaults
        "__pt_ok__": True,
        "__pt_msg__": "",
        "__pt_item__": "",
    })
    ctx["locals_dict"] = env

    io = ctx["io_hooks"]

    try:
        io["install"]()

        code = compile(ctx["student_code"], ctx["filename_for_tracebacks"], "exec")
        exec(code, ctx["globals_dict"], ctx["locals_dict"])

        # If code ran without exception, allow it to report its own status.
        success = bool(env.get("__pt_ok__", True))

        stdout_text = io["out_buffer"].getvalue()
        if not stdout_text.strip():
            stdout_text = "(no output)"

        shorty = tail_text("STDOUT:\n" + stdout_text)
        output_pre.html = ansi_to_html(shorty)

        # If the code reported failure without throwing, show a message in Errors pane (optional)
        if not success:
            msg = env.get("__pt_msg__", "")
            item = env.get("__pt_item__", "")
            if item and msg:
                errors_pre.text = f"(Run reported failure: {item})\n{msg}"
            elif item:
                errors_pre.text = f"(Run reported failure: {item})"
            elif msg:
                errors_pre.text = f"(Run reported failure)\n{msg}"
            else:
                errors_pre.text = "(Run reported failure.)"
        else:
            errors_pre.text = ""

    except SystemExit:
        # treat exit() as a controlled stop; put message in Errors pane (stdout stays stdout-only)
        stdout_text = io["out_buffer"].getvalue()
        if not stdout_text.strip():
            stdout_text = "(no output)"

        shorty = tail_text("STDOUT:\n" + stdout_text)
        output_pre.html = ansi_to_html(shorty)

        errors_pre.text = "(Program called exit(); execution stopped.)"
        success = False

    except Exception as e:
        # stdout-only in output pane
        stdout_text = io["out_buffer"].getvalue()
        if not stdout_text.strip():
            stdout_text = "(no output)"

        shorty = tail_text("STDOUT:\n" + stdout_text)
        output_pre.html = ansi_to_html(shorty)

        # Full traceback text (best effort)
        try:
            tb_full = traceback.format_exc()
        except Exception:
            tb_full = ""

        bundle = format_exception_for_student(e, tb_full, ctx)

        # Center editor on the suspected student line
        if bundle["line_num"] is not None:
            scroll_code_to_line_center(bundle["line_num"] - 2, select_line=True)

        errors_pre.text = bundle["err_text"]
        success = False

    finally:
        try:
            LIVE_STDOUT.flush()
            LIVE_STDERR.flush()
        except Exception:
            pass
        io["restore"]()

    dlog("pyscript.py:  run_code_sync() done; success =")
    return success, env

######################
#   RUN CODE ASYNC   #
######################

# Runs on the main thread.
# It’s “async” only in the Python sense (you can await input_async() and use aio), but it still uses compile/exec in the page.
# Stdout is buffered ( BufferSink) and rendered at the end as STDOUT:\n....

def run_code_async(src):
    dlog("pyscript.py:  ASYNC run_code_async() called")
    output_pre.text = ""
    errors_pre.text = ""

    full_src = (
        "__name__ = '__main__'\n"
        + PT_OPEN_PROLOGUE_LINE + "\n"
        + src
    )

    # IMPORTANT: pre-worker async uses buffered stdout
    ctx = prepare_execution(full_src, use_live_io=True)

    env = ctx["globals_dict"]
    env.update({
        "open": patched_open,
        #"input_async": input_async,
        "aio": aio,
        "__pt_ok__": True,
        "__pt_msg__": "",
        "__pt_item__": "",
    })
    ctx["locals_dict"] = env

    io = ctx["io_hooks"]
    ok = True

    try:
        io["install"]()

        code = compile(ctx["student_code"], ctx["filename_for_tracebacks"], "exec")
        exec(code, ctx["globals_dict"], ctx["locals_dict"])

        ok = bool(env.get("__pt_ok__", True))

        # ---- NEW: render buffered stdout like sync does ----
        out = io["out_buffer"]
        stdout_text = out.getvalue() if hasattr(out, "getvalue") else ""
        if not stdout_text.strip():
            stdout_text = "(no output)"
        output_pre.html = ansi_to_html(tail_text("STDOUT:\n" + stdout_text))

        # (optional) status-contract failure message in Errors pane
        if not ok:
            msg = env.get("__pt_msg__", "")
            item = env.get("__pt_item__", "")
            if item and msg:
                errors_pre.text = f"(Run reported failure: {item})\n{msg}"
            elif item:
                errors_pre.text = f"(Run reported failure: {item})"
            elif msg:
                errors_pre.text = f"(Run reported failure)\n{msg}"
            else:
                errors_pre.text = "(Run reported failure.)"

    except SystemExit:
        ok = False
        # show stdout captured so far
        out = io["out_buffer"]
        stdout_text = out.getvalue() if hasattr(out, "getvalue") else ""
        if not stdout_text.strip():
            stdout_text = "(no output)"
        output_pre.html = ansi_to_html(tail_text("STDOUT:\n" + stdout_text))
        errors_pre.text = "(Program called exit(); execution stopped.)"

    except Exception as e:
        ok = False

        # show stdout captured so far
        out = io["out_buffer"]
        stdout_text = out.getvalue() if hasattr(out, "getvalue") else ""
        if not stdout_text.strip():
            stdout_text = "(no output)"
        output_pre.html = ansi_to_html(tail_text("STDOUT:\n" + stdout_text))

        try:
            tb_full = traceback.format_exc()
        except Exception:
            tb_full = ""

        bundle = format_exception_for_student(e, tb_full, ctx)
        if bundle["line_num"] is not None:
            scroll_code_to_line_center(bundle["line_num"] - 2, select_line=True)
        errors_pre.text = bundle["err_text"]

    finally:
        io["restore"]()

    dlog("pyscript.py: run_code_async() done; ok =", ok)
    return ok, env



#######################
#   RUN CODE BUTTON   #
#######################


def run_code(ev):
    output_pre.text = ""
    errors_pre.text = ""
    set_run_state("running")
    show_errors(False)

    dlog("pyscript.py: run_code(): PYSCRIPT VERSION: 2026-01-08-A (Design A submit)")

    src = code.value

    dlog("pyscript.py: run_code(): ROUTER src head:", repr(src[:80]))

    # decide worker path FIRST, before try/finally
    # wants_blocking_input = ("input(" in src) and ("input_async" not in src)
    # if wants_blocking_input:
    #     run_code_worker(src)
    #     return   # <-- no finally will run now

    force_async = False
    try:
        force_async = bool(document["force_async"].checked)
    except Exception:
        force_async = False

    is_async_mode = force_async or ("await input_async" in src) or ("aio.run(" in src)

    dlog("pyscript.py: run_code(): ROUTER is_async_mode:", is_async_mode, "force_async:", force_async,
            "has await input_async:", ("await input_async" in src),
            "has aio.run(:", ("aio.run(" in src))

    ok = False
    env = None

    if is_async_mode:
        if WORKER_CAPABLE:
            run_code_worker(src)
            ok = True  # means "launched"
            dlog("pyscript.py: run_code(): back from run_code_worker()")
            return
        else:
            # Worker pathway unavailable (commonly in iframes without cross-origin isolation).
            # Fall back to main-thread async runner.
            ok, env = run_code_async(src)
    try:
        ok, env = run_code_sync(src)

    except Exception as e:
        dlog("pyscript.py: run_code(): exception in router:", e)
        ok = False
        try:
            errors_pre.text = "INTERNAL ERROR (runner):\n" + safe_exc_text(e)
        except Exception:
            pass
    finally:
        dlog("pyscript.py: run_code(): processing finally: ok/env =", ok, env)
        set_run_state("ok" if ok else "error")
        if not ok:
            reveal_and_scroll_errors()
        else:
            show_errors(False)
        try:
            publish_run_status(ok, is_async_mode, env)
        except Exception:
            pass



#######################
#   CANCEL RUN        #
#######################


def cancel_run(ev=None):
    dlog("pyscript.py: cancel_run(): hard stop")
    kill_worker("user pressed Stop")

# def cancel_run(ev=None):
#     dlog("pyscript.py: cancel_run(): has been called !!!!!!!!!!!! with ev/py_worker", ev, py_worker)
#     hide_console_input()
#     if py_worker is not None:
#         dlog("pyscript.py: cancel_run(): posting message to cancel worker.")
#         py_worker.postMessage({"type": "cancel"})
#     else:
#         dlog("pyscript.py: cancel_run(): no worker to cancel.")


#############################
#   PUBLISH RUN  STATUS     #
#############################


def publish_run_status(ok, is_async_mode, env=None):
    payload = {
        "type": "pylite_run_status",
        "ok": bool(ok),
        "async": bool(is_async_mode),
    }

    # expose a little more without leaking everything:
    if env is not None:
        payload["pt_msg"] = env.get("__pt_msg__", "")
        payload["pt_item"] = env.get("__pt_item__", "")  # optional

    window.parent.postMessage(payload, "*")


def clear_output(ev):
    output_pre.text = ""
    errors_pre.text = ""



run_btn = document["run1"]
stop_btn = document["stop"]
code_box = document["code"]    
output_box = document["output_pre"] 
errors_section = document["errors_section"]
errors_pre = document["errors_pre"]

# optional: include stop_btn so it gets the same run-running / ok / error CSS class
elList = (run_btn, stop_btn, code_box, output_box)

# default state
stop_btn.disabled = True

def set_run_state(state):
    dlog("pyscript.py: set_run_state() called with state =", state)
    for el in elList:
        el.classList.remove("run-running", "run-ok", "run-error")

    if state == "running":
        for el in elList:
            el.classList.add("run-running")
        run_btn.disabled = True
        stop_btn.disabled = False   # <-- enable Stop while running
        stop_btn.hidden = False

    elif state == "ok":
        for el in elList:
            el.classList.add("run-ok")
        run_btn.disabled = False
        stop_btn.disabled = True
        stop_btn.hidden = True

    elif state == "error":
        for el in elList:
            el.classList.add("run-error")
        run_btn.disabled = False
        stop_btn.disabled = True
        stop_btn.hidden = True


def on_code_edit(ev):
    global _TABIFY_POST_TEXT
    # Any manual edit invalidates tabify-undo ownership
    _TABIFY_POST_TEXT = None

    # Remove result state on any edit
    for el in elList:
        el.classList.remove("run-running", "run-ok", "run-error")

code.bind("input", on_code_edit)


def show_errors(show):
    if show:
        errors_section.removeAttribute("hidden")
    else:
        errors_section.setAttribute("hidden", "")
        errors_pre.textContent = ""

def reveal_and_scroll_errors():
    # Ensure layout is updated before scrolling (important when toggling hidden/display)
    try:
        show_errors(True)
    except Exception:
        pass

    def _do_scroll(*args):
        try:
            # Make sure any pending stdout flush doesn't fight the scroll
            force_flush_out()
        except Exception:
            pass

        try:
            errors_pre.scrollIntoView({"behavior": "smooth", "block": "start"})
        except Exception:
            # fallback: jump scroll without options object
            try:
                errors_pre.scrollIntoView()
            except Exception:
                pass

    try:
        window.setTimeout(_do_scroll, 0)
    except Exception:
        _do_scroll()


########################
#   DICT from URL  #
########################


########################
# PYLITE HEADER DICT
########################

# New branded header:
#   # pylite = {...json...}
# Backward compatibility:
#   # info = {...json...}

PYLITE_RE = re.compile(r'^\s*#\s*pylite\s*=\s*(\{.*\})\s*$')
INFO_RE_OLD = re.compile(r'^\s*#\s*info\s*=\s*(\{.*\})\s*$')

def _now_iso_seconds():
    return datetime.datetime.now().isoformat(timespec="seconds")

def strip_header_and_extract_pylite(src: str):
    """
    Returns (body, pylite_dict).

    Supports:
      - New single-line header: '# pylite = {...}'
      - Old single-line header: '# info = {...}'  (upgrades in-memory)
      - Legacy two-line header: '# tag:' + '# shared:' (upgrades in-memory)

    If no header is present, returns the original src as body and {} as dict.
    """
    lines = src.splitlines()
    pylite = {}

    if not lines:
        return "", pylite

    # New branded header
    m = PYLITE_RE.match(lines[0])
    if m:
        raw = m.group(1)
        try:
            pylite = json.loads(raw)
            if not isinstance(pylite, dict):
                pylite = {}
        except Exception:
            pylite = {}
        return "\n".join(lines[1:]), pylite

    # Old single-line header
    m2 = INFO_RE_OLD.match(lines[0])
    if m2:
        raw = m2.group(1)
        try:
            pylite = json.loads(raw)
            if not isinstance(pylite, dict):
                pylite = {}
        except Exception:
            pylite = {}
        return "\n".join(lines[1:]), pylite

    # Legacy two-line header
    if len(lines) >= 2 and lines[0].lstrip().startswith("# tag:") and lines[1].lstrip().startswith("# shared:"):
        tag = lines[0].lstrip()[len("# tag:"):].strip()
        shared = lines[1].lstrip()[len("# shared:"):].strip()
        pylite = {"v": 1, "tag": tag, "shared": shared}
        return "\n".join(lines[2:]), pylite

    return src, pylite

def seed_pylite_from_url(pylite: dict) -> dict:
    """Seed pylite metadata from URL params WITHOUT marking as shared."""
    if not isinstance(pylite, dict):
        pylite = {}

    params = parse_params()
    url_tag = params.get("tag", "")
    url_file = params.get("file", "")

    if url_tag:
        pylite["tag"] = url_tag
    else:
        pylite.pop("tag", None)

    if url_file:
        pylite["file"] = url_file
    else:
        pylite.pop("file", None)

    pylite.setdefault("v", 2)
    return pylite

def render_pylite_header_line(pylite: dict) -> str:
    """Render the branded header line. Empty dict becomes '# pylite = {}'."""
    if not isinstance(pylite, dict):
        pylite = {}
    payload = json.dumps(pylite, separators=(",", ":"), ensure_ascii=False)
    return f"# pylite = {payload}"

def ensure_pylite_header_present():
    """Guarantee line 1 is '# pylite = {...}' even before any sharing."""
    try:
        current = code.value or ""
    except Exception:
        return

    body, pylite = strip_header_and_extract_pylite(current)
    pylite = seed_pylite_from_url(pylite)   # <-- required
    header = render_pylite_header_line(pylite)
    code.value = header + "\n" + (body or "")

    # If no sharing yet, keep it blank. Otherwise keep whatever keys exist.
    # (We don't add shared/shared_reason here.)
    header = render_pylite_header_line(pylite)

    new_text = header + "\n" + (body or "")
    if new_text != current:
        code.value = new_text

def make_watermarked_source(shared_reason: str):
    params = parse_params()
    url_tag = params.get("tag", "")
    url_file = params.get("file", "")

    body, pylite = strip_header_and_extract_pylite(code.value)

    # URL tag wins if present; otherwise keep stored value (or blank)
    if url_tag:
        pylite["tag"] = url_tag
    else:
        pylite.setdefault("tag", "")

    # URL file wins if present; otherwise omit (don't invent)
    if url_file:
        pylite["file"] = url_file
    else:
        pylite.pop("file", None)

    pylite["shared"] = _now_iso_seconds()
    pylite["shared_reason"] = shared_reason
    pylite.setdefault("v", 2)

    return render_pylite_header_line(pylite) + "\n" + (body or "")


# =========================
# Share / Refresh URL policy
# =========================

MAX_SHARE_URL_LEN = 6500  # target limit you referenced

def _set_share_disabled(disabled: bool):
    # disable BOTH buttons in lockstep
    try:
        document["share"].disabled = disabled
    except Exception:
        pass
    try:
        document["refresh_share"].disabled = disabled
    except Exception:
        pass

def _set_url_warning(msg: str):
    try:
        document["urlWarning"].text = msg or ""
    except Exception:
        pass

def enforce_share_url_policy(url: str) -> bool:
    """
    Returns True if the URL is allowed (buttons remain enabled).
    Returns False if too long (warning is shown and buttons are disabled).
    """
    n = len(url or "")
    if n > MAX_SHARE_URL_LEN:
        _set_url_warning(f"Share link too long ({n} characters; max ≈ {MAX_SHARE_URL_LEN}).")
        _set_share_disabled(True)
        return False

    # ok
    _set_url_warning("")
    _set_share_disabled(False)
    return True

def enforce_share_policy_for_current_code():
    """
    Compute the share URL for the current editor content and enforce policy.
    This is safe to call on load and after URL-based code loads.
    """
    try:
        shared_src = make_watermarked_source("share")   # does NOT modify editor text
        code_b64 = window.encodeCodeForUrl(shared_src)
        url = build_share_url_for_code(code_b64)
        enforce_share_url_policy(url)                   # should set warning + disable buttons
    except Exception:
        # Fail open (don’t brick the UI if something odd happens)
        try:
            _set_url_warning("")
            _set_share_disabled(False)
        except Exception:
            pass

def share_current_url(ev):
    shared_src = make_watermarked_source("share")

    code_b64 = window.encodeCodeForUrl(shared_src)
    url = build_share_url_for_code(code_b64)

    # NEW: enforce length policy (may disable buttons + show warning)
    if not enforce_share_url_policy(url):
        # still reveal the URL box so they can SEE what happened, if you want
        try:
            container = document["share_url_container"]
            if "hidden" in container.attrs:
                del container.attrs["hidden"]
            document["share_url_debug"].value = url
        except Exception:
            pass
        return url  # or return None, but returning url keeps your current behavior

    # UI debug (optional)
    try:
        container = document["share_url_container"]
        if "hidden" in container.attrs:
            del container.attrs["hidden"]
        document["share_url_debug"].value = url
    except KeyError:
        pass

    # Copy to clipboard (best effort)
    try:
        window.navigator.clipboard.writeText(url)
    except Exception as e:
        ...
    return url



def refresh_with_share(ev):
    shared_src = make_watermarked_source("refresh")
    code_b64 = window.encodeCodeForUrl(shared_src)
    url = build_share_url_for_code(code_b64)

    # NEW: enforce length policy
    if not enforce_share_url_policy(url):
        # reveal box so they can see/copy manually if they insist
        try:
            container = document["share_url_container"]
            if "hidden" in container.attrs:
                del container.attrs["hidden"]
            document["share_url_debug"].value = url
        except Exception:
            pass
        return  # critical: do NOT open a new tab

    # Reveal the URL box (so the user can copy even if popups are blocked)
    try:
        container = document["share_url_container"]
        if "hidden" in container.attrs:
            del container.attrs["hidden"]
        document["share_url_debug"].value = url
    except Exception:
        pass

    # Best-effort copy
    try:
        window.navigator.clipboard.writeText(url)
    except Exception:
        pass

    # Open in a NEW tab (don’t replace this tab)
    try:
        window.open(url, "_blank", "noopener,noreferrer")
    except Exception:
        pass



document["run1"].bind("click", run_code)
document["run2"].bind("click", run_code)
document["stop"].bind("click", cancel_run)
document["clear_output"].bind("click", clear_output)
document["share"].bind("click", share_current_url)
document["refresh_share"].bind("click", refresh_with_share)

########################
#   WRAP OUTPUT ONLY   #
########################

# default state: no wrap
try:
    output_wrap_enabled = ("wrap" in output_pre.classList)
except Exception:
    output_wrap_enabled = True   # fallback: default ON

def _apply_wrap_ui():
    """Sync output_pre and wrap_toggle visuals with output_wrap_enabled."""
    btn = document["wrap_toggle"]

    if output_wrap_enabled:
        # output pane classes
        output_pre.classList.add("wrap")
        output_pre.classList.remove("no-wrap")

        # button "on" visual state (CSS .wrap-btn.on)
        btn.classList.add("on")

        # optional: keep title accurate
        btn.attrs["title"] = "Word wrap: on"
        btn.attrs["aria-label"] = "Word wrap: on"
    else:
        output_pre.classList.add("no-wrap")
        output_pre.classList.remove("wrap")

        btn.classList.remove("on")
        btn.attrs["title"] = "Word wrap: off"
        btn.attrs["aria-label"] = "Word wrap: off"

def toggle_output_wrap(ev):
    global output_wrap_enabled
    output_wrap_enabled = not output_wrap_enabled
    _apply_wrap_ui()

# Ensure an initial class is set on page load
try:
    # HTML already seeds a class,  detect it here;
    # otherwise this will enforce "no-wrap" at startup.
    _apply_wrap_ui()
except Exception:
    pass

document["wrap_toggle"].bind("click", toggle_output_wrap)


##################################
#   BLOCK PASTE / DROP / KEYS    #
##################################

def block(ev):
    ev.preventDefault()
    ev.stopPropagation()

def on_paste(ev):
    block(ev)
    window.alert("Pasting is disabled in this editor.")

def on_drop(ev):
    block(ev)

def on_dragover(ev):
    block(ev)

def on_dragenter(ev):
    block(ev)

def on_context_menu(ev):
    block(ev)

def on_keydown(ev):
    global _TABIFY_POST_TEXT
    key = ev.key
    ctrl = ev.ctrlKey
    meta = ev.metaKey
    shift = ev.shiftKey

    # Cmd/Ctrl+Enter => Run
    if key == "Enter" and (meta or ctrl):
        ev.preventDefault()
        run_code(ev)
        return

    # Cmd/Ctrl+Z => Undo tabify ONLY if the last thing applied was tabify
    # Otherwise let the browser/native textarea undo work.
    if (meta or ctrl) and key.lower() == "z":
        try:
            can_tabify_undo = bool(_TABIFY_UNDO) and (_TABIFY_POST_TEXT is not None) and (code.value == _TABIFY_POST_TEXT)
        except Exception:
            can_tabify_undo = False

        if can_tabify_undo:
            ev.preventDefault()
            undo_tabify()
            # After undo, we're no longer “at the post-tabify state”

            _TABIFY_POST_TEXT = None
            return
        # else: do NOT preventDefault — allow native undo


    # Optional redo support:
    # - Cmd/Ctrl+Shift+Z (common)
    # - Ctrl+Y (Windows common)
    if (meta or ctrl) and ((shift and key.lower() == "z") or (not meta and key.lower() == "y")):
        if _TABIFY_REDO:
            ev.preventDefault()
            redo_tabify()
            return

    if PASTE_BLOCK_ENABLED:
        if (key.lower() == "v" and (ctrl or meta)) or (key == "Insert" and shift):
            block(ev)
            window.alert("Paste via keyboard is disabled.")
            return


if PASTE_BLOCK_ENABLED:
    code.bind("paste", on_paste)
    code.bind("drop", on_drop)
    code.bind("dragover", on_dragover)
    code.bind("dragenter", on_dragenter)
    code.bind("contextmenu", on_context_menu)

code.bind("keydown", on_keydown)



def prewarm_worker_if_needed():
    try:
        if document["force_async"].checked:
            dlog("pyscript.py: prewarm ensure_worker()")
            ensure_worker()
    except Exception:
        pass

# Let the UI paint first, then warm the worker immediately.


########################
#   INITIAL SETUP      #
########################

load_code_from_url()
ensure_pylite_header_present()
update_gutter()

# NEW: disable Share/Refresh immediately if the share URL is too long on load
enforce_share_policy_for_current_code()

timer.set_timeout(prewarm_worker_if_needed, 0)