from __future__ import annotations

import inspect
import linecache
import re
import sys
import tokenize
from collections import deque, namedtuple
from contextlib import suppress
from pathlib import Path
from secrets import token_urlsafe
from typing import Any
from urllib.parse import quote

from . import trace_cpy
from .inspector import extract_variables
from .logging import logger
from .syntaxerror import clean_syntax_error_message, extract_enhanced_positions

# Position range: lines are 1-based inclusive, columns are 0-based exclusive
Range = namedtuple("Range", ["lfirst", "lfinal", "cbeg", "cend"])


def compute_cursor_position(
    mark_range: Range | None,
    em_ranges: Range | list[Range] | None,
    linenostart: int,
    common_indent: str = "",
) -> tuple[int, int]:
    """Compute the preferred cursor position from mark and emphasis ranges.

    Prefers the end of emphasis (em) ranges if available, as these mark the
    error position more precisely. Falls back to end of mark range, then
    to line 1, column 0.

    Args:
        mark_range: The marked region Range, or None
        em_ranges: The emphasis Range, list of Ranges, or None
        linenostart: The starting line number of the displayed code (for conversion)
        common_indent: The common indent string that was stripped (to restore original columns)

    Returns:
        Tuple of (line, column) where line is 1-based absolute line number
        and column is 0-based.
    """
    indent_len = len(common_indent)

    # Try emphasis ranges first (more precise error position)
    if em_ranges:
        if isinstance(em_ranges, list) and em_ranges:
            # Use the last em range's end position
            last_em = em_ranges[-1]
            # Convert from context-relative to absolute line number
            # lfinal is 1-based relative to displayed code, linenostart is absolute
            line = linenostart + last_em.lfinal - 1
            # cend is 0-based exclusive in dedented code, add indent for original
            col = last_em.cend + indent_len
            return (line, col)
        elif isinstance(em_ranges, Range):
            line = linenostart + em_ranges.lfinal - 1
            col = em_ranges.cend + indent_len
            return (line, col)

    # Fall back to mark range
    if mark_range:
        line = linenostart + mark_range.lfinal - 1
        col = mark_range.cend + indent_len
        return (line, col)

    # No range information available
    return (linenostart, 0)


# Will be set to an instance if loaded as an IPython extension by %load_ext
ipython: Any = None

# Locations considered to be bug-free (library code, not user code), capture pretty suffix
libdir = re.compile(
    r".*(?:site-packages|dist-packages)/(.+)"
    r"|.*/lib/python\d+\.\d+/(.+)"
    r"|.*/bin/([^/]+)(?<!\.py)"  # CLI scripts
    r"|.*/\.cache/(.+)"
)

# Messages for exception chaining (oldest-first order)
# Suffix added to exception type when chained from a previous exception
chainmsg = {
    "cause": " from previous",
    "context": " in except",
    "none": "",
}

# Symbol descriptions for display in HTML and TTY outputs
symdesc = {
    "call": "Call",
    "warning": "Call from your code",
    "except": "Call from except",
    "error": "",
    "stop": "",
}

# Symbols for each frame relevance type
symbols = {"call": "➤", "warning": "⚠️", "error": "💣", "stop": "🛑", "except": "⚠️"}


def build_chain_header(chain: list[dict]) -> str:
    """Build a header message describing the exception chain."""
    if not chain:
        return ""

    # Chain is oldest-first: chain[0] is first exception, chain[-1] is last (uncaught)
    last_exc = chain[-1]

    # For ExceptionGroups, show the final exception types from subexceptions
    subexceptions = last_exc.get("subexceptions")
    if subexceptions:
        leaf_types = _collect_leaf_exception_types(subexceptions)
        if leaf_types:
            exc_type = " | ".join(leaf_types)
            # Don't say "Uncaught" for ExceptionGroups, just show the leaf types
            if len(chain) == 1:
                return f"⚠️  {exc_type}"
        else:
            exc_type = last_exc.get("type", "Exception")
    else:
        exc_type = last_exc.get("type", "Exception")

    if len(chain) == 1:
        return f"⚠️  Uncaught {exc_type}"

    # Build from last to first
    parts = [f"⚠️  {exc_type}"]

    # Add each previous exception with appropriate joiner
    for i in range(len(chain) - 2, -1, -1):
        exc = chain[i]
        next_exc = chain[i + 1]
        from_type = next_exc.get("from", "none")
        joiner = "from" if from_type == "cause" else "while handling"
        parts.append(f"{joiner} {exc.get('type', 'Exception')}")

    return " ".join(parts)


def _collect_leaf_exception_types(subexceptions: list[list[dict]]) -> list[str]:
    """Collect the final exception types from all subexception chains.

    For nested ExceptionGroups, recursively collects leaf exception types.
    Returns a flat list of exception type names.
    """
    leaf_types = []
    for sub_chain in subexceptions:
        if not sub_chain:
            continue
        # Get the last exception in this chain (the one that was raised)
        last_exc = sub_chain[-1]
        # Check if this is itself an ExceptionGroup with subexceptions
        nested_subs = last_exc.get("subexceptions")
        if nested_subs:
            # Recursively collect from nested ExceptionGroup
            leaf_types.extend(_collect_leaf_exception_types(nested_subs))
        else:
            # This is a leaf exception
            leaf_types.append(last_exc.get("type", "Exception"))
    return leaf_types


def extract_chain(exc=None, **kwargs) -> list:
    """Extract information on current exception.

    Returns a list of exception info dicts, ordered from oldest to newest
    (i.e., the original exception first, then any exceptions that occurred
    while handling it or were raised from it).
    """
    chain = []
    exc = exc or sys.exc_info()[1]
    while exc:
        chain.append(exc)
        exc = exc.__cause__ or None if exc.__suppress_context__ else exc.__context__
    # Reverse to get oldest first (chain is built newest-first)
    chain = list(reversed(chain))
    result = [extract_exception(e, **(kwargs if e is chain[-1] else {})) for e in chain]
    # Deduplicate variable inspectors: only keep variables for the last occurrence
    # of each (filename, function) pair across the entire chain
    _deduplicate_variables(result)
    return result


def _deduplicate_variables(chain: list) -> None:
    """Show the variable inspector only once per frame object.

    When an exception is caught and re-raised inside the same function, the
    traceback contains multiple entries pointing at the same frame object.
    Only the last of those entries keeps its inspector; earlier entries are
    cleared so the inspector is not shown multiple times.  Recursive calls have
    distinct frame objects, so each level keeps its own inspector.  Because
    exception-message filtering is already applied to the last entry, the
    message variable is hidden from all entries for that frame automatically.
    """

    # Group frames by the identity of the underlying frame object.  Two frames
    # for the same recursive call are different objects, while two traceback
    # entries produced by a re-raise inside the same function (or at module
    # level) point at the same frame object.
    frame_groups: dict[int, list[tuple[int, int]]] = {}
    for ei, exc in enumerate(chain):
        for fi, frame in enumerate(exc["frames"]):
            frame_groups.setdefault(frame["idframe"], []).append((ei, fi))

    # Only the last occurrence of each frame object keeps its inspector.
    for occurrences in frame_groups.values():
        for ei, fi in occurrences[:-1]:
            chain[ei]["frames"][fi]["variables"] = []


def _create_summary(message):
    """Extract the first line of the exception message as summary."""
    return message.split("\n", 1)[0]


def _set_relevances(frames: list, e: BaseException) -> None:
    """Set relevance for frames after extraction.

    - The last frame gets "error" (regular Exception) or "stop" (BaseException like KeyboardInterrupt)
    - ExceptionGroups also get "stop" since the interesting parts are in subexceptions
    - If the last frame is in library code, the last user code frame gets "warning"
    - All other frames remain "call"
    """
    if not frames:
        return

    # Last frame is where the exception occurred
    # ExceptionGroups get "stop" like BaseExceptions - the real errors are in subexceptions
    is_regular_exception = isinstance(e, Exception) and not _is_exception_group(e)
    frames[-1]["relevance"] = "error" if is_regular_exception else "stop"

    # Check if the last frame (error frame) is in user code
    last_filename = (
        frames[-1].get("original_filename") or frames[-1].get("filename") or ""
    )
    if _libdir_match(Path(last_filename).as_posix()) is None:
        return
    # Error is in library code - find the last user code frame to mark as warning
    for frame in reversed(frames[:-1]):  # Exclude the last frame  # pragma: no cover
        filename = frame.get("original_filename") or frame.get("filename") or ""
        if _libdir_match(Path(filename).as_posix()) is None:
            # This is user code - mark as warning (bug origin)
            frame["relevance"] = "warning"
            break


def extract_exception(e, *, skip_outmost=0, skip_until=None) -> dict:
    raw_tb = e.__traceback__
    try:
        tb = inspect.getinnerframes(raw_tb)
    except IndexError:  # Bug in inspect internals, find_source()
        logger.exception("Bug in inspect?")
        tb = []
        raw_tb = None

    # For SyntaxError, check if the error is in user code (notebook cell or matching skip_until)
    syntax_frame = None
    if isinstance(e, SyntaxError):
        syntax_frame = _extract_syntax_error_frame(e)
        if syntax_frame:
            # Check if this is a notebook cell (using IPython's filename map) or matches skip_until
            is_user_code = _is_notebook_cell(e.filename) or (
                skip_until and skip_until in (e.filename or "")
            )
            if is_user_code:
                skip_outmost = len(tb)  # Skip all frames

    if skip_until and skip_outmost == 0:
        for i, frame in enumerate(tb):
            if skip_until in frame.filename:
                skip_outmost = i
                break
    tb = tb[skip_outmost:]

    # Also skip the same number of frames from raw_tb
    if raw_tb and skip_outmost > 0:
        for _ in range(skip_outmost):
            if raw_tb:
                raw_tb = raw_tb.tb_next

    # Header and exception message
    message = getattr(e, "message", "") or str(e)
    # For SyntaxError, trim redundant location info from message
    if isinstance(e, SyntaxError):
        message = clean_syntax_error_message(message)
    summary = _create_summary(message)
    # Check if context is suppressed (raise X from None) - affects source trimming
    f = (
        "cause"
        if e.__cause__
        else "context"
        if e.__context__ and not e.__suppress_context__
        else "none"
    )
    try:
        frames = extract_frames(
            tb, raw_tb, except_block=(f != "none"), exc=e, exc_message=message
        )
        # For SyntaxError, add the synthetic frame showing the problematic code
        if syntax_frame:
            # Demote the previous frame (compile, exec, etc.) to call only
            if frames and frames[-1]["relevance"] == "error":
                frames[-1]["relevance"] = "call"
            frames.append(syntax_frame)
    except Exception:
        logger.exception("Error extracting traceback")
        frames = None

    # Determine if this is a "stop" type exception (BaseException or ExceptionGroup)
    # These suppress inner library frames, showing only up to the last user code frame.
    # ExceptionGroups suppress because the interesting parts are in subexceptions.
    is_stop_type = not isinstance(e, Exception) or _is_exception_group(e)

    result = {
        "type": type(e).__name__,
        "message": message,
        "summary": summary,
        "from": f,
        "repr": repr(e),
        "frames": frames or [],
        "suppress_inner": is_stop_type,
    }

    # Extract subexceptions for ExceptionGroups (Python 3.11+)
    # These form parallel timelines within the group's traceback
    subexceptions = _extract_subexceptions(
        e, skip_outmost=skip_outmost, skip_until=skip_until
    )
    if subexceptions:
        result["subexceptions"] = subexceptions

    return result


def _extract_subexceptions(
    e, *, skip_outmost=0, skip_until=None
) -> list[list[dict]] | None:
    """Extract subexceptions from an ExceptionGroup.

    ExceptionGroups (Python 3.11+) contain multiple exceptions that occurred
    in parallel (e.g., in concurrent tasks). Each subexception forms its own
    traceback chain that ran in parallel with others.

    Args:
        e: The exception to check for subexceptions
        skip_outmost: Number of outermost frames to skip
        skip_until: Skip frames until this string is found in filename

    Returns:
        List of exception chains (each chain is a list of exception info dicts),
        or None if not an ExceptionGroup or has no subexceptions.
        Each chain represents a parallel timeline of exceptions.
    """
    # Check if this is an ExceptionGroup (Python 3.11+)
    # BaseExceptionGroup is the base class for both ExceptionGroup and BaseExceptionGroup
    if not hasattr(e, "exceptions") or not isinstance(
        getattr(e, "exceptions", None), (tuple, list)
    ):
        return None

    subexceptions = e.exceptions
    if not subexceptions:
        return None

    # Extract each subexception as its own chain
    # Each subexception may itself be an ExceptionGroup with nested subexceptions
    parallel_chains = []
    for sub_exc in subexceptions:
        # Recursively extract the chain for this subexception
        # This handles nested ExceptionGroups and exception chaining within each sub
        sub_chain = _extract_subexception_chain(
            sub_exc, skip_outmost=skip_outmost, skip_until=skip_until
        )
        if sub_chain:  # pragma: no cover
            parallel_chains.append(sub_chain)

    return parallel_chains if parallel_chains else None


def _extract_subexception_chain(exc, *, skip_outmost=0, skip_until=None) -> list[dict]:
    """Extract the full exception chain for a single subexception.

    Similar to extract_chain but for a subexception that may have its own
    __cause__ or __context__ chain.

    Args:
        exc: The subexception to extract
        skip_outmost: Number of outermost frames to skip
        skip_until: Skip frames until this string is found in filename

    Returns:
        List of exception info dicts, ordered from oldest to newest
    """
    chain = []
    current = exc
    while current:
        chain.append(current)
        current = (
            current.__cause__ or None
            if current.__suppress_context__
            else current.__context__
        )
    # Reverse to get oldest first
    chain = list(reversed(chain))

    # Extract info for each exception in the chain
    # Pass skip args only to the last one (the actual subexception)
    kwargs = {"skip_outmost": skip_outmost, "skip_until": skip_until}
    result = [extract_exception(e, **(kwargs if e is chain[-1] else {})) for e in chain]
    return result


def _is_notebook_cell(filename):
    """Check if the filename corresponds to a Jupyter notebook cell."""
    try:
        return filename in ipython.compile._filename_map  # type: ignore[attr-defined]
    except (AttributeError, KeyError, TypeError):
        return False


def _is_exception_group(e: BaseException) -> bool:
    """Check if exception is an ExceptionGroup (Python 3.11+)."""
    # Check for BaseExceptionGroup which is the base class for both
    # ExceptionGroup and BaseExceptionGroup
    return hasattr(e, "exceptions") and isinstance(
        getattr(e, "exceptions", None), (tuple, list)
    )


def _find_except_start_for_line(frame, lineno: int) -> int | None:
    """If lineno is inside an except handler, return the except line number.

    Uses AST analysis to find if the given line is within an except block.
    Returns the line number of the 'except' keyword for the innermost matching
    except handler, or None if not in an except block.
    """
    from .chain_analysis import (
        find_try_block_for_except_line,
        parse_source_for_try_except,
    )

    try:
        filename = frame.f_code.co_filename
        blocks = parse_source_for_try_except(filename)
        # Find the innermost except block containing this line
        block = find_try_block_for_except_line(blocks, lineno)
        if block:
            return block.except_start
    except Exception:  # pragma: no cover
        pass
    return None


def _get_source_lines_from_code(code, lineno: int, end_lineno: int | None = None):
    """Get source lines from a code object using Python 3.11+ linecache API.

    This provides a fallback for getting source code for interactive code
    (REPL, -c command, exec'd strings) where inspect.getsourcelines() fails.

    Args:
        code: The code object from a frame (frame.f_code)
        lineno: The line number where the error occurred (1-based)
        end_lineno: Optional end line number for multi-line errors

    Returns:
        (lines, start) tuple where lines is a list of source lines with
        newlines, or (None, None) if source cannot be retrieved.
    """
    # Python 3.13+ has linecache._getline_from_code for interactive code
    if not hasattr(linecache, "_getline_from_code"):
        return None, None  # pragma: no cover

    # First, check if we can get the error line at all
    error_line = linecache._getline_from_code(code, lineno)
    if not error_line:
        return None, None

    first_lineno = code.co_firstlineno
    is_module = code.co_name in (
        "<module>",
        "<listcomp>",
        "<dictcomp>",
        "<setcomp>",
        "<genexpr>",
    )

    # For module level, just get context around the error line
    if is_module:
        start = max(1, lineno - 10)
        final = (end_lineno or lineno) + 3
        lines = []
        actual_start = None
        for ln in range(start, final + 1):
            line = linecache._getline_from_code(code, ln)
            if line:
                if actual_start is None:
                    actual_start = ln
                lines.append(line)
            elif lines and ln > (end_lineno or lineno):  # pragma: no cover
                break  # Stop at empty lines after error (e.g., end of source)
        # Defensive: error_line check above guarantees we have lines
        if not lines or actual_start is None:  # pragma: no cover
            return None, None
        return lines, actual_start

    # For functions/methods, collect all lines starting from definition
    # then use inspect.getblock to find the function boundaries
    all_lines = []
    ln = first_lineno
    while True:
        line = linecache._getline_from_code(code, ln)
        if not line:
            break
        all_lines.append(line)
        ln += 1

    # Defensive: error_line check above guarantees we have lines
    if not all_lines:  # pragma: no cover
        return None, None

    # Use inspect.getblock to find the function's extent (same as inspect.getsourcelines)
    try:
        block_lines = inspect.getblock(all_lines)
    except (IndentationError, SyntaxError, tokenize.TokenError):  # pragma: no cover
        # Fallback: just use lines up to a reasonable extent
        block_lines = all_lines[: (end_lineno or lineno) - first_lineno + 3]

    return block_lines, first_lineno


def extract_source_lines(
    frame, lineno, end_lineno=None, *, notebook_cell=False, except_block=False
):
    try:
        lines, start = inspect.getsourcelines(frame)
        if start == 0:
            start = 1

        # Check if lineno is inside an except handler BEFORE trimming
        # This ensures we include the except line even for notebook cells
        # Skip this detection if context was suppressed (raise X from None)
        except_start = (
            _find_except_start_for_line(frame, lineno) if except_block else None
        )

        # For notebook cells, show only the error lines (no context)
        # For regular files, show 10 lines before and 2 lines after
        # Exception: if we're in an except block, ensure except line is included
        if notebook_cell:
            if except_start is not None and except_start >= start:
                # In except block: include from except line to lineno
                lines_before = lineno - except_start  # pragma: no cover
            else:
                lines_before = 0
            lines_after = (end_lineno - lineno) if end_lineno else 0
        else:
            lines_before = 10
            lines_after = (end_lineno - lineno + 2) if end_lineno else 2
        # Calculate slice bounds
        slice_start = max(0, lineno - start - lines_before)
        slice_end = max(0, lineno - start + lines_after + 1)

        # Skip forward if the slice would start inside a string or unclosed parens
        # Analyze all lines before slice_start to determine context state
        skip_to = _find_clean_start_line(lines, slice_start)
        if skip_to > slice_start:
            slice_start = skip_to

        lines = lines[slice_start:slice_end]
        start += slice_start

        # If lineno is inside an except handler, trim to start from the except line
        # (For non-notebook cells, this may still trim if lines_before > distance to except)
        if except_start is not None and except_start > start:
            skip = except_start - start
            if skip < len(lines):  # pragma: no branch
                lines = lines[skip:]
                start = except_start

        # Calculate error line position
        error_idx = lineno - start
        end_idx = (end_lineno - start) if end_lineno else error_idx

        # Safety check: ensure error_idx is valid
        if not lines or error_idx < 0 or error_idx >= len(lines):
            return "", lineno, ""

        # Get the indentation of the first marked line (error line) before any dedenting
        error_indent = 0
        error_line = lines[error_idx]
        error_indent = len(error_line) - len(error_line.lstrip(" \t"))

        # Trim leading lines that have more indentation than error line
        while lines and error_idx > 0:
            first_line = lines[0]
            if first_line.strip():
                first_indent = len(first_line) - len(first_line.lstrip(" \t"))
                if first_indent <= error_indent:
                    break  # This line has same or less indent, keep it
            start += 1
            lines.pop(0)
            error_idx -= 1
            end_idx -= 1

        # Trim trailing lines with less indentation than the error line
        # (hides external structures like else/except that aren't relevant)
        # But don't trim if we're inside unclosed brackets (e.g., list comprehension)
        trim_after = end_idx + 1
        bracket_depth = _count_bracket_depth("".join(lines[: end_idx + 1]))
        while trim_after < len(lines):
            line = lines[trim_after]
            # Keep lines if brackets are still open
            if bracket_depth > 0:
                bracket_depth += _count_bracket_depth(line)
                trim_after += 1
                continue
            # Keep empty lines, but check non-empty lines for indentation
            if line.strip():
                line_indent = len(line) - len(line.lstrip(" \t"))
                if line_indent < error_indent:
                    break  # Found a line with less indent, trim from here
            trim_after += 1
        lines = lines[:trim_after]

        # Calculate common indentation and dedent AFTER pruning
        common_indent = _calculate_common_indent(lines)
        lines = [ln.removeprefix(common_indent) for ln in lines]

        return "".join(lines), start, common_indent
    except OSError:
        # Fallback: try to get source from code object (Python 3.13+ interactive code)
        # This is tested via subprocess tests in test_tty.py::TestInteractiveSourceRetrieval
        code = frame.f_code if hasattr(frame, "f_code") else frame  # pragma: no cover
        fallback_lines, fallback_start = (
            _get_source_lines_from_code(  # pragma: no cover
                code, lineno, end_lineno
            )
        )
        if fallback_lines:  # pragma: no cover
            common_indent = _calculate_common_indent(fallback_lines)
            lines = [ln.removeprefix(common_indent) for ln in fallback_lines]
            return "".join(lines), fallback_start, common_indent
        return "", lineno, ""  # Source not available (non-Python module)


def _count_bracket_depth(text: str) -> int:
    """Count net bracket depth change in text, ignoring brackets in strings/comments.

    Returns positive for more opens than closes, negative for more closes.
    """
    depth = 0
    in_string = False
    string_char = None
    escape_next = False
    i = 0

    while i < len(text):
        char = text[i]

        if escape_next:
            escape_next = False
            i += 1
            continue

        if char == "\\":
            escape_next = True
            i += 1
            continue

        # Handle comments (outside strings)
        if not in_string and char == "#":
            break  # Rest of line is comment

        # Handle string boundaries
        if not in_string:
            # Check for triple-quoted strings
            if char in ('"', "'") and text[i : i + 3] in ('"""', "'''"):
                in_string = True
                string_char = text[i : i + 3]
                i += 3
                continue
            elif char in ('"', "'"):
                in_string = True
                string_char = char
        else:
            # Check for end of string
            if string_char in ('"""', "'''") and text[i : i + 3] == string_char:
                in_string = False
                string_char = None
                i += 3
                continue
            elif (
                string_char is not None
                and len(string_char) == 1
                and char == string_char
            ):
                in_string = False
                string_char = None

        # Count brackets only outside strings
        if not in_string:
            if char in "([{":
                depth += 1
            elif char in ")]}":
                depth -= 1

        i += 1

    return depth


def _find_clean_start_line(lines: list[str], target_idx: int) -> int:
    """Find the first line at or after target_idx that isn't inside an unclosed context.

    Analyzes lines[0:target_idx] to determine if target_idx would start inside:
    - A multi-line string (triple-quoted docstring, etc.)
    - An unclosed parenthesis/bracket/brace expression

    If so, scans forward from target_idx to find where that context closes,
    returning the index of the first "clean" line.

    Args:
        lines: List of source lines (with newlines)
        target_idx: The 0-based index we want to start displaying from

    Returns:
        Index >= target_idx of the first line not inside an unclosed context
    """
    if target_idx <= 0 or target_idx >= len(lines):
        return target_idx

    # Parse all lines before target to determine state at target_idx
    in_string = False
    string_char = None  # The quote char(s) that opened the string
    bracket_depth = 0

    for line in lines[:target_idx]:
        i = 0
        text = line
        escape_next = False

        while i < len(text):
            char = text[i]

            if escape_next:
                escape_next = False
                i += 1
                continue

            if char == "\\" and in_string:
                escape_next = True
                i += 1
                continue

            # Handle comments (outside strings)
            if not in_string and char == "#":
                break  # Rest of line is comment

            # Handle string boundaries
            if not in_string:
                # Check for triple-quoted strings first
                if char in ('"', "'") and text[i : i + 3] in ('"""', "'''"):
                    in_string = True
                    string_char = text[i : i + 3]
                    i += 3
                    continue
                elif char in ('"', "'"):
                    in_string = True
                    string_char = char
            else:
                # Check for end of string
                if string_char in ('"""', "'''") and text[i : i + 3] == string_char:
                    in_string = False
                    string_char = None
                    i += 3
                    continue
                elif (
                    string_char is not None
                    and len(string_char) == 1
                    and char == string_char
                ):
                    in_string = False
                    string_char = None

            # Count brackets only outside strings
            if not in_string:
                if char in "([{":
                    bracket_depth += 1
                elif char in ")]}":
                    bracket_depth -= 1

            i += 1

    # If we're not in a bad context, target_idx is fine
    if not in_string and bracket_depth <= 0:
        return target_idx

    # Scan forward from target_idx until context closes
    # This is defensive code for rare edge cases (multiline strings/brackets at slice boundary)
    for idx in range(target_idx, len(lines)):  # pragma: no cover
        text = lines[idx]
        i = 0
        escape_next = False

        while i < len(text):
            char = text[i]

            if escape_next:
                escape_next = False
                i += 1
                continue

            if char == "\\" and in_string:
                escape_next = True
                i += 1
                continue

            # Handle comments (outside strings)
            if not in_string and char == "#":
                break

            # Handle string boundaries
            if not in_string:
                if char in ('"', "'") and text[i : i + 3] in ('"""', "'''"):
                    in_string = True
                    string_char = text[i : i + 3]
                    i += 3
                    continue
                elif char in ('"', "'"):
                    in_string = True
                    string_char = char
            else:
                if string_char in ('"""', "'''") and text[i : i + 3] == string_char:
                    in_string = False
                    string_char = None
                    i += 3
                    continue
                elif string_char and len(string_char) == 1 and char == string_char:
                    in_string = False
                    string_char = None

            if not in_string:
                if char in "([{":
                    bracket_depth += 1
                elif char in ")]}":
                    bracket_depth -= 1

            i += 1

        # After processing this line, check if we've exited the bad context
        if not in_string and bracket_depth <= 0:
            return idx + 1  # Start from the line AFTER the context closes

    # Couldn't find clean exit, fall back to target
    return target_idx  # pragma: no cover


def _get_full_source(frame, lineno=None):
    """Get the full source code for a frame using inspect.

    Returns (source, start_line) tuple. This works with any source Python
    knows about, including notebook cells and exec'd strings.

    Args:
        frame: The frame object or code object
        lineno: Optional line number hint for fallback source retrieval
    """
    try:
        lines, start = inspect.getsourcelines(frame)
        if start == 0:
            start = 1
        return "".join(lines), start
    except OSError:
        # Fallback: try to get source from code object (Python 3.13+ interactive code)
        # This is tested via subprocess tests in test_tty.py::TestInteractiveSourceRetrieval
        code = frame.f_code if hasattr(frame, "f_code") else frame  # pragma: no cover
        if lineno is None:  # pragma: no cover
            lineno = getattr(frame, "f_lineno", code.co_firstlineno)
        fallback_lines, fallback_start = _get_source_lines_from_code(
            code, lineno
        )  # pragma: no cover
        if fallback_lines:  # pragma: no cover
            return "".join(fallback_lines), fallback_start
        return None, None


def _libdir_match(path):
    """Check if path is in a library directory and return the short suffix if so."""
    m = libdir.fullmatch(path)
    if m:
        return next((g for g in m.groups() if g), "")
    return None


def format_location(filename, lineno, col=1):
    """Format location information for a frame.

    Args:
        filename: The source file path
        lineno: Line number (1-based)
        col: Column number (1-based, default 1)

    Returns:
        Tuple of (filename, location, urls) where:
        - filename: Possibly shortened file path
        - location: Display string for the location
        - urls: Dict of URL schemes to URLs (e.g., VS Code, Jupyter)
    """
    urls = {}
    location = None
    try:
        ipython_in = ipython.compile._filename_map[filename]  # type: ignore[attr-defined]
        location = f"In [{ipython_in}]"
        filename = None
    except (AttributeError, KeyError):
        pass
    if filename and Path(filename).is_file():
        fn = Path(filename).resolve()
        # vscode:// URLs use format vscode://file/path:line:col
        urls["VS Code"] = f"vscode://file{quote(fn.as_posix())}:{lineno}:{col}"
        cwd = Path.cwd()
        if cwd in fn.parents:
            fn = fn.relative_to(cwd)
            if ipython is not None:
                urls["Jupyter"] = f"/edit/{quote(fn.as_posix())}"
        filename = fn.as_posix()
    if not location and filename:
        # Use library short path if available, otherwise truncate long paths
        location = _libdir_match(filename)
        if location is None:
            split = (
                filename.rfind("/", 10, len(filename) - 20) + 1
                if len(filename) > 40
                else 0
            )
            location = filename[split:]
    # Ensure location is never None (fallback for edge cases)
    if not location:
        location = "<unknown>"
    return filename, location, urls


def _get_qualified_function_name(frame, function):
    """Get qualified function name with class prefix if available."""
    if function == "<module>":
        return None
    try:
        cls = next(
            v.__class__ if n == "self" else v
            for n, v in frame.f_locals.items()
            if n in ("self", "cls") and v is not None
        )
        function = f"{cls.__name__}.{function}"
    except StopIteration:
        pass
    return ".".join(function.split(".")[-2:])


def _extract_text_from_range(lines: str, mark_range) -> str | None:
    """Extract the text covered by a Range from source lines.

    Args:
        lines: The source code (may contain multiple lines)
        mark_range: Range object with lfirst, lfinal (1-based inclusive lines),
                   cbeg, cend (0-based exclusive columns), or None

    Returns:
        The extracted text, or None if mark_range is None.
    """
    if mark_range is None:
        return None

    lines_list = lines.splitlines(keepends=True)

    # Convert to 0-based line indices
    start_line_idx = mark_range.lfirst - 1
    end_line_idx = mark_range.lfinal - 1

    # Bounds check
    if start_line_idx < 0 or end_line_idx >= len(lines_list):
        return None

    extracted_parts = []
    for line_idx in range(start_line_idx, end_line_idx + 1):
        line = lines_list[line_idx].rstrip("\r\n")

        if line_idx == start_line_idx == end_line_idx:
            # Single line case
            extracted_parts.append(line[mark_range.cbeg : mark_range.cend])
        elif line_idx == start_line_idx:
            # First line of multi-line
            extracted_parts.append(line[mark_range.cbeg :])
        elif line_idx == end_line_idx:
            # Last line of multi-line
            extracted_parts.append(line[: mark_range.cend])
        else:
            # Middle lines of multi-line
            extracted_parts.append(line)

    return " ".join(extracted_parts)


def _expand_source_for_comprehension(
    lines: str, lineno: int, start: int
) -> str:  # pragma: no cover
    """Expand source to include full comprehension/generator expression if error is inside one.

    This helps show relevant variables like the iterator source (e.g., `data` in `for item in data`).
    Note: Currently unused but kept for future use.

    Args:
        lines: The source code snippet
        lineno: The 1-based line number where the error occurred
        start: The 1-based starting line number of the snippet

    Returns:
        Source code that includes the full comprehension, or original lines if not in one.
    """
    result = _find_comprehension_range(lines, lineno, start)
    if result:
        lines_list = lines.splitlines(keepends=True)
        comp_start, comp_end = result
        return "".join(lines_list[comp_start:comp_end])
    return lines


def _find_comprehension_range(lines: str, lineno: int, start: int):
    """Find the line range of a comprehension containing the error line.

    Args:
        lines: The source code snippet
        lineno: The 1-based line number where the error occurred
        start: The 1-based starting line number of the snippet

    Returns:
        Tuple of (start_idx, end_idx) as 0-based indices into lines_list,
        or None if error is not inside a comprehension.
    """
    import ast

    # Try to parse the source and find comprehensions containing the error line
    try:
        tree = ast.parse(lines)
    except SyntaxError:
        return None

    error_line_in_source = lineno - start + 1

    # Find comprehension nodes that contain the error line
    comprehension_types = (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp)
    for node in ast.walk(tree):
        if isinstance(
            node, comprehension_types
        ) and node.lineno <= error_line_in_source <= (node.end_lineno or node.lineno):
            comp_start = node.lineno - 1  # 0-based
            comp_end = node.end_lineno or node.lineno  # 1-based, inclusive
            return (comp_start, comp_end)

    return None


def _trim_source_to_comprehension(lines: str, lineno: int, start: int):
    """Trim source context to just the comprehension if error is inside one.

    Args:
        lines: The source code snippet
        lineno: The 1-based line number where the error occurred
        start: The 1-based starting line number of the snippet

    Returns:
        Tuple of (trimmed_lines, new_start) where new_start is adjusted line number,
        or (lines, start) if not inside a comprehension.
    """
    result = _find_comprehension_range(lines, lineno, start)
    if result:
        lines_list = lines.splitlines(keepends=True)
        comp_start_idx, comp_end_idx = result
        trimmed = "".join(lines_list[comp_start_idx:comp_end_idx])
        new_start = start + comp_start_idx
        return trimmed, new_start
    return lines, start


def _get_variable_source_for_comprehension(
    lines: str, lineno: int, start: int, mark_range
) -> str:
    """Get the source code to use for variable extraction, handling comprehensions.

    For comprehensions, includes the entire comprehension plus the marked region.
    This ensures external variables used anywhere in the comprehension are visible,
    even when the error occurs in a specific part (e.g., the filter clause).

    Comprehension loop variables (like 'x' in 'for x in data') won't be accessible
    in frame.f_locals anyway, so including them doesn't hurt - they'll just be
    filtered out during variable extraction.

    Args:
        lines: The source code snippet
        lineno: The 1-based line number where the error occurred
        start: The 1-based starting line number of the snippet
        mark_range: Range object with the marked region, or None

    Returns:
        Source code string for variable extraction.
    """
    # Check if we're inside a comprehension
    comp_range = _find_comprehension_range(lines, lineno, start)

    if comp_range is not None:
        # Inside a comprehension: use full comprehension text
        lines_list = lines.splitlines(keepends=True)
        comp_start_idx, comp_end_idx = comp_range
        return "".join(lines_list[comp_start_idx:comp_end_idx])

    # Not in a comprehension: use marked text or fall back to full lines
    marked_text = _extract_text_from_range(lines, mark_range)
    return marked_text or lines


def _fallback_mark_range_for_line(lines, error_line_in_context):
    """Build a single-line mark Range when caret columns are unavailable.

    Trims leading/trailing whitespace and (when present) end-of-line comments so that
    the meaningful portion of the line is highlighted. For comment-only lines, a
    minimal one-character mark is returned; for whitespace-only lines, returns None.
    """
    lines_list = lines.splitlines(keepends=True)
    if not (1 <= error_line_in_context <= len(lines_list)):
        return None
    line = lines_list[error_line_in_context - 1]
    content, _ = _split_line_content(line)
    stripped = content.lstrip()
    if not stripped:
        return None
    start_col = len(content) - len(stripped)

    comment_start = _find_comment_start(content)
    if comment_start is not None:
        code_end = content[:comment_start].rstrip()
        end_col = len(code_end)
    else:
        end_col = len(content.rstrip())

    if end_col <= start_col:
        end_col = start_col + 1
    return Range(error_line_in_context, error_line_in_context, start_col, end_col)


def _extract_emphasis_columns(
    lines, error_line_in_context, end_line, start_col, end_col, start
):
    """Extract emphasis columns using caret anchors from the code segment.

    Returns Range with 1-based inclusive line numbers and 0-based exclusive columns,
    or None if no anchors found.
    """
    if not (end_line and start_col is not None and end_col is not None):
        return None

    all_lines = lines.splitlines(keepends=True)
    segment_start = error_line_in_context - 1  # Convert to 0-based for indexing
    segment_end = end_line if end_line else error_line_in_context

    if not (0 <= segment_start < len(all_lines) and segment_end <= len(all_lines)):
        return None

    # Extract the segment using CPython's approach
    relevant_lines = all_lines[segment_start:segment_end]
    if not relevant_lines:
        # This can happen when re-raising an existing exception where CPython's
        # position info refers to the original raise site but end_line < error_line
        return None

    segment = "".join(relevant_lines)

    # Trim segment using start_col and end_col
    segment = segment[start_col : len(segment) - (len(relevant_lines[-1]) - end_col)]
    # Attempt to parse for anchors
    anchors = None
    with suppress(Exception):
        anchors = trace_cpy._extract_caret_anchors_from_line_segment(segment)
    if not anchors:
        return None

    l0, l1, c0, c1 = (
        anchors.left_end_lineno,
        anchors.right_start_lineno,
        anchors.left_end_offset,
        anchors.right_start_offset,
    )
    # We get 0-based line numbers and offsets within the segment,
    # so we need to adjust them to match the original code.
    if l0 == 0:
        c0 += start_col
    if l1 == 0:
        c1 += start_col

    # Convert to 1-based inclusive line numbers for consistency
    lfirst = l0 + segment_start + 1
    lfinal = l1 + segment_start + 1

    return Range(lfirst, lfinal, c0, c1)


def _build_position_map(raw_tb):
    """Build mapping from frame objects to position tuples.

    A single frame object can appear more than once in a traceback (e.g. a
    re-raise inside the same function), so we store a list of positions per
    frame and consume them in order during frame extraction.
    """
    position_map = {}
    if not raw_tb:
        return position_map
    try:
        for frame_obj, positions in trace_cpy._walk_tb_with_full_positions(raw_tb):
            position_map.setdefault(frame_obj, deque()).append(positions)
    except Exception:
        logger.exception("Error extracting position information")
    return position_map


def _extract_syntax_error_frame(e):
    """Create a synthetic frame dict for a SyntaxError showing the problematic code."""
    if not isinstance(e, SyntaxError):
        return None

    filename = e.filename
    lineno = e.lineno
    if not filename or not lineno:
        return None

    # SyntaxError attributes: filename, lineno, offset, text, end_lineno, end_offset
    end_lineno = getattr(e, "end_lineno", None) or lineno
    # offset is 1-based in SyntaxError, convert to 0-based for our Range
    start_col = (e.offset - 1) if e.offset else 0
    end_col = getattr(e, "end_offset", None)

    if end_col:
        end_col = end_col - 1  # Convert to 0-based
        # Ensure we have at least one character highlighted
        if end_col <= start_col and end_lineno == lineno:
            end_col = start_col + 1
    else:
        end_col = start_col + 1  # Default to single character

    assert start_col is not None and end_col is not None

    # Get source lines
    notebook_cell = _is_notebook_cell(filename)
    lines = None
    all_lines = None
    start = 1  # For SyntaxErrors, we want full source to show bracket matches etc.
    source_from_text = False  # True if we fell back to e.text (no context available)

    # Try to get source from the file or notebook
    try:
        import linecache

        # For notebook cells, try to get from IPython's cache
        if notebook_cell and ipython:
            try:
                cell_source = ipython.compile._filename_map.get(filename)
                if cell_source is not None:
                    # Get the cell content from the history
                    all_lines = linecache.getlines(filename)
                    if all_lines:
                        # For SyntaxErrors, get full source to enable bracket matching
                        lines = "".join(all_lines)
            except Exception:
                pass

        # Fallback: try linecache directly
        if not lines:
            all_lines = linecache.getlines(filename)
            if all_lines:
                # For SyntaxErrors, get full source to enable bracket matching
                lines = "".join(all_lines)

        # Last resort: use the text attribute from SyntaxError itself
        if not lines and e.text:
            lines = e.text if e.text.endswith("\n") else e.text + "\n"
            start = lineno
            source_from_text = True
    except Exception:
        if e.text:
            lines = e.text if e.text.endswith("\n") else e.text + "\n"
            start = lineno
            source_from_text = True

    if not lines:
        return None

    # Calculate error position within the displayed lines
    error_line_in_context = lineno - start + 1
    end_line = end_lineno - start + 1 if end_lineno else None

    # Calculate common indentation
    lines_list = lines.splitlines(keepends=True)
    common_indent = ""

    # Clamp columns reported past the end of the line so we still highlight
    # something meaningful for errors like "expected ':'" or indentation errors.
    if lines_list and 1 <= lineno <= len(lines_list):
        max_col = len(lines_list[lineno - 1].rstrip("\n\r"))
        start_col = min(start_col, max(0, max_col - 1))
        end_col = max(min(end_col, max_col), start_col + 1)
        end_col = min(end_col, max_col)

    # Try enhanced SyntaxError position extraction for better highlighting
    enhanced_mark, enhanced_em = extract_enhanced_positions(e, lines_list)

    if enhanced_mark:
        # Override lineno/end_lineno with the enhanced range (e.g., from opening bracket)
        lineno = enhanced_mark.lfirst
        end_lineno = enhanced_mark.lfinal
        # Also use the enhanced columns so the frame's range matches the
        # highlighted region rather than Python's original positions.
        start_col = enhanced_mark.cbeg
        end_col = enhanced_mark.cend

    # Show two lines of context around the error. e.text only contains the
    # error line, so no slicing is possible there.
    if not source_from_text:
        display_first = min(lineno, len(lines_list))
        display_last = min(end_lineno or lineno, len(lines_list))

        slice_start = max(0, display_first - 3)
        slice_end = min(len(lines_list), display_last + 2)

        # Trim empty context lines, but never trim into the error region.
        while slice_start < display_first - 1 and not lines_list[slice_start].strip():
            slice_start += 1
        while slice_end > display_last and not lines_list[slice_end - 1].strip():
            slice_end -= 1

        lines_list = lines_list[slice_start:slice_end]
        lines = "".join(lines_list)
        start = slice_start + 1
        # The snippet is displayed with original indentation; keep common_indent
        # empty because the highlight ranges stay in original column coordinates.
        common_indent = ""

        error_line_in_context = lineno - start + 1
        end_line = end_lineno - start + 1 if end_lineno else None

    if enhanced_mark:
        # Adjust enhanced ranges from absolute line numbers to context-relative.
        # Columns are kept in original (pre-dedent) coordinates so they align
        # with the absolute positions used by the fragment renderer.
        mark_range = Range(
            enhanced_mark.lfirst - start + 1,
            enhanced_mark.lfinal - start + 1,
            enhanced_mark.cbeg,
            enhanced_mark.cend,
        )
        # Convert list of em ranges to context-relative
        em_ranges = (
            [
                Range(
                    em.lfirst - start + 1,
                    em.lfinal - start + 1,
                    em.cbeg,
                    em.cend,
                )
                for em in enhanced_em
            ]
            if enhanced_em
            else None
        )
    else:
        # Fallback to Python's positions
        # Create mark range using original (pre-dedent) columns so it aligns
        # with the absolute positions used by the fragment renderer.
        mark_range = None
        mark_lfinal = end_line or error_line_in_context
        mark_range = Range(error_line_in_context, mark_lfinal, start_col, end_col)

        # Build emphasis range using original columns; the segment is extracted
        # from the original source text.
        em_ranges = _extract_emphasis_columns(
            lines,
            error_line_in_context,
            end_line,
            start_col,
            end_col,
            start,
        )

    fragments = _parse_lines_to_fragments(lines, mark_range, em_ranges)

    # Compute cursor position (prefer em end, fall back to mark end)
    cursor_line, cursor_col = compute_cursor_position(
        mark_range, em_ranges, start, common_indent
    )

    # Format location info (after enhanced positions may have updated lineno)
    fmt_filename, location, urls = format_location(filename, cursor_line, cursor_col)

    # Get the code line for display
    codeline = lines_list[error_line_in_context - 1].strip() if lines_list else None

    return {
        "id": f"tb-{token_urlsafe(12)}",
        "relevance": "error",
        "idframe": id(e),
        "filename": fmt_filename,
        "location": location,
        "notebook_cell": notebook_cell,
        "codeline": codeline,
        "range": Range(lineno, end_lineno or lineno, start_col, end_col)
        if start_col is not None
        else None,
        "cursor_line": cursor_line,
        "cursor_col": cursor_col,
        "linenostart": start,
        "lines": lines,
        "fragments": fragments,
        "function": None,
        "function_suffix": "",
        "urls": urls,
        "variables": [],
    }


def extract_frames(
    tb, raw_tb=None, *, except_block=False, exc=None, exc_message=None
) -> list:
    if not tb:
        return []

    position_map = _build_position_map(raw_tb)

    frames = []
    for frame, filename, lineno, function, codeline, _ in tb:
        hide = frame.f_globals.get("__tracebackhide__") or frame.f_locals.get(
            "__tracebackhide__"
        )
        if hide:
            if hide == "until":
                # Hide this frame and all previous frames
                frames = []
                continue
            # Mark frame as hidden but keep it for chain analysis
            # (will be filtered out after chronological ordering is built)
            hidden = True
        else:
            hidden = False

        # Relevance is set later in extract_exception via _set_frame_relevance
        relevance = "call"

        # Extract position information first so we can use it for source extraction.
        # A frame object may occur multiple times in the traceback, so consume the
        # next stored position for this frame in order.
        frame_positions = position_map.get(frame)
        pos = frame_positions.popleft() if frame_positions else [None] * 4
        pos_end_lineno, start_col, end_col = pos[1], pos[2], pos[3]

        # Check if this is a notebook cell (to reduce context)
        notebook_cell = _is_notebook_cell(filename)

        lines, start, original_common_indent = extract_source_lines(
            frame,
            lineno,
            pos_end_lineno,
            notebook_cell=notebook_cell,
            except_block=except_block,
        )
        is_last_frame = frame is tb[-1][0]
        if not lines and not is_last_frame:
            if hidden:
                # Still include hidden frames with minimal info for chain analysis
                full_source, full_source_start = _get_full_source(frame)
                frames.append(
                    {
                        "id": f"tb-{token_urlsafe(12)}",
                        "relevance": relevance,
                        "hidden": True,
                        "idframe": id(frame),
                        "lineno": lineno,
                        "full_source": full_source,
                        "full_source_start": full_source_start,
                    }
                )
            continue

        # Get full source for chain analysis (AST parsing for try-except matching)
        # This uses inspect which works with any source Python knows about
        full_source, full_source_start = _get_full_source(frame)

        # For comprehensions/generators, trim context to just the expression
        lines, start = _trim_source_to_comprehension(lines, lineno, start)
        # Recalculate common indent after trimming and dedent again if needed
        lines_list = lines.splitlines(keepends=True)
        extra_indent = _calculate_common_indent(lines_list)
        lines = "".join(ln.removeprefix(extra_indent) for ln in lines_list)
        # Total indent removed is original + any extra from trimming
        total_indent = len(original_common_indent) + len(extra_indent)

        # Preserve original filename for chain analysis (needed for AST parsing)
        original_filename = filename
        function = _get_qualified_function_name(frame, function)

        error_line_in_context = lineno - start + 1
        end_line = pos_end_lineno - start + 1 if pos_end_lineno else None

        # Adjust column positions to account for dedenting
        # Python's column numbers are based on the original indented code,
        # but we display dedented code, so we need to subtract total indentation removed
        adjusted_start_col = start_col - total_indent if start_col is not None else None
        adjusted_end_col = end_col - total_indent if end_col is not None else None

        # Build the frame's source range and the displayed mark range.
        # On Python <3.11 co_positions is unavailable, so fall back to
        # highlighting the meaningful portion of the error line.
        frame_range = None
        mark_range = None
        if start_col is not None and end_col is not None:
            # Ensure columns are not negative after dedenting adjustment
            adjusted_start_col = max(0, start_col - total_indent)
            adjusted_end_col = max(0, end_col - total_indent)
            frame_range = Range(lineno, pos_end_lineno or lineno, start_col, end_col)
            mark_range = Range(
                error_line_in_context,
                end_line or error_line_in_context,
                adjusted_start_col,
                adjusted_end_col,
            )
        elif error_line_in_context:
            fallback = _fallback_mark_range_for_line(lines, error_line_in_context)
            if fallback:
                # Map displayed columns back to original source columns.
                frame_range = Range(
                    lineno,
                    pos_end_lineno or lineno,
                    fallback.cbeg + total_indent,
                    fallback.cend + total_indent,
                )
                mark_range = fallback

        # Build emphasis range and fragments
        em_range = _extract_emphasis_columns(
            lines,
            error_line_in_context,
            end_line,
            adjusted_start_col,
            adjusted_end_col,
            start,
        )
        fragments = _parse_lines_to_fragments(lines, mark_range, em_range)

        # Compute cursor position (prefer em end, fall back to mark end)
        # original_common_indent + extra_indent = total common indent removed
        cursor_line, cursor_col = compute_cursor_position(
            mark_range, em_range, start, original_common_indent + extra_indent
        )

        # Format location with cursor position for precise navigation
        filename, location, urls = format_location(
            original_filename, cursor_line, cursor_col
        )

        # Extract variable source: use marked region + comprehension expansion if inside one
        variable_source = _get_variable_source_for_comprehension(
            lines, lineno, start, mark_range
        )

        frames.append(
            {
                "id": f"tb-{token_urlsafe(12)}",
                "relevance": relevance,
                "hidden": hidden,  # For chain analysis; filtered out after ordering
                "idframe": id(frame),  # Identity used for inspector deduplication
                "filename": filename,
                "original_filename": original_filename,  # For chain analysis AST parsing
                "location": location,
                "notebook_cell": notebook_cell,
                "codeline": codeline[0].strip() if codeline else None,
                "range": frame_range,
                "lineno": lineno,  # Actual error line from traceback (always available)
                "cursor_line": cursor_line,
                "cursor_col": cursor_col,
                "linenostart": start,
                "lines": lines,
                "fragments": fragments,
                "function": function,
                "function_suffix": "",
                "urls": urls,
                "variables": extract_variables(
                    frame.f_locals,
                    variable_source,
                    exc_message=exc_message if is_last_frame else None,
                )
                if not hidden
                else [],
                # Full source for chain analysis (try-except matching via AST)
                "full_source": full_source,
                "full_source_start": full_source_start,
            }
        )

    if exc is not None:
        _set_relevances(frames, exc)
    return frames


def _calculate_common_indent(lines):
    """Calculate common indentation across all non-empty lines."""
    non_empty_lines = [line.rstrip("\r\n") for line in lines if line.strip()]
    if not non_empty_lines:
        return ""
    indent_len = min(len(ln) - len(ln.lstrip(" \t")) for ln in non_empty_lines)
    return non_empty_lines[0][:indent_len]


def _convert_range_to_positions(range_obj, lines):
    """Convert Range (1-based inclusive lines, 0-based exclusive columns) to absolute character positions."""
    positions = set()

    if not range_obj:
        return positions

    # Convert to 0-based line indices for processing
    start_line_idx = range_obj.lfirst - 1
    end_line_idx = range_obj.lfinal - 1

    # Calculate absolute positions
    char_pos = 0
    for line_idx, line in enumerate(lines):
        if start_line_idx <= line_idx <= end_line_idx:
            line_content = line.rstrip("\r\n")

            if line_idx == start_line_idx == end_line_idx:
                # Single line case
                for col in range(
                    max(0, range_obj.cbeg), min(len(line_content), range_obj.cend)
                ):
                    positions.add(char_pos + col)
            elif line_idx == start_line_idx:
                # First line of multi-line
                for col in range(max(0, range_obj.cbeg), len(line_content)):
                    positions.add(char_pos + col)
            elif line_idx == end_line_idx:
                # Last line of multi-line
                for col in range(0, min(len(line_content), range_obj.cend)):
                    positions.add(char_pos + col)
            else:
                # Middle lines of multi-line
                for col in range(len(line_content)):
                    positions.add(char_pos + col)

        char_pos += len(line)

    return positions


def _create_unified_fragments(lines_text, common_indent, mark_positions, em_positions):
    """Create fragments with unified mark/em highlighting."""
    lines = lines_text.splitlines(keepends=True)
    result = []

    for line_idx, line in enumerate(lines):
        line_num = line_idx + 1
        fragments = _parse_line_to_fragments_unified(
            line,
            common_indent,
            mark_positions,
            em_positions,
            sum(len(lines[i]) for i in range(line_idx)),  # char offset for this line
        )
        result.append({"line": line_num, "fragments": fragments})

    return result


def _parse_line_to_fragments_unified(
    line, common_indent, mark_positions, em_positions, line_char_offset
):
    """Parse a single line into fragments using unified highlighting."""
    line_content, line_ending = _split_line_content(line)
    if not line_content and not line_ending:
        return []

    # Process indentation
    fragments, remaining, pos = _process_indentation(line_content, common_indent)

    # Find comment split
    comment_start = _find_comment_start(remaining)

    if comment_start is not None:
        # Handle line with comment
        code_part = remaining[:comment_start]
        comment_part = remaining[comment_start:]

        # Process code part (with trimming)
        code_trimmed = code_part.rstrip()
        code_whitespace = code_part[len(code_trimmed) :]

        if code_trimmed:
            fragments.extend(
                _create_highlighted_fragments_unified(
                    code_trimmed, line_char_offset + pos, mark_positions, em_positions
                )
            )

        # Process comment part
        comment_trimmed = comment_part.rstrip()
        comment_trailing = comment_part[len(comment_trimmed) :]

        comment_with_leading_space = code_whitespace + comment_trimmed
        fragments.append({"code": comment_with_leading_space, "comment": "solo"})

        # Add trailing content
        trailing_content = comment_trailing + line_ending
        if trailing_content:
            fragments.append({"code": trailing_content, "trailing": "solo"})
    else:
        # Handle line without comment
        code_trimmed = remaining.rstrip()
        trailing_whitespace = remaining[len(code_trimmed) :]

        if code_trimmed:
            fragments.extend(
                _create_highlighted_fragments_unified(
                    code_trimmed, line_char_offset + pos, mark_positions, em_positions
                )
            )

        trailing_content = trailing_whitespace + line_ending
        if trailing_content:
            fragments.append({"code": trailing_content, "trailing": "solo"})

    return fragments


def _create_highlighted_fragments_unified(
    text, start_pos, mark_positions, em_positions
):
    """Create fragments with mark/em highlighting using unified position sets."""
    if not text:
        return []

    # Convert absolute positions to text-relative positions
    text_mark_positions = set()
    text_em_positions = set()

    for i in range(len(text)):
        abs_pos = start_pos + i
        if abs_pos in mark_positions:
            text_mark_positions.add(i)
        if abs_pos in em_positions:
            text_em_positions.add(i)

    # Create fragments using existing logic
    return _create_fragments_with_highlighting(
        text, text_mark_positions, text_em_positions
    )


def _parse_lines_to_fragments(lines_text, mark_range=None, em_ranges=None):
    """
    Parse lines of code into fragments with mark/em highlighting information.

    Args:
        lines_text: The multi-line string containing code
        mark_range: Range object for mark highlighting (or None)
        em_ranges: Range object or list of Range objects for em highlighting (or None)

    Returns:
        List of line dictionaries with fragment information
    """
    lines = lines_text.splitlines(keepends=True)
    if not lines:
        return []

    common_indent = _calculate_common_indent(lines)

    # Convert both mark and em to position sets using unified logic
    mark_positions = _convert_range_to_positions(mark_range, lines)

    # Handle em_ranges as either a single Range or a list of Ranges
    em_positions = set()
    if em_ranges:
        if isinstance(em_ranges, list):
            for em_range in em_ranges:
                em_positions |= _convert_range_to_positions(em_range, lines)
        else:
            em_positions = _convert_range_to_positions(em_ranges, lines)

    # Create fragments using unified highlighting
    return _create_unified_fragments(
        lines_text, common_indent, mark_positions, em_positions
    )


def _split_line_content(line):
    """Split line into content and line ending."""
    if line.endswith("\r\n"):
        return line[:-2], "\r\n"
    elif line.endswith("\n"):
        return line[:-1], "\n"
    elif line.endswith("\r"):
        return line[:-1], "\r"
    else:
        return line, ""


def _process_indentation(line_content, common_indent):
    """Process dedent and additional indentation, return fragments and remaining content."""
    fragments = []
    pos = 0

    # Handle dedent (common indentation)
    if common_indent and len(line_content) > len(common_indent):
        dedent_text = line_content[: len(common_indent)]
        fragments.append({"code": dedent_text, "dedent": "solo"})
        pos = len(common_indent)

    # Handle additional indentation
    remaining = line_content[pos:]
    indent_match = re.match(r"^(\s+)", remaining)
    if indent_match:
        indent_text = indent_match.group(1)
        fragments.append({"code": indent_text, "indent": "solo"})
        pos += len(indent_text)
        remaining = remaining[len(indent_text) :]

    return fragments, remaining, pos


def _find_comment_start(text):
    """Find the start of a comment, ignoring # inside strings."""
    in_string = False
    string_char = None
    escape_next = False

    for i, char in enumerate(text):
        if escape_next:
            escape_next = False
            continue
        if char == "\\":
            escape_next = True
            continue
        if not in_string and char == "#":
            return i
        if not in_string and char in ('"', "'"):
            in_string = True
            string_char = char
        elif in_string and char == string_char:
            in_string = False
            string_char = None

    return None


def _positions_to_consecutive_ranges(positions):
    """Convert a set/list of positions to consecutive (start, end) ranges."""
    if not positions:
        return []

    sorted_positions = sorted(set(positions))
    ranges = []
    start = sorted_positions[0]
    end = start + 1

    for pos in sorted_positions[1:]:
        if pos == end:
            # Consecutive position, extend current range
            end = pos + 1
        else:
            # Gap found, close current range and start new one
            ranges.append((start, end))
            start = pos
            end = pos + 1

    # Close the last range
    ranges.append((start, end))
    return ranges


def _get_highlight_boundaries(text, mark_positions, em_positions):
    """Get all boundaries for highlighting (start/end of mark and em regions)."""
    boundaries = {0, len(text)}

    # Add mark boundaries
    for start, end in _positions_to_consecutive_ranges(mark_positions):
        boundaries.add(start)
        boundaries.add(end)

    # Add em boundaries
    for start, end in _positions_to_consecutive_ranges(em_positions):
        boundaries.add(start)
        boundaries.add(end)

    return sorted(boundaries)


def _create_fragments_with_highlighting(text, mark_positions, em_positions):
    """Create fragments with mark/em highlighting using beg/mid/fin/solo logic."""
    if not text:
        return []

    # Get all boundaries and create fragments
    boundaries = _get_highlight_boundaries(text, mark_positions, em_positions)
    mark_ranges = _positions_to_consecutive_ranges(mark_positions)
    em_ranges = _positions_to_consecutive_ranges(em_positions)

    fragments = []
    for i in range(len(boundaries) - 1):
        start = boundaries[i]
        end = boundaries[i + 1]

        if start >= len(text):
            break

        fragment_text = text[start:end]
        fragment = {"code": fragment_text}

        # Determine mark status
        mark_status = _get_highlight_status(start, end, mark_ranges)
        if mark_status:
            fragment["mark"] = mark_status

        # Determine em status
        em_status = _get_highlight_status(start, end, em_ranges)
        if em_status:
            fragment["em"] = em_status

        fragments.append(fragment)

    return fragments


def _get_highlight_status(frag_start, frag_end, ranges):
    """Determine beg/mid/fin/solo status for a fragment within ranges."""
    # Find overlapping ranges
    overlapping = []
    for range_start, range_end in ranges:
        if frag_start < range_end and frag_end > range_start:
            overlapping.append((range_start, range_end))

    if not overlapping:
        return None

    # Use the first overlapping range (they should align with fragment boundaries)
    range_start, range_end = overlapping[0]

    is_start = frag_start <= range_start
    is_end = frag_end >= range_end

    if is_start and is_end:
        return "solo"
    elif is_start:
        return "beg"
    elif is_end:
        return "fin"
    else:
        return "mid"
