// Copyright (C) 2023-2025  Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program.  If not, see <https://www.gnu.org/licenses/>.

const std = @import("std");
const lp = @import("lightpanda");

const js = @import("../js/js.zig");
const Frame = @import("../Frame.zig");
const URL = @import("../URL.zig");
const reflect = @import("../reflect.zig");

const EventTarget = @import("EventTarget.zig");
const collections = @import("collections.zig");

pub const CData = @import("CData.zig");
pub const Element = @import("Element.zig");
pub const Document = @import("Document.zig");
pub const HTMLDocument = @import("HTMLDocument.zig");
pub const Children = @import("children.zig").Children;
pub const DocumentFragment = @import("DocumentFragment.zig");
pub const DocumentType = @import("DocumentType.zig");
pub const ShadowRoot = @import("ShadowRoot.zig");

const log = lp.log;
const String = lp.String;
const Allocator = std.mem.Allocator;
const LinkedList = std.DoublyLinkedList;

const Node = @This();

_type: Type,
_proto: *EventTarget,
_parent: ?*Node = null,
_children: ?*Children = null,
_child_link: LinkedList.Node = .{},

// Lookup for nodes that have a different owner document than frame.document
pub const OwnerDocumentLookup = std.AutoHashMapUnmanaged(*Node, *Document);

pub const Type = union(enum) {
    cdata: *CData,
    element: *Element,
    document: *Document,
    document_type: *DocumentType,
    attribute: *Element.Attribute,
    document_fragment: *DocumentFragment,
};

pub fn asEventTarget(self: *Node) *EventTarget {
    return self._proto;
}

// Returns the node as a more specific type. Will crash if node is not a `T`.
// Use `is` to optionally get the node as T
pub fn as(self: *Node, comptime T: type) *T {
    return self.is(T).?;
}

// Return the node as a more specific type or `null` if the node is not a `T`.
pub fn is(self: *Node, comptime T: type) ?*T {
    const type_name = @typeName(T);
    switch (self._type) {
        .element => |el| {
            if (T == Element) {
                return el;
            }
            if (comptime std.mem.startsWith(u8, type_name, "browser.webapi.element.")) {
                return el.is(T);
            }
        },
        .cdata => |cd| {
            if (T == CData) {
                return cd;
            }
            if (comptime std.mem.startsWith(u8, type_name, "browser.webapi.cdata.")) {
                return cd.is(T);
            }
        },
        .attribute => |attr| {
            if (T == Element.Attribute) {
                return attr;
            }
        },
        .document => |doc| {
            if (T == Document) {
                return doc;
            }
            if (comptime std.mem.startsWith(u8, type_name, "browser.webapi.htmldocument.")) {
                return doc.is(T);
            }
        },
        .document_type => |dt| {
            if (T == DocumentType) {
                return dt;
            }
        },
        .document_fragment => |doc| {
            if (T == DocumentFragment) {
                return doc;
            }
            if (T == ShadowRoot) {
                return doc.is(ShadowRoot);
            }
        },
    }
    return null;
}

/// Given a position, returns target and previous nodes required for
/// insertAdjacentHTML, insertAdjacentElement and insertAdjacentText.
/// * `target_node` is `*Node` (where we actually insert),
/// * `previous_node` is `?*Node`.
pub fn findAdjacentNodes(self: *Node, position: []const u8) !struct { *Node, ?*Node } {
    // Case-insensitive match per HTML spec.
    // "beforeend" was the most common case in my tests; we might adjust the order
    // depending on which ones websites prefer most.
    if (std.ascii.eqlIgnoreCase(position, "beforeend")) {
        return .{ self, null };
    }

    if (std.ascii.eqlIgnoreCase(position, "afterbegin")) {
        // Get the first child; null indicates there are no children.
        return .{ self, self.firstChild() };
    }

    if (std.ascii.eqlIgnoreCase(position, "beforebegin")) {
        // The node must have a parent node in order to use this variant.
        const parent_node = self.parentNode() orelse return error.NoModificationAllowed;
        // Parent cannot be Document.
        switch (parent_node._type) {
            .document, .document_fragment => return error.NoModificationAllowed,
            else => {},
        }

        return .{ parent_node, self };
    }

    if (std.ascii.eqlIgnoreCase(position, "afterend")) {
        // The node must have a parent node in order to use this variant.
        const parent_node = self.parentNode() orelse return error.NoModificationAllowed;
        // Parent cannot be Document.
        switch (parent_node._type) {
            .document, .document_fragment => return error.NoModificationAllowed,
            else => {},
        }

        // Get the next sibling or null; null indicates our node is the only one.
        return .{ parent_node, self.nextSibling() };
    }

    // Returned if:
    // * position is not one of the four listed values.
    // * The input is XML that is not well-formed.
    return error.Syntax;
}

pub fn firstChild(self: *const Node) ?*Node {
    const children = self._children orelse return null;
    return children.first();
}

pub fn lastChild(self: *const Node) ?*Node {
    const children = self._children orelse return null;
    return children.last();
}

pub fn nextSibling(self: *const Node) ?*Node {
    return linkToNodeOrNull(self._child_link.next);
}

pub fn previousSibling(self: *const Node) ?*Node {
    return linkToNodeOrNull(self._child_link.prev);
}

pub fn parentNode(self: *const Node) ?*Node {
    return self._parent;
}

pub fn parentElement(self: *const Node) ?*Element {
    const parent = self._parent orelse return null;
    return parent.is(Element);
}

// Validates that a node can be inserted as a child of parent.
fn validateNodeInsertion(parent: *Node, node: *Node) !void {
    // Check if parent is a valid type to have children
    if (parent._type != .document and parent._type != .element and parent._type != .document_fragment) {
        return error.HierarchyError;
    }

    // Check if node contains parent (would create a cycle)
    if (node.contains(parent)) {
        return error.HierarchyError;
    }

    if (node._type == .attribute) {
        return error.HierarchyError;
    }

    // Doctype nodes can only be inserted into a Document
    if (node._type == .document_type and parent._type != .document) {
        return error.HierarchyError;
    }
}

pub fn appendChild(self: *Node, child: *Node, frame: *Frame) !*Node {
    if (child.is(DocumentFragment)) |_| {
        try frame.appendAllChildren(child, self);
        return child;
    }

    try validateNodeInsertion(self, child);

    frame.domChanged();

    // If the child is currently connected, and if its new parent is connected,
    // then we can remove + add a bit more efficiently (we don't have to fully
    // disconnect then reconnect)
    const child_connected = child.isConnected();

    // Check if we're adopting the node to a different document
    const child_owner = child.ownerDocument(frame);
    const parent_owner = self.ownerDocument(frame) orelse self.as(Document);
    const adopting_to_new_document = child_owner != null and child_owner.? != parent_owner;

    if (child._parent) |parent| {
        // we can signal removeNode that the child will remain connected
        // (when it's appended to self) so that it can be a bit more efficient.
        // But on cross-document moves the child must fully disconnect from the
        // source document (firing disconnectedCallback) before adoption.
        frame.removeNode(parent, child, .{
            .will_be_reconnected = self.isConnected() and !adopting_to_new_document,
        });
    }

    // Adopt the node tree if moving between documents
    if (adopting_to_new_document) {
        try frame.adoptNodeTree(child, child_owner.?, parent_owner);
    }

    // A custom element callback can re-parent the node. If it does, we're done
    if (child._parent != null) {
        return child;
    }

    try frame.appendNode(self, child, .{
        .child_already_connected = child_connected,
        .adopting_to_new_document = adopting_to_new_document,
    });
    return child;
}

pub fn childNodes(self: *Node, frame: *Frame) !*collections.ChildNodes {
    return collections.ChildNodes.init(self, frame);
}

pub fn getTextContent(self: *Node, writer: *std.Io.Writer) error{WriteFailed}!void {
    switch (self._type) {
        .element, .document_fragment => {
            var it = self.childrenIterator();
            while (it.next()) |child| {
                // ignore comments and processing instructions.
                if (child.is(CData.Comment) != null or child.is(CData.ProcessingInstruction) != null) {
                    continue;
                }
                try child.getTextContent(writer);
            }
        },
        .cdata => |c| try writer.writeAll(c._data.str()),
        .document => {},
        .document_type => {},
        .attribute => |attr| try writer.writeAll(attr._value.str()),
    }
}

pub fn getTextContentAlloc(self: *Node, allocator: Allocator) error{WriteFailed}![:0]const u8 {
    var buf = std.Io.Writer.Allocating.init(allocator);
    try self.getTextContent(&buf.writer);
    try buf.writer.writeByte(0);
    const data = buf.written();
    return data[0 .. data.len - 1 :0];
}

/// Returns the "child text content" which is the concatenation of the data
/// of all the Text node children of the node, in tree order.
/// This differs from textContent which includes all descendant text.
/// See: https://dom.spec.whatwg.org/#concept-child-text-content
pub fn getChildTextContent(self: *Node, writer: *std.Io.Writer) error{WriteFailed}!void {
    var it = self.childrenIterator();
    while (it.next()) |child| {
        if (child.is(CData.Text)) |text| {
            try writer.writeAll(text._proto._data.str());
        }
    }
}

pub fn setTextContent(self: *Node, data: []const u8, frame: *Frame) !void {
    switch (self._type) {
        .element => |el| {
            if (data.len == 0) {
                return el.replaceChildren(&.{}, frame);
            }
            return el.replaceChildren(&.{.{ .text = data }}, frame);
        },
        // Per spec, setting textContent on CharacterData runs replaceData(0, length, value)
        .cdata => |c| try c.replaceData(0, c.getLength(), data, frame),
        .document => {},
        .document_type => {},
        .document_fragment => |frag| {
            if (data.len == 0) {
                return frag.replaceChildren(&.{}, frame);
            }
            return frag.replaceChildren(&.{.{ .text = data }}, frame);
        },
        .attribute => |attr| return attr.setValue(.wrap(data), frame),
    }
}

pub fn getNodeName(self: *const Node, buf: []u8) []const u8 {
    return switch (self._type) {
        .element => |el| el.getTagNameSpec(buf),
        .cdata => |cd| switch (cd._type) {
            .text => "#text",
            .cdata_section => "#cdata-section",
            .comment => "#comment",
            .processing_instruction => |pi| pi._target,
        },
        .document => "#document",
        .document_type => |dt| dt.getName(),
        .document_fragment => "#document-fragment",
        .attribute => |attr| attr._name.str(),
    };
}

pub fn getNodeType(self: *const Node) u8 {
    return switch (self._type) {
        .element => 1,
        .attribute => 2,
        .cdata => |cd| switch (cd._type) {
            .text => 3,
            .cdata_section => 4,
            .processing_instruction => 7,
            .comment => 8,
        },
        .document => 9,
        .document_type => 10,
        .document_fragment => 11,
    };
}

pub fn lookupNamespaceURI(self: *Node, prefix_arg: ?[]const u8, frame: *Frame) ?[]const u8 {
    const prefix: ?[]const u8 = if (prefix_arg) |p| (if (p.len == 0) null else p) else null;

    switch (self._type) {
        .element => |el| return el.lookupNamespaceURIForElement(prefix, frame),
        .document => |doc| {
            const de = doc.getDocumentElement() orelse return null;
            return de.lookupNamespaceURIForElement(prefix, frame);
        },
        .document_type, .document_fragment => return null,
        .attribute => |attr| {
            const owner = attr.getOwnerElement() orelse return null;
            return owner.lookupNamespaceURIForElement(prefix, frame);
        },
        .cdata => {
            const parent = self.parentElement() orelse return null;
            return parent.lookupNamespaceURIForElement(prefix, frame);
        },
    }
}

pub fn isDefaultNamespace(self: *Node, namespace_arg: ?[]const u8, frame: *Frame) bool {
    const namespace: ?[]const u8 = if (namespace_arg) |ns| (if (ns.len == 0) null else ns) else null;
    const default_ns = self.lookupNamespaceURI(null, frame);
    if (default_ns == null and namespace == null) return true;
    if (default_ns != null and namespace != null) return std.mem.eql(u8, default_ns.?, namespace.?);
    return false;
}

pub fn isEqualNode(self: *Node, other: *Node) bool {
    if (self == other) {
        return true;
    }

    // Make sure types match.
    if (self.getNodeType() != other.getNodeType()) {
        return false;
    }

    // TODO: Compare `localName` and prefix.
    return switch (self._type) {
        .element => self.as(Element).isEqualNode(other.as(Element)),
        .attribute => self.as(Element.Attribute).isEqualNode(other.as(Element.Attribute)),
        .cdata => self.as(CData).isEqualNode(other.as(CData)),
        .document_fragment => self.as(DocumentFragment).isEqualNode(other.as(DocumentFragment)),
        .document_type => self.as(DocumentType).isEqualNode(other.as(DocumentType)),
        .document => {
            // Document comparison is complex and rarely used in practice
            log.warn(.not_implemented, "Node.isEqualNode", .{
                .type = "document",
            });
            return false;
        },
    };
}

pub fn isInShadowTree(self: *Node) bool {
    var node = self._parent;
    while (node) |n| {
        if (n.is(ShadowRoot) != null) {
            return true;
        }
        node = n._parent;
    }
    return false;
}

pub fn isConnected(self: *const Node) bool {
    // Walk up to find the root node
    var root = self;
    while (root._parent) |parent| {
        root = parent;
    }

    switch (root._type) {
        .document => return true,
        .document_fragment => |df| {
            const sr = df.is(ShadowRoot) orelse return false;
            return sr._host.asNode().isConnected();
        },
        else => return false,
    }
}

const GetRootNodeOpts = struct {
    composed: bool = false,
};
pub fn getRootNode(self: *Node, opts_: ?GetRootNodeOpts) *Node {
    const opts = opts_ orelse GetRootNodeOpts{};

    var root = self;
    while (root._parent) |parent| {
        root = parent;
    }

    // If composed is true, traverse through shadow boundaries
    if (opts.composed) {
        while (true) {
            const shadow_root = @constCast(root).is(ShadowRoot) orelse break;
            root = shadow_root.getHost().asNode();
            while (root._parent) |parent| {
                root = parent;
            }
        }
    }

    return root;
}

pub fn contains(self: *const Node, child_: ?*const Node) bool {
    const child = child_ orelse return false;

    if (self == child) {
        // yes, this is correct
        return true;
    }

    var parent = child._parent;
    while (parent) |p| {
        if (p == self) {
            return true;
        }
        parent = p._parent;
    }
    return false;
}

pub fn ownerDocument(self: *const Node, frame: *const Frame) ?*Document {
    // A document node does not have an owner.
    if (self._type == .document) {
        return null;
    }

    // The root of the tree that a node belongs to is its owner.
    var current = self;
    while (current._parent) |parent| {
        current = parent;
    }

    // If the root is a document, then that's our owner.
    if (current._type == .document) {
        return current._type.document;
    }

    // Otherwise, this is a detached node. Check if it has a specific owner
    // document registered (for nodes created via non-main documents).
    if (frame._node_owner_documents.get(@constCast(self))) |owner| {
        return owner;
    }

    // Default to the main document for detached nodes without a specific owner.
    return frame.document;
}

// Returns the Frame that owns this node's tree. Used to tie cached state of
// "live" collections (NodeList, HTMLCollection, etc.) to the right frame's DOM
// version: cross-realm callers must invalidate based on mutations through the
// node's owning frame, not the caller's frame.
//
// Falls back to `default` when the node has no associated document yet (e.g.,
// freshly created and detached) or its document has no frame.
pub fn ownerFrame(self: *const Node, default: *Frame) *Frame {
    if (self._type == .document) {
        return self._type.document._frame orelse default;
    }
    const doc = self.ownerDocument(default) orelse return default;
    return doc._frame orelse default;
}

pub const ResolveURLOpts = struct {
    allocator: ?Allocator = null,
};

// Resolve a URL relative to this node's owning document.
// Uses the document's charset for query string encoding (with NCR fallback for unmappable chars).
pub fn resolveURL(self: *const Node, url: anytype, frame: *Frame, opts: ResolveURLOpts) ![:0]const u8 {
    const owner_frame = self.ownerFrame(frame);
    const allocator = opts.allocator orelse frame.call_arena;
    return URL.resolve(allocator, owner_frame.base(), url, .{ .encoding = owner_frame.charset });
}

pub fn isSameDocumentAs(self: *const Node, other: *const Node, frame: *const Frame) bool {
    // Get the root document for each node
    const self_doc = if (self._type == .document) self._type.document else self.ownerDocument(frame);
    const other_doc = if (other._type == .document) other._type.document else other.ownerDocument(frame);
    return self_doc == other_doc;
}

pub fn hasChildNodes(self: *const Node) bool {
    return self.firstChild() != null;
}

pub fn isSameNode(self: *const Node, other: ?*Node) bool {
    return self == other;
}

pub fn removeChild(self: *Node, child: *Node, frame: *Frame) !*Node {
    var it = self.childrenIterator();
    while (it.next()) |n| {
        if (n == child) {
            frame.domChanged();
            frame.removeNode(self, child, .{ .will_be_reconnected = false });
            return child;
        }
    }
    return error.NotFound;
}

pub fn insertBefore(self: *Node, new_node: *Node, ref_node_: ?*Node, frame: *Frame) !*Node {
    const ref_node = ref_node_ orelse {
        return self.appendChild(new_node, frame);
    };

    // special case: if nodes are the same, ignore the change.
    if (new_node == ref_node_) {
        frame.domChanged();

        if (frame.hasMutationObservers()) {
            const parent = new_node._parent.?;
            const previous_sibling = new_node.previousSibling();
            const next_sibling = new_node.nextSibling();
            const replaced = [_]*Node{new_node};
            frame.childListChange(parent, &replaced, &replaced, previous_sibling, next_sibling);
        }

        return new_node;
    }

    if (ref_node._parent == null or ref_node._parent.? != self) {
        return error.NotFound;
    }

    if (new_node.is(DocumentFragment)) |_| {
        try frame.insertAllChildrenBefore(new_node, self, ref_node);
        return new_node;
    }

    try validateNodeInsertion(self, new_node);

    const child_already_connected = new_node.isConnected();

    // Check if we're adopting the node to a different document
    const child_owner = new_node.ownerDocument(frame);
    const parent_owner = self.ownerDocument(frame) orelse self.as(Document);
    const adopting_to_new_document = child_owner != null and child_owner.? != parent_owner;

    frame.domChanged();
    const will_be_reconnected = self.isConnected() and !adopting_to_new_document;
    if (new_node._parent) |parent| {
        frame.removeNode(parent, new_node, .{ .will_be_reconnected = will_be_reconnected });
    }

    // Adopt the node tree if moving between documents
    if (adopting_to_new_document) {
        try frame.adoptNodeTree(new_node, child_owner.?, parent_owner);
    }

    // See Node.appendChild: a callback above (disconnectedCallback or
    // adoptedCallback) can re-parent new_node. Let that placement stand.
    if (new_node._parent != null) {
        return new_node;
    }

    // The same callback could also have detached ref_node from self. Fall
    // back to append so new_node still lands in self.
    if (ref_node._parent != self) {
        try frame.appendNode(self, new_node, .{
            .child_already_connected = child_already_connected,
            .adopting_to_new_document = adopting_to_new_document,
        });
        return new_node;
    }

    try frame.insertNodeRelative(
        self,
        new_node,
        .{ .before = ref_node },
        .{
            .child_already_connected = child_already_connected,
            .adopting_to_new_document = adopting_to_new_document,
        },
    );

    return new_node;
}

pub fn replaceChild(self: *Node, new_child: *Node, old_child: *Node, frame: *Frame) !*Node {
    if (old_child._parent == null or old_child._parent.? != self) {
        return error.HierarchyError;
    }

    try validateNodeInsertion(self, new_child);

    _ = try self.insertBefore(new_child, old_child, frame);

    // Special case: if we replace a node by itself, we don't remove it.
    // insertBefore is an noop in this case.
    // Re-check parent after insertBefore since callbacks (e.g. connectedCallback)
    // could have already removed old_child from self.
    if (new_child != old_child and old_child._parent == self) {
        frame.removeNode(self, old_child, .{ .will_be_reconnected = false });
    }

    return old_child;
}

pub fn getNodeValue(self: *const Node) ?String {
    return switch (self._type) {
        .cdata => |c| c.getData(),
        .attribute => |attr| attr._value,
        .element => null,
        .document => null,
        .document_type => null,
        .document_fragment => null,
    };
}

pub fn setNodeValue(self: *const Node, value: ?String, frame: *Frame) !void {
    switch (self._type) {
        // Per spec, setting nodeValue on CharacterData runs replaceData(0, length, value)
        .cdata => |c| {
            const new_value: []const u8 = if (value) |v| v.str() else "";
            try c.replaceData(0, c.getLength(), new_value, frame);
        },
        .attribute => |attr| try attr.setValue(value, frame),
        .element => {},
        .document => {},
        .document_type => {},
        .document_fragment => {},
    }
}

pub fn format(self: *Node, writer: *std.Io.Writer) !void {
    // // If you need extra debugging:
    // return @import("../dump.zig").deep(self, .{}, writer);
    return switch (self._type) {
        .cdata => |cd| cd.format(writer),
        .element => |el| writer.print("{f}", .{el}),
        .document => writer.writeAll("<document>"),
        .document_type => writer.writeAll("<doctype>"),
        .document_fragment => writer.writeAll("<document_fragment>"),
        .attribute => |attr| writer.print("{f}", .{attr}),
    };
}

// Returns an iterator the can be used to iterate through the node's children
// For internal use.
pub fn childrenIterator(self: *Node) NodeIterator {
    const children = self._children orelse {
        return .{ .node = null };
    };

    return .{
        .node = children.first(),
    };
}

pub fn getChildrenCount(self: *Node) usize {
    return switch (self._type) {
        .element, .document, .document_fragment => self.getLength(),
        .document_type, .attribute, .cdata => return 0,
    };
}

pub fn getLength(self: *Node) u32 {
    switch (self._type) {
        .cdata => |cdata| {
            return @intCast(cdata.getData().len);
        },
        .element, .document, .document_fragment => {
            var count: u32 = 0;
            var it = self.childrenIterator();
            while (it.next()) |_| {
                count += 1;
            }
            return count;
        },
        .document_type, .attribute => return 0,
    }
}

pub fn getChildIndex(self: *Node, target: *const Node) ?u32 {
    var i: u32 = 0;
    var it = self.childrenIterator();
    while (it.next()) |child| {
        if (child == target) {
            return i;
        }
        i += 1;
    }
    return null;
}

pub fn getChildAt(self: *Node, index: u32) ?*Node {
    var i: u32 = 0;
    var it = self.childrenIterator();
    while (it.next()) |child| {
        if (i == index) {
            return child;
        }
        i += 1;
    }
    return null;
}

pub fn getData(self: *const Node) String {
    return switch (self._type) {
        .cdata => |c| c.getData(),
        else => .empty,
    };
}

pub fn setData(self: *Node, data: []const u8, frame: *Frame) !void {
    switch (self._type) {
        .cdata => |c| try c.setData(data, frame),
        else => {},
    }
}

pub fn normalize(self: *Node, frame: *Frame) !void {
    var buffer: std.ArrayList(u8) = .empty;
    return self._normalize(frame.call_arena, &buffer, frame);
}

const CloneError = error{
    OutOfMemory,
    StringTooLarge,
    NotSupported,
    NotImplemented,
    InvalidCharacterError,
    CloneError,
    Idna,
    IFrameLoadError,
    TooManyContexts,
    LinkLoadError,
    StyleLoadError,
    TypeError,
    CompilationError,
    JsException,
};
pub fn cloneNode(self: *Node, deep_: ?bool, frame: *Frame) CloneError!*Node {
    const deep = deep_ orelse false;
    switch (self._type) {
        .cdata => |cd| {
            const data = cd.getData().str();
            return switch (cd._type) {
                .text => frame.createTextNode(data),
                .cdata_section => frame.createCDATASection(data),
                .comment => frame.createComment(data),
                .processing_instruction => |pi| frame.createProcessingInstruction(pi._target, data),
            };
        },
        .element => |el| return el.clone(deep, frame),
        .document => return error.NotSupported,
        .document_type => |dt| {
            const cloned = dt.clone(frame) catch return error.CloneError;
            return cloned.asNode();
        },
        .document_fragment => |frag| return frag.cloneFragment(deep, frame),
        .attribute => |attr| {
            const cloned = attr.clone(frame) catch return error.CloneError;
            return cloned._proto;
        },
    }
}

/// Clone a node for the purpose of appending to a parent.
/// Returns null if the cloned node was already attached somewhere by a custom element
/// constructor, indicating that the constructor's decision should be respected.
///
/// This helper is used when iterating over children to clone them. The typical pattern is:
///   while (child_it.next()) |child| {
///       if (try child.cloneNodeForAppending(true, frame)) |cloned| {
///           try frame.appendNode(parent, cloned, opts);
///       }
///   }
///
/// The only case where a cloned node would already have a parent is when a custom element
/// constructor (which runs during cloning per the HTML spec) explicitly attaches the element
/// somewhere. In that case, we respect the constructor's decision and return null to signal
/// that the cloned node should not be appended to our intended parent.
pub fn cloneNodeForAppending(self: *Node, deep: bool, frame: *Frame) CloneError!?*Node {
    const cloned = try self.cloneNode(deep, frame);
    if (cloned._parent != null) {
        return null;
    }
    return cloned;
}

pub fn compareDocumentPosition(self: *Node, other: *Node) u16 {
    const DISCONNECTED: u16 = 0x01;
    const PRECEDING: u16 = 0x02;
    const FOLLOWING: u16 = 0x04;
    const CONTAINS: u16 = 0x08;
    const CONTAINED_BY: u16 = 0x10;
    const IMPLEMENTATION_SPECIFIC: u16 = 0x20;

    if (self == other) {
        return 0;
    }

    // Check if either node is disconnected
    const self_root = self.getRootNode(.{});
    const other_root = other.getRootNode(.{});

    if (self_root != other_root) {
        // Nodes are in different trees - disconnected
        // Use pointer comparison for implementation-specific ordering
        return DISCONNECTED | IMPLEMENTATION_SPECIFIC | if (@intFromPtr(self) < @intFromPtr(other)) FOLLOWING else PRECEDING;
    }

    // Check if one contains the other
    if (self.contains(other)) {
        return FOLLOWING | CONTAINED_BY;
    }

    if (other.contains(self)) {
        return PRECEDING | CONTAINS;
    }

    // Neither contains the other - find common ancestor and compare positions
    // Walk up from self to build ancestor chain
    var self_ancestors: [256]*const Node = undefined;
    var ancestor_count: usize = 0;
    var current: ?*const Node = self;
    while (current) |node| : (current = node._parent) {
        if (ancestor_count >= self_ancestors.len) break;
        self_ancestors[ancestor_count] = node;
        ancestor_count += 1;
    }

    const ancestors = self_ancestors[0..ancestor_count];

    // Walk up from other until we find common ancestor
    current = other;
    while (current) |node| : (current = node._parent) {
        // Check if this node is in self's ancestor chain
        for (ancestors, 0..) |ancestor, i| {
            if (ancestor != node) {
                continue;
            }

            // Found common ancestor
            // Compare the children that are ancestors of self and other
            if (i == 0) {
                // self is directly under the common ancestor
                // Find other's ancestor that's a child of the common ancestor
                if (other == node) {
                    // other is the common ancestor, so self follows it
                    return FOLLOWING;
                }
                var other_ancestor = other;
                while (other_ancestor._parent) |p| {
                    if (p == node) break;
                    other_ancestor = p;
                }
                return if (isNodeBefore(self, other_ancestor)) FOLLOWING else PRECEDING;
            }

            const self_ancestor = self_ancestors[i - 1];
            // Find other's ancestor that's a child of the common ancestor
            var other_ancestor = other;
            if (other == node) {
                // other is the common ancestor, so self is contained by it
                return PRECEDING | CONTAINS;
            }
            while (other_ancestor._parent) |p| {
                if (p == node) break;
                other_ancestor = p;
            }
            return if (isNodeBefore(self_ancestor, other_ancestor)) FOLLOWING else PRECEDING;
        }
    }

    // Shouldn't reach here if both nodes are in the same tree
    return DISCONNECTED;
}

// faster to compare the linked list node links directly
fn isNodeBefore(node1: *const Node, node2: *const Node) bool {
    var current = node1._child_link.next;
    const target = &node2._child_link;
    while (current) |link| {
        if (link == target) return true;
        current = link.next;
    }
    return false;
}

fn _normalize(self: *Node, allocator: Allocator, buffer: *std.ArrayList(u8), frame: *Frame) !void {
    var it = self.childrenIterator();
    while (it.next()) |child| {
        try child._normalize(allocator, buffer, frame);
    }

    var child = self.firstChild();
    while (child) |current_node| {
        var next_node = current_node.nextSibling();

        const text_node = current_node.is(CData.Text) orelse {
            child = next_node;
            continue;
        };

        if (text_node._proto.getData().len == 0) {
            frame.removeNode(self, current_node, .{ .will_be_reconnected = false });
            child = next_node;
            continue;
        }

        if (next_node) |next| {
            if (next.is(CData.Text)) |_| {
                try buffer.appendSlice(allocator, text_node.getWholeText());

                while (next_node) |node_to_merge| {
                    const next_text_node = node_to_merge.is(CData.Text) orelse break;
                    try buffer.appendSlice(allocator, next_text_node.getWholeText());

                    const to_remove = node_to_merge;
                    next_node = node_to_merge.nextSibling();
                    frame.removeNode(self, to_remove, .{ .will_be_reconnected = false });
                }
                text_node._proto._data = try frame.dupeSSO(buffer.items);
                buffer.clearRetainingCapacity();
            }
        }

        child = next_node;
    }
}

pub const GetElementsByTagNameResult = union(enum) {
    tag: collections.NodeLive(.tag),
    tag_name: collections.NodeLive(.tag_name),
    all_elements: collections.NodeLive(.all_elements),
};
// Not exposed in the WebAPI, but used by both Element and Document
pub fn getElementsByTagName(self: *Node, tag_name: []const u8, frame: *Frame) !GetElementsByTagNameResult {
    if (tag_name.len > 256) {
        // 256 seems generous.
        return error.InvalidTagName;
    }

    if (std.mem.eql(u8, tag_name, "*")) {
        return .{
            .all_elements = collections.NodeLive(.all_elements).init(self, {}, frame),
        };
    }

    const lower = std.ascii.lowerString(&frame.buf, tag_name);
    if (Node.Element.Tag.parseForMatch(lower)) |known| {
        // optimized for known tag names, comparis
        return .{
            .tag = collections.NodeLive(.tag).init(self, known, frame),
        };
    }

    const arena = frame.arena;
    const filter = try String.init(arena, tag_name, .{});
    return .{ .tag_name = collections.NodeLive(.tag_name).init(self, filter, frame) };
}

// Not exposed in the WebAPI, but used by both Element and Document
pub fn getElementsByTagNameNS(self: *Node, namespace: ?[]const u8, local_name: []const u8, frame: *Frame) !collections.NodeLive(.tag_name_ns) {
    if (local_name.len > 256) {
        return error.InvalidTagName;
    }

    // Parse namespace - "*" means wildcard (null), null means Element.Namespace.null
    const ns: ?Element.Namespace = if (namespace) |ns_str|
        if (std.mem.eql(u8, ns_str, "*")) null else Element.Namespace.parse(ns_str)
    else
        Element.Namespace.null;

    return collections.NodeLive(.tag_name_ns).init(self, .{
        .namespace = ns,
        .local_name = try String.init(frame.arena, local_name, .{}),
    }, frame);
}

// Not exposed in the WebAPI, but used by both Element and Document
pub fn getElementsByClassName(self: *Node, class_name: []const u8, frame: *Frame) !collections.NodeLive(.class_name) {
    const arena = frame.arena;

    // Parse space-separated class names
    var class_names: std.ArrayList([]const u8) = .empty;
    var it = std.mem.tokenizeAny(u8, class_name, "\t\n\x0C\r ");
    while (it.next()) |name| {
        try class_names.append(arena, try frame.dupeString(name));
    }

    return collections.NodeLive(.class_name).init(self, class_names.items, frame);
}

/// Shared implementation of replaceChildren for Element, Document, and DocumentFragment.
/// Validates all nodes, removes existing children, then appends new children.
pub fn replaceChildren(self: *Node, nodes: []const NodeOrText, frame: *Frame) !void {
    // First pass: validate all nodes and collect them
    // We need to collect because DocumentFragments contribute their children, not themselves
    var children_to_add: std.ArrayList(*Node) = .empty;

    for (nodes) |node_or_text| {
        const child = try node_or_text.toNode(frame);

        // DocumentFragments contribute their children, not themselves
        if (child.is(DocumentFragment)) |frag| {
            var frag_it = frag.asNode().childrenIterator();
            while (frag_it.next()) |frag_child| {
                try validateNodeInsertion(self, frag_child);
                try children_to_add.append(frame.call_arena, frag_child);
            }
        } else {
            try validateNodeInsertion(self, child);
            try children_to_add.append(frame.call_arena, child);
        }
    }

    frame.domChanged();

    // Remove all existing children
    var it = self.childrenIterator();
    while (it.next()) |child| {
        frame.removeNode(self, child, .{ .will_be_reconnected = false });
    }

    // Append new children
    const parent_is_connected = self.isConnected();
    for (children_to_add.items) |child| {
        var child_connected = false;
        if (child._parent) |previous_parent| {
            child_connected = child.isConnected();
            frame.removeNode(previous_parent, child, .{ .will_be_reconnected = parent_is_connected });
        }
        try frame.appendNode(self, child, .{ .child_already_connected = child_connected });
    }
}

// Writes a JSON representation of the node and its children
pub fn jsonStringify(self: *const Node, writer: *std.json.Stringify) !void {
    // stupid json api requires this to be const,
    // so we @constCast it because our stringify re-uses code that can be
    // used to iterate nodes, e.g. the NodeIterator
    return @import("../dump.zig").toJSON(@constCast(self), writer);
}

const NodeIterator = struct {
    node: ?*Node,
    pub fn next(self: *NodeIterator) ?*Node {
        const node = self.node orelse return null;
        self.node = linkToNodeOrNull(node._child_link.next);
        return node;
    }
};

// Turns a linked list node into a Node
pub fn linkToNode(n: *LinkedList.Node) *Node {
    return @fieldParentPtr("_child_link", n);
}

pub fn linkToNodeOrNull(n_: ?*LinkedList.Node) ?*Node {
    return if (n_) |n| linkToNode(n) else null;
}

pub const JsApi = struct {
    pub const bridge = js.Bridge(Node);

    pub const Meta = struct {
        pub const name = "Node";
        pub const prototype_chain = bridge.prototypeChain();
        pub var class_id: bridge.ClassId = undefined;
        pub const enumerable = false;
    };

    pub const ELEMENT_NODE = bridge.property(1, .{ .template = true });
    pub const ATTRIBUTE_NODE = bridge.property(2, .{ .template = true });
    pub const TEXT_NODE = bridge.property(3, .{ .template = true });
    pub const CDATA_SECTION_NODE = bridge.property(4, .{ .template = true });
    pub const ENTITY_REFERENCE_NODE = bridge.property(5, .{ .template = true });
    pub const ENTITY_NODE = bridge.property(6, .{ .template = true });
    pub const PROCESSING_INSTRUCTION_NODE = bridge.property(7, .{ .template = true });
    pub const COMMENT_NODE = bridge.property(8, .{ .template = true });
    pub const DOCUMENT_NODE = bridge.property(9, .{ .template = true });
    pub const DOCUMENT_TYPE_NODE = bridge.property(10, .{ .template = true });
    pub const DOCUMENT_FRAGMENT_NODE = bridge.property(11, .{ .template = true });
    pub const NOTATION_NODE = bridge.property(12, .{ .template = true });

    pub const DOCUMENT_POSITION_DISCONNECTED = bridge.property(0x01, .{ .template = true });
    pub const DOCUMENT_POSITION_PRECEDING = bridge.property(0x02, .{ .template = true });
    pub const DOCUMENT_POSITION_FOLLOWING = bridge.property(0x04, .{ .template = true });
    pub const DOCUMENT_POSITION_CONTAINS = bridge.property(0x08, .{ .template = true });
    pub const DOCUMENT_POSITION_CONTAINED_BY = bridge.property(0x10, .{ .template = true });
    pub const DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC = bridge.property(0x20, .{ .template = true });

    pub const nodeName = bridge.accessor(struct {
        fn wrap(self: *const Node, frame: *Frame) []const u8 {
            return self.getNodeName(&frame.buf);
        }
    }.wrap, null, .{});
    pub const nodeType = bridge.accessor(Node.getNodeType, null, .{});

    pub const textContent = bridge.accessor(_textContext, Node.setTextContent, .{});
    fn _textContext(self: *Node, frame: *const Frame) !?[]const u8 {
        // cdata and attributes can return value directly, avoiding the copy
        switch (self._type) {
            .element, .document_fragment => {
                var buf = std.Io.Writer.Allocating.init(frame.call_arena);
                try self.getTextContent(&buf.writer);
                return buf.written();
            },
            .cdata => |cdata| return cdata._data.str(),
            .attribute => |attr| return attr._value.str(),
            .document => return null,
            .document_type => return null,
        }
    }

    pub const firstChild = bridge.accessor(Node.firstChild, null, .{});
    pub const lastChild = bridge.accessor(Node.lastChild, null, .{});
    pub const nextSibling = bridge.accessor(Node.nextSibling, null, .{});
    pub const previousSibling = bridge.accessor(Node.previousSibling, null, .{});
    pub const parentNode = bridge.accessor(Node.parentNode, null, .{});
    pub const parentElement = bridge.accessor(Node.parentElement, null, .{});
    pub const appendChild = bridge.function(Node.appendChild, .{ .dom_exception = true });
    pub const childNodes = bridge.accessor(Node.childNodes, null, .{ .cache = .{ .private = "child_nodes" } });
    pub const isConnected = bridge.accessor(Node.isConnected, null, .{});
    pub const ownerDocument = bridge.accessor(Node.ownerDocument, null, .{});
    pub const hasChildNodes = bridge.function(Node.hasChildNodes, .{});
    pub const isSameNode = bridge.function(Node.isSameNode, .{});
    pub const contains = bridge.function(Node.contains, .{});
    pub const removeChild = bridge.function(Node.removeChild, .{ .dom_exception = true });
    pub const nodeValue = bridge.accessor(Node.getNodeValue, Node.setNodeValue, .{});
    pub const insertBefore = bridge.function(Node.insertBefore, .{ .dom_exception = true });
    pub const replaceChild = bridge.function(Node.replaceChild, .{ .dom_exception = true });
    pub const normalize = bridge.function(Node.normalize, .{});
    pub const cloneNode = bridge.function(Node.cloneNode, .{ .dom_exception = true });
    pub const compareDocumentPosition = bridge.function(Node.compareDocumentPosition, .{});
    pub const getRootNode = bridge.function(Node.getRootNode, .{});
    pub const isEqualNode = bridge.function(Node.isEqualNode, .{});
    pub const lookupNamespaceURI = bridge.function(Node.lookupNamespaceURI, .{});
    pub const isDefaultNamespace = bridge.function(Node.isDefaultNamespace, .{});

    fn _baseURI(_: *Node, frame: *const Frame) []const u8 {
        return frame.base();
    }
    pub const baseURI = bridge.accessor(_baseURI, null, .{});
};

pub const Build = struct {
    // Calls `func_name` with `args` on the most specific type where it is
    // implement. This could be on the Node itself (as a last-resort);
    pub fn call(self: *const Node, comptime func_name: []const u8, args: anytype) !void {
        inline for (@typeInfo(Node.Type).@"union".fields) |f| {
            // The inner type has its own "call" method. Defer to it.
            if (@field(Node.Type, f.name) == self._type) {
                const S = reflect.Struct(f.type);
                if (@hasDecl(S, "Build")) {
                    if (@hasDecl(S.Build, "call")) {
                        const sub = @field(self._type, f.name);
                        if (try S.Build.call(sub, func_name, args)) {
                            return;
                        }
                    }
                    // The inner type implements this function. Call it and we're done.
                    if (@hasDecl(S, func_name)) {
                        return @call(.auto, @field(f.type, func_name), args);
                    }
                }
            }
        }

        if (@hasDecl(Node.Build, func_name)) {
            // Our last resort - the node implements this function.
            return @call(.auto, @field(Node.Build, func_name), args);
        }
    }
};

pub const NodeOrText = union(enum) {
    node: *Node,
    text: []const u8,

    pub fn format(self: *const NodeOrText, writer: *std.io.Writer) !void {
        switch (self.*) {
            .node => |n| try n.format(writer),
            .text => |text| {
                try writer.writeByte('\'');
                try writer.writeAll(text);
                try writer.writeByte('\'');
            },
        }
    }

    pub fn toNode(self: *const NodeOrText, frame: *Frame) !*Node {
        return switch (self.*) {
            .node => |n| n,
            .text => |txt| frame.createTextNode(txt),
        };
    }

    /// DOM spec: first following sibling of `node` that is not in `nodes`.
    pub fn viableNextSibling(node: *Node, nodes: []const NodeOrText) ?*Node {
        var sibling = node.nextSibling() orelse return null;
        blk: while (true) {
            for (nodes) |n| {
                switch (n) {
                    .node => |nn| if (sibling == nn) {
                        sibling = sibling.nextSibling() orelse return null;
                        continue :blk;
                    },
                    .text => {},
                }
            } else {
                return sibling;
            }
        }
        return null;
    }
};

const testing = @import("../../testing.zig");
test "WebApi: Node" {
    try testing.htmlRunner("node", .{});
}
