//! `Document` / `CopiedBlocks` → HTML serialization.
//!
//! Two entry points:
//! - [`Html::export`] — `DocumentExport` trait impl. Serialises a
//!   [`Document`] using its flat `root_block_ids()` iteration order.
//! - [`blocks_to_html`] — clipboard-oriented serialiser that accepts
//!   the `CopiedBlocks` subtree shape (with explicit `children` IDs
//!   for tables / toggle descendants / etc.). This is the one the
//!   React clipboard path calls through WASM.
//!
//! Both produce identical output for simple blocks; only clipboard
//! mode emits the `data-devup-props` marker (lossless devup→devup
//! table round-trip) and Notion-compatible toggle nesting.

use std::collections::HashMap;

use devup_editor_core::{
    Block, BlockId, Document, DocumentExport, DocumentImport, IdGenerator, Mark, TextSpan,
};
use serde_json::Value;

use crate::HtmlError;
use crate::clipboard::{CopiedBlocks, DEVUP_PROPS_ATTR, encode_props};
use crate::import::parse_html;

/// Marker type carrying the [`DocumentExport`] / [`DocumentImport`]
/// impls for HTML.
pub struct Html;

impl DocumentExport for Html {
    type Output = String;
    type Error = HtmlError;

    fn export(doc: &Document) -> Result<String, HtmlError> {
        // Promote the flat Document into a children-keyed map so the
        // clipboard-aware walker can use it uniformly.
        let copied = document_to_copied_blocks(doc);
        Ok(serialize_roots(&copied.roots, &copied.by_id))
    }
}

impl DocumentImport for Html {
    type Input = String;
    type Error = HtmlError;

    fn import(input: String, id_gen: &mut dyn IdGenerator) -> Result<Document, HtmlError> {
        let copied = parse_html(&input, id_gen);
        // Promote the clipboard shape into a Document. We only keep
        // roots at Document level; call sites that need the full tree
        // (tables, toggle children) should use `html_to_copied_blocks`.
        let mut doc = Document::new();
        for root in copied.roots {
            doc.push_root_block(root);
        }
        Ok(doc)
    }
}

/// Recursively copy a block and every descendant reachable via
/// `children` into `by_id`. Used when promoting a `Document` into the
/// clipboard shape for serialisation.
///
/// Guards against cycles (a block listing itself / an ancestor as a
/// child) by short-circuiting when the id is already in `by_id` —
/// the recursion has already covered that subtree.
fn populate_map(doc: &Document, block: &Block, by_id: &mut HashMap<BlockId, Block>) {
    if by_id.contains_key(&block.id) {
        return;
    }
    by_id.insert(block.id.clone(), block.clone());
    for child_id in &block.children {
        if let Some(child) = doc.get_block(child_id) {
            populate_map(doc, child, by_id);
        }
    }
}

/// Promote a flat [`Document`] into the clipboard `{roots, byId}`
/// shape. Descends `block.children` so tables, toggle descendants, and
/// any future parent→child references survive the conversion.
///
/// Single source of truth for the "Document → `CopiedBlocks`" shape
/// transform — the WASM layer re-exports it verbatim instead of
/// maintaining a near-identical copy.
#[must_use]
#[allow(clippy::implicit_hasher)]
pub fn document_to_copied_blocks(doc: &Document) -> CopiedBlocks {
    let mut by_id: HashMap<BlockId, Block> = HashMap::new();
    let roots: Vec<Block> = doc
        .root_block_ids()
        .iter()
        .filter_map(|id| doc.get_block(id).cloned())
        .collect();
    for block in &roots {
        populate_map(doc, block, &mut by_id);
    }
    CopiedBlocks { roots, by_id }
}

/// Clipboard-oriented serialiser. Mirrors the React `blocksToHtml`:
/// emits toggle blocks in Notion's canonical nested format and consumes
/// indent-based siblings as toggle children.
///
/// The `by_id` map uses the default hasher — this is deliberate. We
/// don't generalize over `BuildHasher` because callers always build
/// this map themselves from editor state (never from a custom-hasher
/// container) and the generic bound would only add noise to the
/// public API.
#[must_use]
#[allow(clippy::implicit_hasher)]
pub fn blocks_to_html(roots: &[Block], by_id: &HashMap<BlockId, Block>) -> String {
    serialize_roots(roots, by_id)
}

/// Convenience for the WASM boundary: accepts [`CopiedBlocks`] directly.
#[must_use]
pub fn copied_blocks_to_html(copied: &CopiedBlocks) -> String {
    blocks_to_html(&copied.roots, &copied.by_id)
}

fn serialize_roots(roots: &[Block], by_id: &HashMap<BlockId, Block>) -> String {
    let mut cursor = 0usize;
    let mut out = String::new();
    emit_siblings(roots, by_id, &mut cursor, 0, &mut out);
    out
}

/// Emit roots from `cursor` forward while their indent is ≥
/// `stop_indent`. On encountering a toggle block, recursively consume
/// subsequent higher-indent blocks as its children (Notion clipboard
/// format).
fn emit_siblings(
    roots: &[Block],
    by_id: &HashMap<BlockId, Block>,
    cursor: &mut usize,
    stop_indent: i64,
    out: &mut String,
) {
    while *cursor < roots.len() {
        let block = &roots[*cursor];
        let indent = block.indent_level().max(0);
        if indent < stop_indent {
            break;
        }
        *cursor += 1;

        if block.ty == "toggle" {
            let title = render_inline(&block.content);
            out.push_str(r#"<ul class="toggle"><li><details open=""><summary>"#);
            out.push_str(&title);
            out.push_str("</summary>");
            emit_siblings(roots, by_id, cursor, indent + 1, out);
            out.push_str("</details></li></ul>");
        } else {
            write_block_html(block, by_id, out);
        }
    }
}

// ── Single-block HTML emission ────────────────────────────────────

fn write_block_html(block: &Block, by_id: &HashMap<BlockId, Block>, out: &mut String) {
    match block.ty.as_str() {
        "heading" => {
            let level = block
                .props
                .get("level")
                .and_then(Value::as_u64)
                .unwrap_or(1)
                .clamp(1, 6);
            out.push('<');
            out.push('h');
            out.push(digit(level));
            out.push('>');
            out.push_str(&render_inline(&block.content));
            out.push_str("</h");
            out.push(digit(level));
            out.push('>');
        }
        "quote" => {
            out.push_str("<blockquote>");
            out.push_str(&render_inline(&block.content));
            out.push_str("</blockquote>");
        }
        "todo" => {
            let checked = block
                .props
                .get("checked")
                .and_then(Value::as_bool)
                .unwrap_or(false);
            out.push_str("<p data-type=\"todo\" data-checked=\"");
            out.push_str(if checked { "true" } else { "false" });
            out.push_str("\">");
            out.push_str(&render_inline(&block.content));
            out.push_str("</p>");
        }
        "list" => {
            let style = block
                .props
                .get("style")
                .and_then(Value::as_str)
                .unwrap_or("unordered");
            let tag = if style.starts_with("ordered") {
                "ol"
            } else {
                "ul"
            };
            out.push('<');
            out.push_str(tag);
            out.push_str("><li>");
            out.push_str(&render_inline(&block.content));
            out.push_str("</li></");
            out.push_str(tag);
            out.push('>');
        }
        "code" => {
            let lang = block
                .props
                .get("language")
                .and_then(Value::as_str)
                .unwrap_or("");
            let plain = block.plain_text();
            out.push_str("<pre><code");
            if !lang.is_empty() {
                out.push_str(" class=\"language-");
                out.push_str(&escape_attr(lang));
                out.push('"');
            }
            out.push('>');
            out.push_str(&escape_text(&plain));
            out.push_str("</code></pre>");
        }
        "divider" => {
            out.push_str("<hr>");
        }
        "table" => {
            write_table(block, by_id, out);
        }
        _ => {
            out.push_str("<p>");
            out.push_str(&render_inline(&block.content));
            out.push_str("</p>");
        }
    }
}

fn digit(n: u64) -> char {
    match n {
        1 => '1',
        2 => '2',
        3 => '3',
        4 => '4',
        5 => '5',
        _ => '6',
    }
}

// ── Table emission ────────────────────────────────────────────────

fn write_table(table: &Block, by_id: &HashMap<BlockId, Block>, out: &mut String) {
    let mut rows_html = String::new();
    for row_id in &table.children {
        let Some(row) = by_id.get(row_id) else {
            continue;
        };
        let mut cells_html = String::new();
        for cell_id in &row.children {
            let Some(cell) = by_id.get(cell_id) else {
                continue;
            };
            write_cell(cell, &mut cells_html);
        }
        write_row(row, &cells_html, &mut rows_html);
    }
    let colgroup = serialize_colgroup(table);

    let mut attrs = TableAttrs::new();
    attrs.push_style(&inline_cell_style(&table.props));
    attrs.push_marker(&encode_props(Some(&table.props)));
    out.push_str("<table");
    attrs.write_into(out);
    out.push('>');
    out.push_str(&colgroup);
    out.push_str("<tbody>");
    out.push_str(&rows_html);
    out.push_str("</tbody></table>");
}

fn write_row(row: &Block, inner_cells: &str, out: &mut String) {
    let mut attrs = TableAttrs::new();
    let height = match row.props.get("height") {
        Some(Value::Number(n)) => n.as_f64().map(format_px),
        Some(Value::String(s)) => Some(s.clone()),
        _ => None,
    };
    if let Some(h) = height {
        attrs.push_style(&format!("height:{h}"));
    }
    attrs.push_marker(&encode_props(Some(&row.props)));

    out.push_str("<tr");
    attrs.write_into(out);
    out.push('>');
    out.push_str(inner_cells);
    out.push_str("</tr>");
}

fn write_cell(cell: &Block, out: &mut String) {
    let mut attrs = TableAttrs::new();
    if let Some(n) = cell.props.get("colspan").and_then(Value::as_u64)
        && n > 1
    {
        attrs.push_raw(&format!("colspan=\"{n}\""));
    }
    if let Some(n) = cell.props.get("rowspan").and_then(Value::as_u64)
        && n > 1
    {
        attrs.push_raw(&format!("rowspan=\"{n}\""));
    }
    attrs.push_style(&inline_cell_style(&cell.props));
    attrs.push_marker(&encode_props(Some(&cell.props)));

    out.push_str("<td");
    attrs.write_into(out);
    out.push('>');
    out.push_str(&render_inline(&cell.content));
    out.push_str("</td>");
}

fn serialize_colgroup(table: &Block) -> String {
    let Some(Value::Array(cols)) = table.props.get("columns") else {
        return String::new();
    };
    if cols.is_empty() {
        return String::new();
    }
    let mut s = String::from("<colgroup>");
    for col in cols {
        let width = col.get("width").and_then(|v| match v {
            Value::Number(n) => n.as_f64().map(format_px),
            Value::String(raw) => Some(raw.clone()),
            _ => None,
        });
        match width {
            Some(w) => {
                s.push_str("<col style=\"width:");
                s.push_str(&escape_attr(&w));
                s.push_str("\">");
            }
            None => s.push_str("<col>"),
        }
    }
    s.push_str("</colgroup>");
    s
}

/// Helper that accumulates `style="…"` declarations AND the
/// `data-devup-props` marker AND any raw `colspan`/`rowspan` attributes,
/// then emits a single leading-space attribute string.
struct TableAttrs {
    parts: Vec<String>,
    styles: Vec<String>,
    marker: String,
}

impl TableAttrs {
    fn new() -> Self {
        Self {
            parts: Vec::new(),
            styles: Vec::new(),
            marker: String::new(),
        }
    }

    fn push_style(&mut self, s: &str) {
        if !s.is_empty() {
            self.styles.push(s.to_string());
        }
    }

    fn push_marker(&mut self, marker: &str) {
        if !marker.is_empty() {
            self.marker = marker.to_string();
        }
    }

    fn push_raw(&mut self, raw: &str) {
        self.parts.push(raw.to_string());
    }

    fn write_into(&self, out: &mut String) {
        for p in &self.parts {
            out.push(' ');
            out.push_str(p);
        }
        if !self.styles.is_empty() {
            out.push_str(" style=\"");
            out.push_str(&escape_attr(&self.styles.join(";")));
            out.push('"');
        }
        if !self.marker.is_empty() {
            out.push(' ');
            out.push_str(DEVUP_PROPS_ATTR);
            out.push_str("=\"");
            out.push_str(&escape_attr(&self.marker));
            out.push('"');
        }
    }
}

fn inline_cell_style(props: &serde_json::Map<String, Value>) -> String {
    let mut parts: Vec<String> = Vec::new();
    if let Some(v) = props.get("backgroundColor").and_then(Value::as_str) {
        parts.push(format!("background-color:{v}"));
    }
    if let Some(v) = props.get("borderColor").and_then(Value::as_str) {
        parts.push(format!("border-color:{v}"));
    }
    if let Some(v) = props.get("borderWidth").and_then(Value::as_str) {
        parts.push(format!("border-width:{v}"));
    }
    if let Some(v) = props.get("borderStyle").and_then(Value::as_str) {
        parts.push(format!("border-style:{v}"));
    }
    if let Some(v) = props.get("verticalAlign").and_then(Value::as_str) {
        parts.push(format!("vertical-align:{v}"));
    }
    if let Some(v) = props.get("padding") {
        let as_str = match v {
            Value::String(s) => Some(s.clone()),
            Value::Number(n) => n.as_f64().map(format_px),
            _ => None,
        };
        if let Some(s) = as_str {
            parts.push(format!("padding:{s}"));
        }
    }
    parts.join(";")
}

/// Format a pixel dimension without trailing `.0` for whole values so
/// output matches the TS ``${n}px`` template exactly on integer
/// inputs. See the matching `extract_row_props` helper in `import.rs`
/// for the justification of the two clippy opt-outs: `float_cmp`
/// because we want an exact integral check (not an epsilon window),
/// and `cast_possible_truncation` because `as i64` saturates on
/// values exceeding `i64::MAX` rather than panicking — harmless here
/// since callers only ever pass finite positive heights produced by
/// the same `import.rs` normalization path.
///
/// `#[allow]` is scoped to the two specific expressions that need it
/// rather than the whole function — future code added here is
/// unaffected and would still be flagged by clippy.
fn format_px(v: f64) -> String {
    #[allow(clippy::float_cmp)]
    let is_integral = v == v.trunc();
    if is_integral {
        #[allow(clippy::cast_possible_truncation)]
        let as_int = v as i64;
        format!("{as_int}px")
    } else {
        format!("{v}px")
    }
}

// ── Inline (spans + marks) serialisation ─────────────────────────

fn render_inline(spans: &[TextSpan]) -> String {
    let mut out = String::new();
    for span in spans {
        out.push_str(&apply_marks(&span.text, &span.marks));
    }
    out
}

fn apply_marks(text: &str, marks: &[Mark]) -> String {
    // Text is escaped first, then `\n` becomes `<br>` — mirrors
    // `spanHtml()` ordering. Escaping before br-replace ensures any
    // `<` in the text doesn't collide with the inserted tag.
    let escaped = escape_text(text).replace('\n', "<br>");
    let mut out = escaped;

    let has = |t: &str| marks.iter().any(|m| m.ty == t);

    if has("code") {
        out = format!("<code>{out}</code>");
    }
    if has("strike") {
        out = format!("<s>{out}</s>");
    }
    if has("underline") {
        out = format!("<u>{out}</u>");
    }
    if has("italic") {
        out = format!("<em>{out}</em>");
    }
    if has("bold") {
        out = format!("<strong>{out}</strong>");
    }

    // Color / highlight → single span wrapper.
    let mut style_parts: Vec<String> = Vec::new();
    if let Some(c) = style_value(marks, "color", "color") {
        style_parts.push(format!("color:{}", sanitize_css(c)));
    }
    if let Some(bg) = style_value(marks, "highlight", "backgroundColor") {
        style_parts.push(format!("background-color:{}", sanitize_css(bg)));
    }
    if !style_parts.is_empty() {
        out = format!(
            "<span style=\"{}\">{out}</span>",
            escape_attr(&style_parts.join(";"))
        );
    }

    if let Some(href) = link_href(marks) {
        out = format!(
            "<a href=\"{}\" rel=\"noopener noreferrer\">{out}</a>",
            escape_attr(href)
        );
    }

    // Unknown marks — surface them via `<span data-mark="type">` so
    // text is never silently dropped on copy.
    for mark in marks {
        if !is_known_mark(&mark.ty) {
            out = format!("<span data-mark=\"{}\">{out}</span>", escape_attr(&mark.ty));
        }
    }

    out
}

fn is_known_mark(ty: &str) -> bool {
    matches!(
        ty,
        "bold" | "italic" | "underline" | "strike" | "code" | "link" | "color" | "highlight"
    )
}

fn style_value<'a>(marks: &'a [Mark], mark_type: &str, key: &str) -> Option<&'a str> {
    marks.iter().find(|m| m.ty == mark_type).and_then(|mark| {
        mark.style()
            .and_then(|style| style.get(key))
            .and_then(Value::as_str)
    })
}

fn link_href(marks: &[Mark]) -> Option<&str> {
    marks.iter().find(|m| m.ty == "link").and_then(|mark| {
        mark.attrs
            .get("href")
            .and_then(Value::as_str)
            .filter(|href| is_safe_href(href))
    })
}

/// Reject hrefs that could trigger script execution when opened
/// directly (`javascript:`, `vbscript:`), or would leak local-file or
/// arbitrary-content URIs (`file:`, most `data:`). `data:image/*` is
/// permitted because inline-image previews are legitimate.
///
/// **Must stay in sync with `isSafeLinkHref` in
/// `packages/react/src/utils/spansToHtml.ts`** — any policy change
/// here must be mirrored there (and vice versa) so the React render
/// path and the clipboard export path reject identical URLs. A parity
/// test in `tests/href_parity.rs` enforces the common cases.
pub(crate) fn is_safe_href(href: &str) -> bool {
    let trimmed = href.trim().to_ascii_lowercase();
    if trimmed.is_empty() {
        return false;
    }
    if trimmed.starts_with("javascript:")
        || trimmed.starts_with("vbscript:")
        || trimmed.starts_with("file:")
    {
        return false;
    }
    // Only `data:image/*` is permitted; other data URIs can smuggle
    // HTML / scripts into third-party viewers.
    if trimmed.starts_with("data:") && !trimmed.starts_with("data:image/") {
        return false;
    }
    true
}

fn sanitize_css(s: &str) -> String {
    s.chars()
        .filter(|c| *c != '"' && *c != '\\' && *c != '\n' && *c != '\r')
        .collect()
}

fn escape_text(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for c in s.chars() {
        match c {
            '&' => out.push_str("&amp;"),
            '<' => out.push_str("&lt;"),
            '>' => out.push_str("&gt;"),
            _ => out.push(c),
        }
    }
    out
}

fn escape_attr(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for c in s.chars() {
        match c {
            '&' => out.push_str("&amp;"),
            '<' => out.push_str("&lt;"),
            '>' => out.push_str("&gt;"),
            '"' => out.push_str("&quot;"),
            _ => out.push(c),
        }
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;
    use devup_editor_core::{TextSpan, model::block::Block};

    /// The flat-document path: `root_block_ids` becomes `roots` and
    /// `by_id` contains every root. No children traversal needed.
    #[test]
    fn document_to_copied_blocks_flat_document() {
        let mut doc = Document::new();
        let mut p1 = Block::new_paragraph(BlockId::new("p1"));
        p1.content = vec![TextSpan::plain("first")];
        let mut p2 = Block::new_paragraph(BlockId::new("p2"));
        p2.content = vec![TextSpan::plain("second")];
        doc.push_root_block(p1);
        doc.push_root_block(p2);

        let copied = document_to_copied_blocks(&doc);
        assert_eq!(copied.roots.len(), 2);
        assert_eq!(copied.by_id.len(), 2);
        assert!(copied.by_id.contains_key(&BlockId::new("p1")));
        assert!(copied.by_id.contains_key(&BlockId::new("p2")));
    }

    /// The table-subtree path: rows and cells aren't in `root_block_ids`
    /// but are reachable via `block.children`. They MUST show up in
    /// `by_id` or the clipboard loses the table structure.
    #[test]
    fn document_to_copied_blocks_preserves_table_children() {
        let mut doc = Document::new();
        let cell_id = BlockId::new("c1");
        let row_id = BlockId::new("r1");
        let table_id = BlockId::new("t1");

        let mut cell = Block::new(cell_id.clone(), "table_cell");
        cell.content = vec![TextSpan::plain("hi")];
        cell.parent = Some(row_id.clone());

        let mut row = Block::new(row_id.clone(), "table_row");
        row.children = vec![cell_id.clone()];
        row.parent = Some(table_id.clone());

        let mut table = Block::new(table_id.clone(), "table");
        table.children = vec![row_id.clone()];

        // Push all blocks as roots (Document doesn't model children
        // hierarchy separately — the parent/children fields on Block
        // are the tree representation). For this test the important
        // thing is that `document_to_copied_blocks` follows
        // `block.children` and NOT the root list.
        doc.push_root_block(table);
        doc.push_root_block(row);
        doc.push_root_block(cell);

        let copied = document_to_copied_blocks(&doc);
        // Every descendant reachable via children is in `by_id`.
        assert!(copied.by_id.contains_key(&table_id));
        assert!(copied.by_id.contains_key(&row_id));
        assert!(copied.by_id.contains_key(&cell_id));
    }

    /// An empty document produces an empty `CopiedBlocks`.
    #[test]
    fn document_to_copied_blocks_empty() {
        let doc = Document::new();
        let copied = document_to_copied_blocks(&doc);
        assert!(copied.roots.is_empty());
        assert!(copied.by_id.is_empty());
    }

    /// Self-reference / cycle guard: a block listing itself as a
    /// child must not cause infinite recursion. `populate_map` uses
    /// `entry().or_insert_with()` which is a strong enough guard.
    #[test]
    fn document_to_copied_blocks_cycle_safe() {
        let mut doc = Document::new();
        let mut b = Block::new(BlockId::new("x"), "paragraph");
        b.children = vec![BlockId::new("x")];
        doc.push_root_block(b);
        let copied = document_to_copied_blocks(&doc);
        assert_eq!(copied.by_id.len(), 1);
    }
}
