// MobRootView.swift — SwiftUI entry point. Observes MobViewModel and renders the
// node tree pushed by BEAM NIFs via MobViewModel.setRoot().

import SwiftUI
import AVKit
import WebKit

// ── Native view component registry ───────────────────────────────────────────
// Register platform-native views by name at app startup. The name is the
// Elixir module with "Elixir." stripped and "." replaced with "_":
//   MyApp.ChartComponent → "MyApp_ChartComponent"
//
//   MobNativeViewRegistry.shared.register("MyApp_ChartComponent") { props, send in
//       AnyView(ChartView(data: props["data"]) { index in
//           send("tapped", ["index": index])
//       })
//   }

public typealias MobNativeSend = (_ event: String, _ payload: [String: Any]) -> Void
public typealias MobNativeViewFactory = (_ props: [String: Any], _ send: @escaping MobNativeSend) -> AnyView

public final class MobNativeViewRegistry {
    public static let shared = MobNativeViewRegistry()
    private var factories: [String: MobNativeViewFactory] = [:]

    public func register(_ name: String, factory: @escaping MobNativeViewFactory) {
        factories[name] = factory
    }

    func view(for node: MobNode) -> AnyView? {
        guard let name = node.nativeViewModule,
              let factory = factories[name],
              let props = node.nativeViewProps as? [String: Any] else { return nil }
        let handle = node.nativeViewHandle
        let send: MobNativeSend = { event, payload in
            if let data = try? JSONSerialization.data(withJSONObject: payload),
               let json = String(data: data, encoding: .utf8) {
                mob_send_component_event(handle, event, json)
            }
        }
        return factory(props, send)
    }
}

extension View {
    @ViewBuilder
    func ifLet<T>(_ value: T?, transform: (Self, T) -> some View) -> some View {
        if let value {
            transform(self, value)
        } else {
            self
        }
    }

    /// Apply Mob gesture handlers from a node — long press, double tap, swipe.
    /// Each is opt-in (nil callbacks become no-ops). Per-widget; most widgets
    /// won't have any of these set, so the cost is one nil check per gesture.
    /// Drag gesture is only attached if at least one swipe handler is set
    /// (otherwise it would interfere with ScrollView and tap behaviors).
    @ViewBuilder
    func mobGestures(_ node: MobNode) -> some View {
        let hasSwipe =
            node.onSwipe != nil ||
            node.onSwipeLeft != nil ||
            node.onSwipeRight != nil ||
            node.onSwipeUp != nil ||
            node.onSwipeDown != nil

        self
            .ifLet(node.onLongPress) { view, cb in
                view.onLongPressGesture(minimumDuration: 0.5) { cb() }
            }
            .ifLet(node.onDoubleTap) { view, cb in
                view.onTapGesture(count: 2) { cb() }
            }
            .ifLet(hasSwipe ? node : nil) { view, n in
                view.gesture(
                    DragGesture(minimumDistance: 30)
                        .onEnded { value in
                            let dx = value.translation.width
                            let dy = value.translation.height
                            let direction: String
                            if abs(dx) > abs(dy) {
                                direction = dx > 0 ? "right" : "left"
                            } else {
                                direction = dy > 0 ? "down" : "up"
                            }
                            n.onSwipe?(direction)
                            switch direction {
                            case "left":  n.onSwipeLeft?()
                            case "right": n.onSwipeRight?()
                            case "up":    n.onSwipeUp?()
                            case "down":  n.onSwipeDown?()
                            default:      break
                            }
                        }
                )
            }
    }
}

// Allow MobNode to be used as ForEach identity (NSObject provides hash/isEqual).
extension MobNode: Identifiable {
    public var id: ObjectIdentifier { ObjectIdentifier(self) }
}

// Logical icon name → SF Symbol. Logical names are platform-agnostic so the
// same Elixir tree renders an Apple-styled icon on iOS and a Material-styled
// icon on Android. Unknown logical names pass through verbatim, letting
// power users use raw SF Symbol identifiers (e.g. "globe.americas.fill").
private func sfSymbolName(_ logical: String) -> String {
    switch logical {
    case "settings":      return "gear"
    case "back":          return "chevron.left"
    case "forward":       return "chevron.right"
    case "close":         return "xmark"
    case "add":           return "plus"
    case "remove":        return "minus"
    case "edit":          return "pencil"
    case "check":         return "checkmark"
    case "chevron_right": return "chevron.right"
    case "chevron_left":  return "chevron.left"
    case "chevron_up":    return "chevron.up"
    case "chevron_down":  return "chevron.down"
    case "info":          return "info.circle"
    case "warning":       return "exclamationmark.triangle"
    case "error":         return "xmark.circle"
    case "search":        return "magnifyingglass"
    case "trash":         return "trash"
    case "share":         return "square.and.arrow.up"
    case "more":          return "ellipsis"
    case "menu":          return "line.3.horizontal"
    case "refresh":       return "arrow.clockwise"
    case "favorite":      return "heart"
    case "favorite_filled": return "heart.fill"
    case "star":          return "star"
    case "star_filled":   return "star.fill"
    case "user":          return "person"
    case "home":          return "house"
    case "history":       return "clock.arrow.circlepath"
    case "list":          return "list.bullet"
    case "qr_code":       return "qrcode"
    case "link":          return "link"
    case "snowflake":     return "snowflake"
    default:              return logical  // raw SF Symbol pass-through
    }
}

extension MobNode {
    var childNodes: [MobNode] {
        children.compactMap { $0 as? MobNode }
    }

    /// EdgeInsets that honour per-edge padding props (padding_top etc.).
    /// Falls back to the uniform `padding` value for any unset edge.
    var paddingEdgeInsets: EdgeInsets {
        let top    = paddingTop    >= 0 ? paddingTop    : padding
        let right  = paddingRight  >= 0 ? paddingRight  : padding
        let bottom = paddingBottom >= 0 ? paddingBottom : padding
        let left   = paddingLeft   >= 0 ? paddingLeft   : padding
        return EdgeInsets(top: top, leading: left, bottom: bottom, trailing: right)
    }

    /// Resolved SwiftUI Font respecting font family, size, weight, and italic.
    var resolvedFont: Font {
        let size: CGFloat = textSize > 0 ? textSize : 16.0
        let weight: Font.Weight = {
            switch fontWeight {
            case "bold":     return .bold
            case "semibold": return .semibold
            case "medium":   return .medium
            case "light":    return .light
            case "thin":     return .thin
            default:         return .regular
            }
        }()
        var font: Font
        if let family = fontFamily, !family.isEmpty {
            font = Font.custom(family, size: size)
        } else {
            font = .system(size: size)
        }
        font = font.weight(weight)
        if italic { font = font.italic() }
        return font
    }

    var textAlignEnum: TextAlignment {
        switch textAlign {
        case "center": return .center
        case "right":  return .trailing
        default:       return .leading
        }
    }

    var frameTextAlignment: Alignment {
        switch textAlign {
        case "center": return .center
        case "right":  return .trailing
        default:       return .leading
        }
    }

    /// Extra inter-line spacing derived from the lineHeight multiplier.
    var computedLineSpacing: CGFloat {
        guard lineHeight > 0 else { return 0 }
        let size: CGFloat = textSize > 0 ? textSize : 16.0
        return (lineHeight - 1.0) * size
    }
}

// ── Recursive node renderer ────────────────────────────────────────────────

struct MobNodeView: View {
    let node: MobNode

    var body: some View {
        Group {
            switch node.nodeType {
            case .column:
                VStack(alignment: .leading, spacing: 0) {
                    ForEach(Array(node.childNodes.enumerated()), id: \.offset) { _, child in MobNodeView(node: child) }
                }
                // fill_height: true lets a column flex to fill its parent so children
                // with Spacer() or fill_height of their own can pin to the bottom.
                // Without maxHeight the VStack hugs its content vertically and a
                // trailing footer sits directly below the last child instead of the
                // parent's bottom edge.
                .frame(maxWidth: .infinity, maxHeight: node.fillHeight ? .infinity : nil, alignment: .topLeading)
                .padding(node.paddingEdgeInsets)
                .background(node.backgroundColor.map { Color($0) } ?? Color.clear)
                .ifLet(node.onTap) { view, tap in
                    view.contentShape(Rectangle()).onTapGesture { tap() }
                }
                .mobGestures(node)

            case .row:
                let alignment: VerticalAlignment = {
                    switch node.rowAlign {
                    case "top":    return .top
                    case "bottom": return .bottom
                    default:       return .center
                    }
                }()
                HStack(alignment: alignment, spacing: 0) {
                    ForEach(Array(node.childNodes.enumerated()), id: \.offset) { _, child in MobNodeView(node: child) }
                }
                // Without maxWidth: .infinity an HStack hugs its content.
                // Flex Spacers inside then have nothing to expand into and
                // centering tricks (spacer / content / spacer) collapse.
                // Honors `fill_width: true` to match Android's row behaviour.
                .ifLet(node.fillWidth ? () : nil) { view, _ in view.frame(maxWidth: .infinity) }
                .padding(node.paddingEdgeInsets)
                .background(node.backgroundColor.map { Color($0) } ?? Color.clear)
                .ifLet(node.onTap) { view, tap in
                    view.contentShape(Rectangle()).onTapGesture { tap() }
                }
                .mobGestures(node)

            case .box:
                MobBox(node: node)

            case .label:
                // Only span the parent's full width when the caller explicitly
                // asks (via `fill_width: true` or a non-leading `text_align`).
                // Defaulting to .frame(maxWidth: .infinity) made every Text
                // greedily fill its container, which broke centering when a
                // Text sat inside a row meant to size to its content (the row
                // would inflate to full width and the box's centering had
                // nothing to position).
                let textShouldFill = node.fillWidth || node.textAlign == "center" || node.textAlign == "right"
                Text(node.text ?? "")
                    .font(node.resolvedFont)
                    .foregroundColor(node.textColor.map { Color($0) } ?? Color.primary)
                    .multilineTextAlignment(node.textAlignEnum)
                    .lineSpacing(node.computedLineSpacing)
                    .kerning(node.letterSpacing)
                    .ifLet(textShouldFill ? () : nil) { view, _ in
                        view.frame(maxWidth: .infinity, alignment: node.frameTextAlignment)
                    }
                    .padding(node.paddingEdgeInsets)
                    .background(node.backgroundColor.map { Color($0) } ?? Color.clear)
                    .ifLet(node.onTap) { view, tap in
                        view.contentShape(Rectangle()).onTapGesture { tap() }
                    }
                    .mobGestures(node)

            case .icon:
                // Resolve logical icon name (e.g. "settings") to the matching
                // SF Symbol. textSize controls glyph size; textColor controls tint.
                // node.text serves as accessibility label when set.
                Image(systemName: sfSymbolName(node.iconName ?? "questionmark"))
                    .font(.system(size: node.textSize > 0 ? node.textSize : 20))
                    .foregroundColor(node.textColor.map { Color($0) } ?? Color.primary)
                    .padding(node.paddingEdgeInsets)
                    .background(node.backgroundColor.map { Color($0) } ?? Color.clear)
                    .ifLet(node.onTap) { view, tap in
                        view.contentShape(Rectangle()).onTapGesture { tap() }
                    }
                    .ifLet(node.text) { view, label in view.accessibilityLabel(label) }
                    .ifLet(node.accessibilityId) { view, id in view.accessibilityIdentifier(id) }
                    .mobGestures(node)

            case .button:
                // Padding + background INSIDE the Button's label, not outside.
                // SwiftUI's Button only registers taps on its content view's
                // bounds — applying `.padding()` to the Button itself leaves
                // the padded area visually present but not tappable, so users
                // tap the visible edge of the button and nothing happens.
                // contentShape(Rectangle()) ensures the full padded area is
                // hit-testable even when the background is .clear.
                Button(action: { node.onTap?() }) {
                    Text(node.text ?? "")
                        .font(node.resolvedFont)
                        .foregroundColor(node.textColor.map { Color($0) } ?? Color.clear)
                        .lineLimit(1)
                        .frame(maxWidth: node.fillWidth ? .infinity : nil)
                        .padding(node.paddingEdgeInsets)
                        .background(node.backgroundColor.map { Color($0) } ?? Color.clear)
                        .contentShape(Rectangle())
                }
                .clipShape(RoundedRectangle(cornerRadius: node.cornerRadius))
                .ifLet(node.accessibilityId) { view, id in
                    view.accessibilityIdentifier(id)
                }

            case .scroll:
                let isHorizontal = node.axis == "horizontal"
                let axes: Axis.Set = isHorizontal ? .horizontal : .vertical
                ScrollView(axes, showsIndicators: node.showIndicator) {
                    if isHorizontal {
                        HStack(alignment: .top, spacing: 0) {
                            ForEach(Array(node.childNodes.enumerated()), id: \.offset) { _, child in MobNodeView(node: child) }
                        }
                        .frame(maxHeight: .infinity, alignment: .topLeading)
                    } else {
                        VStack(alignment: .leading, spacing: 0) {
                            ForEach(Array(node.childNodes.enumerated()), id: \.offset) { _, child in MobNodeView(node: child) }
                        }
                        .frame(maxWidth: .infinity, alignment: .leading)
                    }
                }
                .scrollDismissesKeyboard(.interactively)
                .padding(node.paddingEdgeInsets)
                .background(node.backgroundColor.map { Color($0) } ?? Color.clear)
                // ── Batch 5 Tier 1: scroll position observation ──
                // SwiftUI's onScrollGeometryChange is iOS 18+. On older iOS
                // there's no clean SwiftUI API for raw offset; UIKit-backed
                // alternative pending. Until then, scroll events are silently
                // unavailable on iOS 17 (renderer still accepts on_scroll
                // props — they just won't fire).
                .modifier(MobScrollObserverGate(node: node, isHorizontal: isHorizontal))

            case .textField:
                let placeholder = node.placeholder ?? ""
                let initialText = node.text ?? ""
                MobTextField(node: node, placeholder: placeholder, initialText: initialText)
                    .padding(node.paddingEdgeInsets)

            case .toggle:
                MobToggle(node: node)
                    .padding(node.paddingEdgeInsets)

            case .slider:
                MobSlider(node: node)
                    .padding(node.paddingEdgeInsets)

            case .divider:
                Divider()
                    .frame(height: node.thickness)
                    .overlay(
                        node.color.map { Color($0) } ?? Color(UIColor.separator)
                    )
                    .padding(node.paddingEdgeInsets)

            case .spacer:
                if node.fixedSize > 0 {
                    // Constrain both axes so a sized Spacer works as a fixed
                    // gap in both VStack (column) and HStack (row). Without
                    // the width constraint, an HStack treats it as flexible
                    // alongside other Spacers and the layout collapses.
                    Spacer().frame(width: node.fixedSize, height: node.fixedSize)
                } else {
                    Spacer()
                }

            case .image:
                MobImage(node: node)
                    .padding(node.paddingEdgeInsets)

            case .lazyList:
                ScrollView(.vertical, showsIndicators: true) {
                    LazyVStack(alignment: .leading, spacing: 0) {
                        ForEach(Array(node.childNodes.enumerated()), id: \.offset) { index, child in
                            MobNodeView(node: child)
                                .onAppear {
                                    if index == node.childNodes.count - 1 {
                                        node.onTap?()
                                    }
                                }
                        }
                    }
                    .frame(maxWidth: .infinity, alignment: .leading)
                }
                .frame(maxHeight: .infinity)
                .padding(node.paddingEdgeInsets)
                .background(node.backgroundColor.map { Color($0) } ?? Color.clear)

            case .progress:
                let trackColor = node.color.map { Color($0) } ?? Color.accentColor
                if node.value.isNaN {
                    ProgressView()
                        .progressViewStyle(.linear)
                        .tint(trackColor)
                        .frame(maxWidth: .infinity)
                        .padding(node.paddingEdgeInsets)
                } else {
                    ProgressView(value: node.value, total: 1.0)
                        .progressViewStyle(.linear)
                        .tint(trackColor)
                        .frame(maxWidth: .infinity)
                        .padding(node.paddingEdgeInsets)
                }

            case .tabBar:
                let tabs = node.tabDefs as? [[String: Any]] ?? []
                MobTabView(node: node, tabs: tabs)

            case .video:
                if let src = node.src {
                    MobVideoPlayer(src: src, autoplay: node.videoAutoplay,
                                   loop: node.videoLoop, controls: node.videoControls)
                        .ifLet(node.fixedWidth  > 0 ? node.fixedWidth  : nil) { v, w in v.frame(width: CGFloat(w)) }
                        .ifLet(node.fixedHeight > 0 ? node.fixedHeight : nil) { v, h in v.frame(height: CGFloat(h)) }
                        .padding(node.paddingEdgeInsets)
                }

            case .cameraPreview:
                MobCameraPreviewView(facing: node.cameraFacing)
                    .ifLet(node.fixedWidth  > 0 ? node.fixedWidth  : nil) { v, w in v.frame(width: CGFloat(w)) }
                    .ifLet(node.fixedHeight > 0 ? node.fixedHeight : nil) { v, h in v.frame(height: CGFloat(h)) }
                    .padding(node.paddingEdgeInsets)

            case .webView:
                MobWebView(node: node)
                    .ifLet(node.fixedWidth  > 0 ? node.fixedWidth  : nil) { v, w in v.frame(width: CGFloat(w)) }
                    .ifLet(node.fixedHeight > 0 ? node.fixedHeight : nil) { v, h in v.frame(height: CGFloat(h)) }
                    .padding(node.paddingEdgeInsets)

            case .nativeView:
                if let view = MobNativeViewRegistry.shared.view(for: node) {
                    view.padding(node.paddingEdgeInsets)
                }

            case .canvas:
                MobCanvasView(node: node)
                    .padding(node.paddingEdgeInsets)

            case .gpuView:
                MobGpuView(node: node)
                    .ifLet(node.fixedWidth  > 0 ? node.fixedWidth  : nil) { v, w in v.frame(width: CGFloat(w)) }
                    .ifLet(node.fixedHeight > 0 ? node.fixedHeight : nil) { v, h in v.frame(height: CGFloat(h)) }
                    .padding(node.paddingEdgeInsets)

            @unknown default:
                EmptyView()
            }
        }
        // Per-node offset — applied uniformly to every node type. Default is
        // (0, 0) which is a no-op. Used by SquareTriangle's hexagonal
        // snowflake to position rings absolutely within a center-aligned box.
        .offset(x: CGFloat(node.offsetX), y: CGFloat(node.offsetY))
    }
}

// ── Box ──────────────────────────────────────────────────────────────────────
// Extracted from MobNodeView so the conditional sizing chain doesn't blow
// past SwiftUI's type-inference budget. When fixedWidth is set the box
// uses that exact width; otherwise it stretches to fill maxWidth (the
// original behavior). Same logic for fixedHeight (otherwise content-sized).
//
// The fixed-width path is what makes circular ring cells possible without
// a dedicated primitive — set width: N, height: N, corner_radius: N/2,
// border_color + border_width and the box renders as a ring.
private struct MobBox: View {
    let node: MobNode

    var body: some View {
        let alignment: Alignment = boxAlignmentFromString(node.boxAlign)

        let stack = ZStack(alignment: alignment) {
            ForEach(Array(node.childNodes.enumerated()), id: \.offset) { _, child in
                MobNodeView(node: child)
            }
        }

        return Group {
            if node.fixedWidth > 0 {
                stack.frame(
                    width: CGFloat(node.fixedWidth),
                    height: node.fixedHeight > 0 ? CGFloat(node.fixedHeight) : nil,
                    alignment: alignment
                )
            } else if node.fillHeight {
                // fill_height: true is what lets a wrapping box stretch to the
                // viewport so center alignment lands on the visible midpoint
                // (e.g. for floating dialogs that need to sit mid-screen
                // regardless of their sibling's content size).
                stack.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: alignment)
            } else {
                stack.frame(maxWidth: .infinity, alignment: alignment)
            }
        }
        .padding(node.paddingEdgeInsets)
        .mobBoxBackground(node: node)
        .overlay(
            // Border opt-in via border_color + border_width on the BEAM side.
            // When width is 0 (default) the stroke draws nothing — no perf cost.
            // .allowsHitTesting(false) is critical: without it, SwiftUI routes
            // taps inside the border's bounding rect to the overlay instead of
            // the box's children, swallowing tap events on every nested button.
            RoundedRectangle(cornerRadius: node.cornerRadius)
                .stroke(node.borderColor.map { Color($0) } ?? Color.clear,
                        lineWidth: node.borderWidth)
                .allowsHitTesting(false)
        )
        .ifLet(node.onTap) { view, tap in
            view.contentShape(Rectangle()).onTapGesture { tap() }
        }
        .mobGestures(node)
        // (offset is applied uniformly by MobNodeView's body; not here)
    }
}

// Backgrounds for `MobBox`. When the active theme has `glass: true` the BEAM
// renderer sets `useGlass` on every box that has a `background:` so we swap
// the solid fill for a translucent material. Liquid Glass landed on iOS 26;
// on older systems we fall back to `.ultraThinMaterial` (visually similar,
// less expensive). Without `useGlass` the original solid behaviour is kept.
private extension View {
    @ViewBuilder
    func mobBoxBackground(node: MobNode) -> some View {
        let radius = node.cornerRadius
        let shape: AnyShape =
            radius > 0
            ? AnyShape(RoundedRectangle(cornerRadius: radius, style: .continuous))
            : AnyShape(Rectangle())

        if node.useGlass {
            // Liquid Glass on iOS 26+; otherwise the closest visual approximation
            // that ships in older system SDKs.
            //
            // `Glass.clear` (vs `Glass.regular`) — the surface is noticeably
            // more transparent; what's behind shows through. Card-style
            // surfaces look "floating" rather than "frosted". Switch to
            // `.regular` if a tinted, opaque-leaning glass is wanted.
            if #available(iOS 26.0, *) {
                self.glassEffect(.clear, in: shape)
            } else {
                self.background(.ultraThinMaterial, in: shape)
            }
        } else {
            self.background(node.backgroundColor.map { Color($0) } ?? Color.clear)
        }
    }
}

private func boxAlignmentFromString(_ s: String) -> Alignment {
    switch s {
    case "center":          return .center
    case "top":             return .top
    case "top_center":      return .top
    case "top_trailing":    return .topTrailing
    case "leading":         return .leading
    case "trailing":        return .trailing
    case "bottom":          return .bottom
    case "bottom_leading":  return .bottomLeading
    case "bottom_center":   return .bottom
    case "bottom_trailing": return .bottomTrailing
    default:                return .topLeading
    }
}

// ── Canvas (Mob.Canvas declarative draw spec) ────────────────────────────────
// Renders the node.canvasOps array via SwiftUI Canvas. Each op is an
// NSDictionary with an "op" key plus op-specific fields, pre-resolved by
// the Elixir renderer (color tokens already converted to ARGB integers).

private struct MobCanvasView: View {
    let node: MobNode

    var body: some View {
        Canvas { ctx, size in
            let ops = node.canvasOps as? [[String: Any]] ?? []
            for op in ops {
                drawOp(op, in: &ctx, size: size)
            }
        }
        .frame(
            width: node.canvasWidth > 0 ? CGFloat(node.canvasWidth) : nil,
            height: node.canvasHeight > 0 ? CGFloat(node.canvasHeight) : nil
        )
    }

    private func drawOp(_ op: [String: Any], in ctx: inout GraphicsContext, size: CGSize) {
        guard let opName = op["op"] as? String else { return }

        let color = canvasColor(op["color"])
        let opacity = (op["opacity"] as? Double) ?? 1.0
        let strokeStyle = canvasStrokeStyle(op)
        let isFill = (op["fill"] as? Bool) ?? false

        ctx.opacity = opacity
        defer { ctx.opacity = 1.0 }

        switch opName {
        case "line":
            let path = Path { p in
                p.move(to: CGPoint(x: cgNum(op["x1"]), y: cgNum(op["y1"])))
                p.addLine(to: CGPoint(x: cgNum(op["x2"]), y: cgNum(op["y2"])))
            }
            ctx.stroke(path, with: .color(color), style: strokeStyle)

        case "circle":
            let r = cgNum(op["r"])
            let rect = CGRect(x: cgNum(op["x"]) - r, y: cgNum(op["y"]) - r, width: r * 2, height: r * 2)
            let path = Path(ellipseIn: rect)
            if isFill { ctx.fill(path, with: .color(color)) } else { ctx.stroke(path, with: .color(color), style: strokeStyle) }

        case "ellipse":
            let rx = cgNum(op["rx"])
            let ry = cgNum(op["ry"])
            let rect = CGRect(x: cgNum(op["x"]) - rx, y: cgNum(op["y"]) - ry, width: rx * 2, height: ry * 2)
            let path = Path(ellipseIn: rect)
            if isFill { ctx.fill(path, with: .color(color)) } else { ctx.stroke(path, with: .color(color), style: strokeStyle) }

        case "arc":
            // Mob.Canvas arc convention: degrees, 0° to the right, sweeping clockwise.
            // SwiftUI Path.addArc uses radians; we negate for clockwise from the start.
            let center = CGPoint(x: cgNum(op["x"]), y: cgNum(op["y"]))
            let r = cgNum(op["r"])
            let start = Angle(degrees: cgDouble(op["start_deg"]))
            let end = Angle(degrees: cgDouble(op["end_deg"]))
            let path = Path { p in
                p.addArc(center: center, radius: r, startAngle: start, endAngle: end, clockwise: false)
            }
            ctx.stroke(path, with: .color(color), style: strokeStyle)

        case "rect":
            let rect = CGRect(
                x: cgNum(op["x"]),
                y: cgNum(op["y"]),
                width: cgNum(op["w"]),
                height: cgNum(op["h"])
            )
            let radius = cgNum(op["radius"])
            let path: Path = radius > 0
                ? Path(roundedRect: rect, cornerRadius: radius)
                : Path(rect)
            if isFill { ctx.fill(path, with: .color(color)) } else { ctx.stroke(path, with: .color(color), style: strokeStyle) }

        case "path":
            guard let pts = op["points"] as? [[Double]], !pts.isEmpty else { return }
            let closed = (op["closed"] as? Bool) ?? false
            let path = Path { p in
                p.move(to: CGPoint(x: pts[0][0], y: pts[0][1]))
                for pt in pts.dropFirst() {
                    p.addLine(to: CGPoint(x: pt[0], y: pt[1]))
                }
                if closed || isFill { p.closeSubpath() }
            }
            if isFill { ctx.fill(path, with: .color(color)) } else { ctx.stroke(path, with: .color(color), style: strokeStyle) }

        case "text":
            let str = (op["text"] as? String) ?? ""
            let size = cgNum(op["size"])
            let weight = canvasFontWeight(op["weight"] as? String)
            var text = Text(str).font(.system(size: size, weight: weight))
            if let family = op["family"] as? String, !family.isEmpty {
                text = Text(str).font(.custom(family, size: size).weight(weight))
            }
            let resolved = ctx.resolve(text.foregroundColor(color))
            // Anchor: SwiftUI's draw(at:anchor:) takes a UnitPoint.
            let anchor: UnitPoint = {
                switch op["anchor"] as? String {
                case "center": return .leading  // y stays top; horizontal center handled by switching
                default:       return .topLeading
                }
            }()
            // For horizontal anchor handling we measure first.
            let measured = resolved.measure(in: CGSize(width: CGFloat.infinity, height: CGFloat.infinity))
            let x = cgNum(op["x"])
            let y = cgNum(op["y"])
            let drawX: CGFloat = {
                switch op["anchor"] as? String {
                case "center": return x - measured.width / 2
                case "end":    return x - measured.width
                default:       return x
                }
            }()
            ctx.draw(resolved, at: CGPoint(x: drawX, y: y), anchor: .topLeading)
            _ = anchor // anchor variable unused after switching to manual offset; kept to document intent

        case "image":
            guard let src = op["source"] as? String else { return }
            if let img = UIImage(named: src) {
                let rect = CGRect(
                    x: cgNum(op["x"]),
                    y: cgNum(op["y"]),
                    width: cgNum(op["w"]),
                    height: cgNum(op["h"])
                )
                ctx.draw(Image(uiImage: img), in: rect)
            }

        default:
            break
        }
    }
}

// ── Canvas helpers ───────────────────────────────────────────────────────────

private func cgNum(_ value: Any?) -> CGFloat {
    if let d = value as? Double { return CGFloat(d) }
    if let i = value as? Int { return CGFloat(i) }
    if let n = value as? NSNumber { return CGFloat(n.doubleValue) }
    return 0
}

private func cgDouble(_ value: Any?) -> Double {
    if let d = value as? Double { return d }
    if let i = value as? Int { return Double(i) }
    if let n = value as? NSNumber { return n.doubleValue }
    return 0
}

private func canvasColor(_ value: Any?) -> Color {
    // Pre-resolved ARGB integer from the renderer, or hex string fallback.
    if let argb = value as? Int {
        let a = Double((argb >> 24) & 0xFF) / 255.0
        let r = Double((argb >> 16) & 0xFF) / 255.0
        let g = Double((argb >>  8) & 0xFF) / 255.0
        let b = Double((argb >>  0) & 0xFF) / 255.0
        return Color(red: r, green: g, blue: b, opacity: a)
    }
    if let n = value as? NSNumber {
        return canvasColor(n.intValue)
    }
    if let hex = value as? String, hex.hasPrefix("#") {
        let scanner = Scanner(string: String(hex.dropFirst()))
        var rgb: UInt64 = 0
        if scanner.scanHexInt64(&rgb) {
            let r = Double((rgb >> 16) & 0xFF) / 255.0
            let g = Double((rgb >>  8) & 0xFF) / 255.0
            let b = Double((rgb >>  0) & 0xFF) / 255.0
            return Color(red: r, green: g, blue: b)
        }
    }
    return Color.black
}

private func canvasStrokeStyle(_ op: [String: Any]) -> StrokeStyle {
    let width = cgNum(op["width"])
    let cap: CGLineCap = {
        switch op["cap"] as? String {
        case "round":  return .round
        case "square": return .square
        default:       return .butt
        }
    }()
    let join: CGLineJoin = {
        switch op["join"] as? String {
        case "round": return .round
        case "bevel": return .bevel
        default:      return .miter
        }
    }()
    let dash: [CGFloat] = (op["dash"] as? [Any])?.compactMap { cgNum($0) } ?? []
    return StrokeStyle(
        lineWidth: width > 0 ? width : 1,
        lineCap: cap,
        lineJoin: join,
        dash: dash
    )
}

private func canvasFontWeight(_ name: String?) -> Font.Weight {
    switch name {
    case "thin":     return .thin
    case "light":    return .light
    case "medium":   return .medium
    case "semibold": return .semibold
    case "bold":     return .bold
    default:         return .regular
    }
}

// ── Tab bar ───────────────────────────────────────────────────────────────────

private struct MobTabView: View {
    let node: MobNode
    let tabs: [[String: Any]]

    var body: some View {
        let activeId = node.activeTab ?? (tabs.first?["id"] as? String ?? "")
        TabView(selection: Binding(
            get: { activeId },
            set: { newId in node.onTabSelect?(newId) }
        )) {
            ForEach(Array(tabs.enumerated()), id: \.offset) { index, tab in
                if index < node.childNodes.count {
                    let child = node.childNodes[index]
                    MobNodeView(node: child)
                        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
                        .background(child.backgroundColor.map { Color($0) } ?? Color.clear)
                        .tabItem {
                            Label(
                                tab["label"] as? String ?? "",
                                systemImage: sfSymbolName(tab["icon"] as? String ?? "circle")
                            )
                        }
                        .tag(tab["id"] as? String ?? "\(index)")
                        .ignoresSafeArea(.container, edges: .bottom)
                }
            }
        }
    }
}

// ── Video player ─────────────────────────────────────────────────────────────

private struct MobVideoPlayer: UIViewControllerRepresentable {
    let src: String
    let autoplay: Bool
    let loop: Bool
    let controls: Bool

    func makeUIViewController(context: Context) -> AVPlayerViewController {
        let vc = AVPlayerViewController()
        let url: URL
        if src.hasPrefix("http://") || src.hasPrefix("https://") {
            url = URL(string: src)!
        } else {
            url = URL(fileURLWithPath: src)
        }
        let player = AVPlayer(url: url)
        vc.player = player
        vc.showsPlaybackControls = controls
        if loop {
            NotificationCenter.default.addObserver(
                forName: .AVPlayerItemDidPlayToEndTime,
                object: player.currentItem, queue: .main) { _ in
                player.seek(to: .zero)
                player.play()
            }
        }
        if autoplay { player.play() }
        return vc
    }

    func updateUIViewController(_ vc: AVPlayerViewController, context: Context) {}
}

// ── Camera preview ────────────────────────────────────────────────────────

// Custom UIView whose backing layer is an AVCaptureVideoPreviewLayer.
// UIKit automatically keeps the layer frame in sync with the view bounds —
// no manual frame management required.
private class CameraPreviewUIView: UIView {
    override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
    var cameraLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer }
}

private struct MobCameraPreviewView: UIViewRepresentable {
    let facing: String

    func makeUIView(context: Context) -> CameraPreviewUIView {
        let view = CameraPreviewUIView()
        view.backgroundColor = .black
        view.cameraLayer.videoGravity = .resizeAspectFill
        // Connect immediately if the session is already running.
        view.cameraLayer.session = g_preview_session
        rotatePreviewConnection(view: view)
        // Observe future session changes (start, stop, facing swap).
        context.coordinator.startObserving(view: view)
        return view
    }

    // Pin the preview to portrait so what the user sees matches the
    // upright frame we ship to the model. Without this, the sensor's
    // landscape-native output renders sideways in a portrait UI.
    private func rotatePreviewConnection(view: CameraPreviewUIView) {
        guard let conn = view.cameraLayer.connection else { return }
        if #available(iOS 17.0, *) {
            if conn.isVideoRotationAngleSupported(90) {
                conn.videoRotationAngle = 90
            }
        } else if conn.isVideoOrientationSupported {
            conn.videoOrientation = .portrait
        }
    }

    func updateUIView(_ view: CameraPreviewUIView, context: Context) {}

    func makeCoordinator() -> Coordinator { Coordinator() }

    class Coordinator: NSObject {
        private var observer: NSObjectProtocol?
        private weak var hostView: CameraPreviewUIView?

        func startObserving(view: CameraPreviewUIView) {
            hostView = view
            observer = NotificationCenter.default.addObserver(
                forName: .mobCameraSessionChanged,
                object: nil,
                queue: .main
            ) { [weak self] _ in
                guard let view = self?.hostView else { return }
                view.cameraLayer.session = g_preview_session
                if let conn = view.cameraLayer.connection {
                    if #available(iOS 17.0, *) {
                        if conn.isVideoRotationAngleSupported(90) {
                            conn.videoRotationAngle = 90
                        }
                    } else if conn.isVideoOrientationSupported {
                        conn.videoOrientation = .portrait
                    }
                }
            }
        }

        deinit {
            if let obs = observer { NotificationCenter.default.removeObserver(obs) }
        }
    }
}

extension Notification.Name {
    static let mobCameraSessionChanged = Notification.Name("MobCameraSessionChanged")
}

// ── WebView ───────────────────────────────────────────────────────────────────

private let kMobJsShimSwift =
    "(function(){" +
    "if(window.mob)return;" +
    "var _h=[];" +
    "window.mob={" +
      "send:function(d){window.webkit.messageHandlers.mob.postMessage(JSON.stringify(d));}," +
      "onMessage:function(h){_h.push(h);return function(){_h=_h.filter(function(x){return x!==h;});};}," +
      "_dispatch:function(j){try{var d=JSON.parse(j);_h.forEach(function(h){h(d);});}catch(e){}}" +
    "};" +
    "})();"

private struct MobWebView: View {
    let node: MobNode

    var body: some View {
        VStack(spacing: 0) {
            if let title = node.webViewTitle {
                Text(title)
                    .font(.caption)
                    .foregroundColor(.secondary)
                    .padding(.horizontal, 12)
                    .padding(.vertical, 4)
                    .frame(maxWidth: .infinity, alignment: .leading)
            }
            MobWKWebView(node: node)
        }
    }
}

private struct MobWKWebView: UIViewRepresentable {
    let node: MobNode

    func makeCoordinator() -> Coordinator { Coordinator(node: node) }

    func makeUIView(context: Context) -> WKWebView {
        let config = WKWebViewConfiguration()
        config.userContentController.add(context.coordinator, name: "mob")
        let shim = WKUserScript(source: kMobJsShimSwift,
                                injectionTime: .atDocumentStart,
                                forMainFrameOnly: true)
        config.userContentController.addUserScript(shim)
        let wv = WKWebView(frame: .zero, configuration: config)
        wv.navigationDelegate = context.coordinator
        g_webview = wv
        if let urlStr = node.webViewUrl, let url = URL(string: urlStr) {
            wv.load(URLRequest(url: url))
        }
        return wv
    }

    func updateUIView(_ wv: WKWebView, context: Context) {
        g_webview = wv
        context.coordinator.node = node
    }

    class Coordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler {
        var node: MobNode
        init(node: MobNode) { self.node = node }

        // JS → Elixir: window.mob.send(data) arrives here.
        // Delegates to mob_deliver_webview_message() in mob_nif.m.
        func userContentController(_ ucc: WKUserContentController,
                                   didReceive message: WKScriptMessage) {
            guard message.name == "mob", let json = message.body as? String else { return }
            mob_deliver_webview_message(json)
        }

        // URL whitelist enforcement.
        func webView(_ wv: WKWebView,
                     decidePolicyFor action: WKNavigationAction,
                     decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
            guard let url = action.request.url?.absoluteString else {
                decisionHandler(.allow); return
            }
            let allowStr = node.webViewAllow ?? ""
            let allowList = allowStr.split(separator: ",").map(String.init).filter { !$0.isEmpty }
            guard !allowList.isEmpty else { decisionHandler(.allow); return }
            if allowList.contains(where: { url.hasPrefix($0) }) {
                decisionHandler(.allow)
            } else {
                decisionHandler(.cancel)
                mob_deliver_webview_blocked(url)
            }
        }
    }
}

// ── Input component views ──────────────────────────────────────────────────

private struct MobTextField: View {
    let node: MobNode
    let placeholder: String
    let initialText: String
    @State private var text: String
    @FocusState private var isFocused: Bool

    init(node: MobNode, placeholder: String, initialText: String) {
        self.node = node
        self.placeholder = placeholder
        self.initialText = initialText
        _text = State(initialValue: initialText)
    }

    private var keyboardType: UIKeyboardType {
        switch node.keyboardTypeStr {
        case "number":  return .numberPad
        case "decimal": return .decimalPad
        case "email":   return .emailAddress
        case "phone":   return .phonePad
        case "url":     return .URL
        default:        return .default
        }
    }

    private var submitLabel: SubmitLabel {
        switch node.returnKeyStr {
        case "next":   return .next
        case "go":     return .go
        case "search": return .search
        case "send":   return .send
        default:       return .done
        }
    }

    @ViewBuilder
    private var field: some View {
        if node.isSecure {
            SecureField(placeholder, text: $text)
        } else {
            TextField(placeholder, text: $text)
        }
    }

    var body: some View {
        field
            .focused($isFocused)
            .keyboardType(keyboardType)
            .submitLabel(submitLabel)
            .onSubmit {
                node.onSubmit?()
                // dismiss for terminal actions; "next" intentionally keeps keyboard open
                if node.returnKeyStr != "next" { isFocused = false }
            }
            .onChange(of: text) { _, newValue in
                node.onChangeStr?(newValue)
            }
            .onChange(of: isFocused) { _, focused in
                if focused { node.onFocus?() } else { node.onBlur?() }
            }
            // Sync from parent when the `value:` prop changes externally —
            // but only if the user isn't actively typing (which would yank
            // the cursor and overwrite their in-flight edits). This is the
            // controlled-input fix for the case where Elixir code updates
            // the bound value via Mob.Socket.assign without user input.
            .onChange(of: initialText) { _, newValue in
                if !isFocused && text != newValue {
                    text = newValue
                }
            }
            .textFieldStyle(.roundedBorder)
            .frame(maxWidth: .infinity)
            // Only contribute keyboard-toolbar items when THIS field is
            // focused. Without the `if isFocused` guard, every MobTextField
            // on the screen contributes its own Done button to the shared
            // keyboard accessory toolbar — producing N stacked Done buttons
            // when there are N fields visible. Guarded, only the focused
            // field's button shows.
            .toolbar {
                ToolbarItemGroup(placement: .keyboard) {
                    if isFocused {
                        Spacer()
                        Button("Done") { isFocused = false }
                    }
                }
            }
    }
}

private struct MobToggle: View {
    let node: MobNode
    @State private var isOn: Bool

    init(node: MobNode) {
        self.node = node
        _isOn = State(initialValue: node.checked)
    }

    var body: some View {
        let label = node.text ?? ""
        Toggle(label, isOn: $isOn)
            .onChange(of: isOn) { _, newValue in
                node.onChangeBool?(newValue)
            }
            .frame(maxWidth: .infinity, alignment: .leading)
            // issues.md #8: SwiftUI's Toggle("Label", …) initializer does
            // not propagate the label string into the underlying control's
            // accessibilityLabel — the AX tree exposes the visual Text as
            // a separate node and the Switch as a button with empty label.
            // Setting it here lets `Mob.Test.toggle(node, "Notifications")`
            // find the toggle via plain label match.
            .accessibilityLabel(label)
    }
}

private struct MobSlider: View {
    let node: MobNode
    @State private var value: Double

    init(node: MobNode) {
        self.node = node
        let initial = node.value.isNaN ? node.minValue : node.value
        _value = State(initialValue: initial)
    }

    var body: some View {
        // issues.md #7: SwiftUI's plain Slider doesn't emit AX adjustable
        // actions unless `.accessibilityAdjustableAction` is attached. Without
        // it, VoiceOver users (and `Mob.Test.adjust_slider/4` which calls the
        // same AX API) see :ok back from increment/decrement but the value
        // never changes. Default step is (max - min) / 10 — the same default
        // VoiceOver picks for native UISlider when no explicit step is set.
        let step = (node.maxValue - node.minValue) / 10.0
        Slider(value: $value, in: node.minValue...node.maxValue)
            .onChange(of: value) { _, newValue in
                node.onChangeFloat?(newValue)
            }
            .tint(node.color.map { Color($0) } ?? Color.accentColor)
            .frame(maxWidth: .infinity)
            .accessibilityAdjustableAction { direction in
                switch direction {
                case .increment:
                    value = Swift.min(value + step, node.maxValue)
                    node.onChangeFloat?(value)
                case .decrement:
                    value = Swift.max(value - step, node.minValue)
                    node.onChangeFloat?(value)
                @unknown default:
                    break
                }
            }
    }
}

private struct MobImage: View {
    let node: MobNode

    private var contentMode: ContentMode {
        node.contentModeStr == "fill" ? .fill : .fit
    }

    private var placeholder: Color {
        node.placeholderColor.map { Color($0) } ?? Color(UIColor.systemGray5)
    }

    var body: some View {
        Group {
            if let src = node.src {
                if src.hasPrefix("http://") || src.hasPrefix("https://"),
                   let url = URL(string: src) {
                    AsyncImage(url: url) { phase in
                        switch phase {
                        case .success(let image):
                            image.resizable().aspectRatio(contentMode: contentMode)
                        default:
                            placeholder
                        }
                    }
                } else if let uiImage = UIImage(contentsOfFile: src) {
                    Image(uiImage: uiImage)
                        .resizable()
                        .aspectRatio(contentMode: contentMode)
                } else {
                    placeholder
                }
            } else {
                placeholder
            }
        }
        .frame(
            width: node.fixedWidth  > 0 ? node.fixedWidth  : nil,
            height: node.fixedHeight > 0 ? node.fixedHeight : nil
        )
        .clipShape(RoundedRectangle(cornerRadius: node.cornerRadius))
    }
}

// ── Root view — observed by the hosting controller ─────────────────────────

public struct MobRootView: View {
    @ObservedObject var model = MobViewModel.shared
    @Environment(\.colorScheme) private var colorScheme
    @State private var currentRoot: MobNode?
    @State private var currentTransition: String = "none"
    // Local mirror of model.navVersion so the .id() change happens INSIDE
    // the withAnimation block (the model's @Published value changes via
    // SwiftUI observation, which doesn't carry the animation context and
    // produces a default crossfade instead of the .move transition).
    @State private var currentNavVersion: Int = 0

    public init() {}

    public var body: some View {
        ZStack {
            if let root = currentRoot {
                MobNodeView(node: root)
                    // .id changes only on navigation (push/pop/reset), so
                    // typing in a TextField doesn't tear the view down.
                    // Driven by currentNavVersion (not model.navVersion)
                    // so the change happens inside withAnimation — see
                    // .onChange(of: model.rootVersion) below.
                    .id(currentNavVersion)
                    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
                    .transition(navTransition(currentTransition))
            } else {
                ZStack {
                    Color.black.ignoresSafeArea()
                    VStack(spacing: 20) {
                        if let error = model.startupError {
                            Image(systemName: "exclamationmark.triangle.fill")
                                .font(.system(size: 44))
                                .foregroundColor(Color.orange)
                            Text("Startup Error")
                                .font(.system(size: 20, weight: .bold))
                                .foregroundColor(.white)
                            Text(error)
                                .font(.system(size: 13, design: .monospaced))
                                .foregroundColor(Color(red: 0.9, green: 0.5, blue: 0.5))
                                .multilineTextAlignment(.center)
                                .padding(.horizontal, 28)
                        } else {
                            ProgressView()
                                .progressViewStyle(.circular)
                                .tint(.white)
                                .scaleEffect(1.3)
                            Text(model.startupPhase)
                                .font(.system(size: 17, weight: .medium))
                                .foregroundColor(.white.opacity(0.75))
                        }
                    }
                }
                .frame(maxWidth: .infinity, maxHeight: .infinity)
                .transition(.opacity)
            }
        }
        .ignoresSafeArea(.container, edges: [.bottom, .horizontal])
        .onChange(of: model.rootVersion) {
            let t = model.transition
            let newRoot = model.root
            let newNavVersion = model.navVersion
            // Capture transition before the animation block so the modifier
            // sees the right value when the new view is inserted.
            currentTransition = t
            // Log every nav transition so log-tail-based checks can verify
            // the animation fired without resorting to video recording.
            // Format: [MobNav] transition=<push|pop|reset|none> navVersion=<n>
            if t != "none" {
                NSLog("[MobNav] transition=%@ navVersion=%d", t, newNavVersion)
            }
            if let animation = navAnimation(t) {
                withAnimation(animation) {
                    currentRoot = newRoot
                    // Apply navVersion inside withAnimation so the .id()
                    // change carries the animation context — without this
                    // SwiftUI replaces the view via default crossfade and
                    // the .move transition never plays.
                    currentNavVersion = newNavVersion
                }
            } else {
                currentRoot = newRoot
                currentNavVersion = newNavVersion
            }
        }
        // Notify Elixir when the OS appearance toggles so subscribers
        // (Mob.Device → Mob.Theme.Adaptive consumers) can re-resolve.
        // SwiftUI re-evaluates `colorScheme` automatically on system change,
        // so this fires reliably without polling.
        .onChange(of: colorScheme) { _, newScheme in
            mob_notify_color_scheme(newScheme == .dark ? "dark" : "light")
        }
    }

    private func navTransition(_ t: String) -> AnyTransition {
        switch t {
        case "push":
            return .asymmetric(
                insertion: .move(edge: .trailing),
                removal: .move(edge: .leading)
            )
        case "pop":
            return .asymmetric(
                insertion: .move(edge: .leading),
                removal: .move(edge: .trailing)
            )
        case "reset":
            return .opacity
        default:
            return .identity
        }
    }

    private func navAnimation(_ t: String) -> Animation? {
        switch t {
        case "push", "pop":
            return .spring(response: 0.3, dampingFraction: 0.85)
        case "reset":
            return .easeInOut(duration: 0.25)
        default:
            return nil
        }
    }
}

// MARK: - Batch 5: scroll position observation
//
// MobScrollObserver wires SwiftUI's scroll-geometry observer to MobNode's
// closures. Tier 1 (raw deltas) goes through node.onScroll which is throttled
// native-side. Tier 2 (semantic begin/end/top) is derived here. Tier 3 (parallax,
// fade-on-scroll, sticky) is rendered with no BEAM round-trip.

// MobScrollObserverGate applies the iOS 18+ observer when available and
// falls through to a no-op on older iOS. Once a UIKit-backed observer for
// iOS 17 lands, this is where the alternative would dispatch.
struct MobScrollObserverGate: ViewModifier {
    let node: MobNode
    let isHorizontal: Bool

    func body(content: Content) -> some View {
        if #available(iOS 18.0, *) {
            content.modifier(MobScrollObserver(node: node, isHorizontal: isHorizontal))
        } else {
            content
        }
    }
}

// MobScrollObserver wires SwiftUI's onScrollGeometryChange (iOS 18+) to the
// MobNode closures populated by mob_nif.m. Throttling and delta-thresholding
// happen native-side in mob_send_scroll, so this modifier just forwards every
// geometry change. End-of-scroll is detected by a debounced "no motion for N
// ms" timer.
@available(iOS 18.0, *)
struct MobScrollObserver: ViewModifier {
    let node: MobNode
    let isHorizontal: Bool

    @State private var lastX: CGFloat = 0
    @State private var lastY: CGFloat = 0
    @State private var lastTs: TimeInterval = 0
    @State private var hasBegun: Bool = false
    @State private var pastThreshold: Bool = false
    @State private var endTask: Task<Void, Never>?

    private static let endDebounceMs: Int = 150

    func body(content: Content) -> some View {
        content
            .onScrollGeometryChange(for: CGPoint.self, of: { $0.contentOffset }) { _, offset in
                let now = ProcessInfo.processInfo.systemUptime
                let dt = lastTs > 0 ? now - lastTs : 0
                let x = offset.x
                let y = offset.y
                let dx = x - lastX
                let dy = y - lastY
                let vx = dt > 0 ? dx / CGFloat(dt) : 0
                let vy = dt > 0 ? dy / CGFloat(dt) : 0

                if !hasBegun {
                    hasBegun = true
                    node.onScrollBegan?()
                    node.onScroll?(0, 0, x, y, 0, 0, "began")
                } else {
                    node.onScroll?(dx, dy, x, y, vx, vy, "dragging")
                }

                // Tier 2 — top reached (fires on entering y == 0)
                if y <= 0.001 && lastY > 0.001 {
                    node.onTopReached?()
                }

                // Tier 2 — scrolled-past (latched, only fires on transition)
                let threshold = node.scrolledPastThreshold
                if threshold > 0 {
                    let nowPast = (isHorizontal ? x : y) > threshold
                    if nowPast && !pastThreshold {
                        node.onScrolledPast?()
                    }
                    pastThreshold = nowPast
                }

                lastX = x
                lastY = y
                lastTs = now

                // Debounced scroll-ended detector. Cancel any prior task and
                // schedule a fresh one — fires only after motion stops for
                // endDebounceMs.
                endTask?.cancel()
                let ms = Self.endDebounceMs
                endTask = Task { @MainActor in
                    try? await Task.sleep(nanoseconds: UInt64(ms) * 1_000_000)
                    if Task.isCancelled { return }
                    if hasBegun {
                        node.onScrollEnded?()
                        node.onScrollSettled?()
                        node.onScroll?(0, 0, lastX, lastY, 0, 0, "ended")
                        hasBegun = false
                    }
                }
            }
    }
}
