// Copyright (C) 2023-2026  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 Frame = @import("Frame.zig");
const URL = @import("URL.zig");
const TreeWalker = @import("webapi/TreeWalker.zig");
const Element = @import("webapi/Element.zig");
const Node = @import("webapi/Node.zig");
const isAllWhitespace = @import("../string.zig").isAllWhitespace;

pub const Opts = struct {};

const State = struct {
    const ListType = enum { ordered, unordered };
    const ListState = struct {
        type: ListType,
        index: usize,
    };

    list_depth: usize = 0,
    list_stack: [32]ListState = undefined,
    pre_node: ?*Node = null,
    in_table: bool = false,
    table_row_index: usize = 0,
    table_col_count: usize = 0,
    last_char_was_newline: bool = true,
};

fn shouldAddSpacing(tag: Element.Tag) bool {
    return switch (tag) {
        .p, .h1, .h2, .h3, .h4, .h5, .h6, .blockquote, .pre, .table => true,
        else => false,
    };
}

fn isLayoutBlock(tag: Element.Tag) bool {
    return switch (tag) {
        .main, .section, .article, .nav, .aside, .header, .footer, .div, .ul, .ol => true,
        else => false,
    };
}

fn isStandaloneAnchor(el: *Element) bool {
    const node = el.asNode();
    const parent = node.parentNode() orelse return false;
    const parent_el = parent.is(Element) orelse return false;

    if (!isLayoutBlock(parent_el.getTag())) return false;

    var prev = node.previousSibling();
    while (prev) |p| : (prev = p.previousSibling()) {
        if (isSignificantText(p)) return false;
        if (p.is(Element)) |pe| {
            if (isVisibleElement(pe)) break;
        }
    }

    var next = node.nextSibling();
    while (next) |n| : (next = n.nextSibling()) {
        if (isSignificantText(n)) return false;
        if (n.is(Element)) |ne| {
            if (isVisibleElement(ne)) break;
        }
    }

    return true;
}

fn isSignificantText(node: *Node) bool {
    const text = node.is(Node.CData.Text) orelse return false;
    return !isAllWhitespace(text.getWholeText());
}

fn isVisibleElement(el: *Element) bool {
    const tag = el.getTag();
    return !tag.isMetadata() and tag != .svg;
}

fn getAnchorLabel(el: *Element) ?[]const u8 {
    return el.getAttributeSafe(comptime .wrap("aria-label")) orelse el.getAttributeSafe(comptime .wrap("title"));
}

const ContentInfo = struct {
    has_visible: bool,
    has_block: bool,
};

fn analyzeContent(root: *Node) ContentInfo {
    var result: ContentInfo = .{ .has_visible = false, .has_block = false };
    var tw = TreeWalker.FullExcludeSelf.init(root, .{});
    while (tw.next()) |node| {
        if (isSignificantText(node)) {
            result.has_visible = true;
            if (result.has_block) return result;
        } else if (node.is(Element)) |el| {
            if (!isVisibleElement(el)) {
                tw.skipChildren();
            } else {
                const tag = el.getTag();
                if (tag == .img) {
                    result.has_visible = true;
                    if (result.has_block) return result;
                }
                if (tag.isBlock()) {
                    result.has_block = true;
                    if (result.has_visible) return result;
                }
            }
        }
    }
    return result;
}

const Context = struct {
    state: State,
    writer: *std.Io.Writer,
    frame: *Frame,

    fn ensureNewline(self: *Context) !void {
        if (!self.state.last_char_was_newline) {
            try self.writer.writeByte('\n');
            self.state.last_char_was_newline = true;
        }
    }

    fn render(self: *Context, node: *Node) error{WriteFailed}!void {
        switch (node._type) {
            .document, .document_fragment => {
                try self.renderChildren(node);
            },
            .element => |el| {
                try self.renderElement(el);
            },
            .cdata => |cd| {
                if (node.is(Node.CData.Text)) |_| {
                    var text = cd.getData().str();
                    if (self.state.pre_node) |pre| {
                        if (node.parentNode() == pre and node.nextSibling() == null) {
                            text = std.mem.trimRight(u8, text, " \t\r\n");
                        }
                    }
                    try self.renderText(text);
                }
            },
            else => {},
        }
    }

    fn renderChildren(self: *Context, parent: *Node) !void {
        var it = parent.childrenIterator();
        while (it.next()) |child| {
            try self.render(child);
        }
    }

    fn renderElement(self: *Context, el: *Element) !void {
        const tag = el.getTag();

        if (!isVisibleElement(el)) return;

        // Ensure block elements start on a new line
        if (tag.isBlock() and !self.state.in_table) {
            try self.ensureNewline();
            if (shouldAddSpacing(tag)) {
                try self.writer.writeByte('\n');
            }
        } else if (tag == .li or tag == .tr) {
            try self.ensureNewline();
        }

        switch (tag) {
            .h1 => try self.writer.writeAll("# "),
            .h2 => try self.writer.writeAll("## "),
            .h3 => try self.writer.writeAll("### "),
            .h4 => try self.writer.writeAll("#### "),
            .h5 => try self.writer.writeAll("##### "),
            .h6 => try self.writer.writeAll("###### "),
            .ul => {
                if (self.state.list_depth < self.state.list_stack.len) {
                    self.state.list_stack[self.state.list_depth] = .{ .type = .unordered, .index = 0 };
                    self.state.list_depth += 1;
                }
            },
            .ol => {
                if (self.state.list_depth < self.state.list_stack.len) {
                    self.state.list_stack[self.state.list_depth] = .{ .type = .ordered, .index = 1 };
                    self.state.list_depth += 1;
                }
            },
            .li => {
                const indent = if (self.state.list_depth > 0) self.state.list_depth - 1 else 0;
                for (0..indent) |_| try self.writer.writeAll("  ");

                if (self.state.list_depth > 0 and self.state.list_stack[self.state.list_depth - 1].type == .ordered) {
                    const current_list = &self.state.list_stack[self.state.list_depth - 1];
                    try self.writer.print("{d}. ", .{current_list.index});
                    current_list.index += 1;
                } else {
                    try self.writer.writeAll("- ");
                }
                self.state.last_char_was_newline = false;
            },
            .table => {
                self.state.in_table = true;
                self.state.table_row_index = 0;
                self.state.table_col_count = 0;
            },
            .tr => {
                self.state.table_col_count = 0;
                try self.writer.writeByte('|');
            },
            .td, .th => {
                self.state.last_char_was_newline = false;
                try self.writer.writeByte(' ');
            },
            .blockquote => {
                try self.writer.writeAll("> ");
                self.state.last_char_was_newline = false;
            },
            .pre => {
                try self.writer.writeAll("```\n");
                self.state.pre_node = el.asNode();
                self.state.last_char_was_newline = true;
            },
            .code => {
                if (self.state.pre_node == null) {
                    try self.writer.writeByte('`');
                    self.state.last_char_was_newline = false;
                }
            },
            .b, .strong => {
                try self.writer.writeAll("**");
                self.state.last_char_was_newline = false;
            },
            .i, .em => {
                try self.writer.writeAll("*");
                self.state.last_char_was_newline = false;
            },
            .s, .del => {
                try self.writer.writeAll("~~");
                self.state.last_char_was_newline = false;
            },
            .hr => {
                try self.writer.writeAll("---\n");
                self.state.last_char_was_newline = true;
                return;
            },
            .br => {
                if (self.state.in_table) {
                    try self.writer.writeByte(' ');
                } else {
                    try self.writer.writeByte('\n');
                    self.state.last_char_was_newline = true;
                }
                return;
            },
            .img => {
                try self.writer.writeAll("![");
                if (el.getAttributeSafe(comptime .wrap("alt"))) |alt| {
                    try self.escape(alt);
                }
                try self.writer.writeAll("](");
                if (el.getAttributeSafe(comptime .wrap("src"))) |src| {
                    const frame = self.frame;
                    const absolute_src = URL.resolve(frame.call_arena, frame.base(), src, .{ .encoding = frame.charset }) catch src;
                    try self.writer.writeAll(absolute_src);
                }
                try self.writer.writeAll(")");
                self.state.last_char_was_newline = false;
                return;
            },
            .anchor => {
                const frame = self.frame;
                const info = analyzeContent(el.asNode());
                const label = getAnchorLabel(el);
                const href_raw = el.getAttributeSafe(comptime .wrap("href"));

                if (!info.has_visible and label == null and href_raw == null) return;

                const href = if (href_raw) |h| URL.resolve(frame.call_arena, frame.base(), h, .{ .encoding = frame.charset }) catch h else null;

                if (info.has_block) {
                    try self.renderChildren(el.asNode());
                    if (href) |h| {
                        if (!self.state.last_char_was_newline) try self.writer.writeByte('\n');
                        try self.writer.writeByte('[');
                        try self.writer.writeAll(label orelse h);
                        try self.writer.writeAll("](");
                        try self.writer.writeAll(h);
                        try self.writer.writeAll(")\n");
                        self.state.last_char_was_newline = true;
                    }
                    return;
                }

                const standalone = isStandaloneAnchor(el);
                if (standalone) {
                    if (!self.state.last_char_was_newline) try self.writer.writeByte('\n');
                }
                try self.writer.writeByte('[');
                if (info.has_visible) {
                    try self.renderChildren(el.asNode());
                } else {
                    try self.writer.writeAll(label orelse "");
                }
                try self.writer.writeAll("](");
                if (href) |h| {
                    try self.writer.writeAll(h);
                }
                try self.writer.writeByte(')');
                if (standalone) {
                    try self.writer.writeByte('\n');
                    self.state.last_char_was_newline = true;
                } else {
                    self.state.last_char_was_newline = false;
                }
                return;
            },
            .input => {
                const type_attr = el.getAttributeSafe(comptime .wrap("type")) orelse return;
                if (std.ascii.eqlIgnoreCase(type_attr, "checkbox")) {
                    const checked = el.getAttributeSafe(comptime .wrap("checked")) != null;
                    try self.writer.writeAll(if (checked) "[x] " else "[ ] ");
                    self.state.last_char_was_newline = false;
                }
                return;
            },
            else => {},
        }

        try self.renderChildren(el.asNode());

        switch (tag) {
            .pre => {
                if (!self.state.last_char_was_newline) {
                    try self.writer.writeByte('\n');
                }
                try self.writer.writeAll("```\n");
                self.state.pre_node = null;
                self.state.last_char_was_newline = true;
            },
            .code => {
                if (self.state.pre_node == null) {
                    try self.writer.writeByte('`');
                    self.state.last_char_was_newline = false;
                }
            },
            .b, .strong => {
                try self.writer.writeAll("**");
                self.state.last_char_was_newline = false;
            },
            .i, .em => {
                try self.writer.writeAll("*");
                self.state.last_char_was_newline = false;
            },
            .s, .del => {
                try self.writer.writeAll("~~");
                self.state.last_char_was_newline = false;
            },
            .blockquote => {},
            .ul, .ol => {
                if (self.state.list_depth > 0) self.state.list_depth -= 1;
            },
            .table => {
                self.state.in_table = false;
            },
            .tr => {
                try self.writer.writeByte('\n');
                if (self.state.table_row_index == 0) {
                    try self.writer.writeByte('|');
                    for (0..self.state.table_col_count) |_| {
                        try self.writer.writeAll("---|");
                    }
                    try self.writer.writeByte('\n');
                }
                self.state.table_row_index += 1;
                self.state.last_char_was_newline = true;
            },
            .td, .th => {
                try self.writer.writeAll(" |");
                self.state.table_col_count += 1;
                self.state.last_char_was_newline = false;
            },
            else => {},
        }

        if (tag.isBlock() and !self.state.in_table) {
            try self.ensureNewline();
        }
    }

    fn renderText(self: *Context, text: []const u8) !void {
        if (text.len == 0) return;

        if (self.state.pre_node) |_| {
            try self.writer.writeAll(text);
            self.state.last_char_was_newline = text[text.len - 1] == '\n';
            return;
        }

        // Check for pure whitespace
        if (isAllWhitespace(text)) {
            if (!self.state.last_char_was_newline) {
                try self.writer.writeByte(' ');
            }
            return;
        }

        // Collapse whitespace
        var it = std.mem.tokenizeAny(u8, text, " \t\n\r");
        var first = true;
        while (it.next()) |word| {
            if (!first or (!self.state.last_char_was_newline and std.ascii.isWhitespace(text[0]))) {
                try self.writer.writeByte(' ');
            }

            try self.escape(word);
            self.state.last_char_was_newline = false;
            first = false;
        }

        // Handle trailing whitespace from the original text
        if (!first and !self.state.last_char_was_newline and std.ascii.isWhitespace(text[text.len - 1])) {
            try self.writer.writeByte(' ');
        }
    }

    fn escape(self: *Context, text: []const u8) !void {
        var start: usize = 0;
        for (text, 0..) |c, i| {
            switch (c) {
                '\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '#', '+', '-', '!', '|' => {
                    if (i > start) try self.writer.writeAll(text[start..i]);
                    try self.writer.writeByte('\\');
                    try self.writer.writeByte(c);
                    start = i + 1;
                },
                else => {},
            }
        }
        if (start < text.len) try self.writer.writeAll(text[start..]);
    }
};

pub fn dump(node: *Node, opts: Opts, writer: *std.Io.Writer, frame: *Frame) !void {
    _ = opts;
    var ctx: Context = .{
        .state = .{},
        .writer = writer,
        .frame = frame,
    };
    try ctx.render(node);
    if (!ctx.state.last_char_was_newline) {
        try writer.writeByte('\n');
    }
}

fn testMarkdownHTML(html: []const u8, expected: []const u8) !void {
    const testing = @import("../testing.zig");
    const frame = try testing.test_session.createPage();
    defer testing.test_session.removePage();
    frame.url = "http://localhost/";

    const doc = frame.window._document;

    const div = try doc.createElement("div", null, frame);
    try frame.parseHtmlAsChildren(div.asNode(), html);

    var aw: std.Io.Writer.Allocating = .init(testing.allocator);
    defer aw.deinit();
    try dump(div.asNode(), .{}, &aw.writer, frame);

    try testing.expectString(expected, aw.written());
}

test "browser.markdown: basic" {
    try testMarkdownHTML("Hello world", "Hello world\n");
}

test "browser.markdown: whitespace" {
    try testMarkdownHTML("<span>A</span> <span>B</span>", "A B\n");
}

test "browser.markdown: escaping" {
    try testMarkdownHTML("<p># Not a header</p>", "\n\\# Not a header\n");
}

test "browser.markdown: strikethrough" {
    try testMarkdownHTML("<s>deleted</s>", "~~deleted~~\n");
}

test "browser.markdown: task list" {
    try testMarkdownHTML(
        \\<input type="checkbox" checked><input type="checkbox">
    , "[x] [ ] \n");
}

test "browser.markdown: ordered list" {
    try testMarkdownHTML(
        \\<ol><li>First</li><li>Second</li></ol>
    , "1. First\n2. Second\n");
}

test "browser.markdown: table" {
    try testMarkdownHTML(
        \\<table><thead><tr><th>Head 1</th><th>Head 2</th></tr></thead>
        \\<tbody><tr><td>Cell 1</td><td>Cell 2</td></tr></tbody></table>
    ,
        \\
        \\| Head 1 | Head 2 |
        \\|---|---|
        \\| Cell 1 | Cell 2 |
        \\
    );
}

test "browser.markdown: nested lists" {
    try testMarkdownHTML(
        \\<ul><li>Parent<ul><li>Child</li></ul></li></ul>
    ,
        \\- Parent
        \\  - Child
        \\
    );
}

test "browser.markdown: blockquote" {
    try testMarkdownHTML("<blockquote>Hello world</blockquote>", "\n> Hello world\n");
}

test "browser.markdown: links" {
    try testMarkdownHTML("<a href=\"/relative\">Link</a>", "[Link](http://localhost/relative)\n");
}

test "browser.markdown: images" {
    try testMarkdownHTML("<img src=\"logo.png\" alt=\"Logo\">", "![Logo](http://localhost/logo.png)\n");
}

test "browser.markdown: headings" {
    try testMarkdownHTML("<h1>Title</h1><h2>Subtitle</h2>",
        \\
        \\# Title
        \\
        \\## Subtitle
        \\
    );
}

test "browser.markdown: code" {
    try testMarkdownHTML(
        \\<p>Use git push</p>
        \\<pre><code>line 1
        \\line 2</code></pre>
    ,
        \\
        \\Use git push
        \\
        \\```
        \\line 1
        \\line 2
        \\```
        \\
    );
}

test "browser.markdown: block link" {
    try testMarkdownHTML(
        \\<a href="https://example.com">
        \\  <h3>Title</h3>
        \\  <p>Description</p>
        \\</a>
    ,
        \\
        \\### Title
        \\
        \\Description
        \\[https://example.com](https://example.com)
        \\
    );
}

test "browser.markdown: block link with aria-label" {
    try testMarkdownHTML(
        \\<a href="https://example.com" aria-label="Docs">
        \\  <h3>Title</h3>
        \\  <p>Description</p>
        \\</a>
    ,
        \\
        \\### Title
        \\
        \\Description
        \\[Docs](https://example.com)
        \\
    );
}

test "browser.markdown: block link with title" {
    try testMarkdownHTML(
        \\<a href="https://example.com" title="Docs">
        \\  <h3>Title</h3>
        \\  <p>Description</p>
        \\</a>
    ,
        \\
        \\### Title
        \\
        \\Description
        \\[Docs](https://example.com)
        \\
    );
}

test "browser.markdown: inline link" {
    try testMarkdownHTML(
        \\<p>Visit <a href="https://example.com">Example</a>.</p>
    ,
        \\
        \\Visit [Example](https://example.com).
        \\
    );
}

test "browser.markdown: standalone anchors" {
    // Inside main, with whitespace between anchors -> treated as blocks
    try testMarkdownHTML(
        \\<main>
        \\  <a href="1">Link 1</a>
        \\  <a href="2">Link 2</a>
        \\</main>
    ,
        \\[Link 1](http://localhost/1)
        \\[Link 2](http://localhost/2)
        \\
    );
}

test "browser.markdown: mixed anchors in main" {
    // Anchors surrounded by text should remain inline
    try testMarkdownHTML(
        \\<main>
        \\  Welcome <a href="1">Link 1</a>.
        \\</main>
    ,
        \\Welcome [Link 1](http://localhost/1). 
        \\
    );
}

test "browser.markdown: skip empty links" {
    try testMarkdownHTML(
        \\<a href="/"></a>
        \\<a href="/"><svg></svg></a>
    ,
        \\[](http://localhost/)
        \\[](http://localhost/)
        \\
    );
}

test "browser.markdown: resolve links" {
    const testing = @import("../testing.zig");
    const frame = try testing.test_session.createPage();
    defer testing.test_session.removePage();
    frame.url = "https://example.com/a/index.html";

    const doc = frame.window._document;
    const div = try doc.createElement("div", null, frame);
    try frame.parseHtmlAsChildren(div.asNode(),
        \\<a href="b">Link</a>
        \\<img src="../c.png" alt="Img">
        \\<a href="/my page">Space</a>
    );

    var aw: std.Io.Writer.Allocating = .init(testing.allocator);
    defer aw.deinit();
    try dump(div.asNode(), .{}, &aw.writer, frame);

    try testing.expectString(
        \\[Link](https://example.com/a/b)
        \\![Img](https://example.com/c.png) 
        \\[Space](https://example.com/my%20page)
        \\
    , aw.written());
}

test "browser.markdown: anchor fallback label" {
    try testMarkdownHTML(
        \\<a href="/discord" aria-label="Discord Server"><svg></svg></a>
    , "[Discord Server](http://localhost/discord)\n");

    try testMarkdownHTML(
        \\<a href="/search" title="Search Site"><svg></svg></a>
    , "[Search Site](http://localhost/search)\n");

    try testMarkdownHTML(
        \\<a href="/no-label"><svg></svg></a>
    , "[](http://localhost/no-label)\n");
}
