use std::fs;
use std::path::Path;

use quick_xml::events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event};
use quick_xml::{Reader, Writer, XmlVersion, escape::escape};

use crate::engine::EngineError;
use crate::engine::EngineResult;
use crate::model::Project;
use crate::util::atomic_write_str;
use crate::util::timestamp::unix_ms_to_iso;

/// A navigation menu item per menu.allium.
#[derive(Debug, Clone, PartialEq)]
pub struct MenuItem {
    pub kind: MenuItemKind,
    pub label: String,
    pub slug: Option<String>,
    pub children: Vec<MenuItem>,
}

#[derive(Debug, Clone, PartialEq)]
pub enum MenuItemKind {
    Home,
    Page,
    Submenu,
    CategoryArchive,
}

impl MenuItemKind {
    fn as_str(&self) -> &'static str {
        match self {
            Self::Home => "home",
            Self::Page => "page",
            Self::Submenu => "submenu",
            Self::CategoryArchive => "category-archive",
        }
    }

    fn from_str(s: &str) -> Self {
        match s {
            "home" => Self::Home,
            "submenu" => Self::Submenu,
            "category-archive" | "category_archive" => Self::CategoryArchive,
            _ => Self::Page,
        }
    }
}

/// Read the navigation menu from meta/menu.opml.
pub fn read_menu(data_dir: &Path) -> EngineResult<Vec<MenuItem>> {
    let path = data_dir.join("meta").join("menu.opml");
    if !path.exists() {
        return Ok(vec![MenuItem {
            kind: MenuItemKind::Home,
            label: "Home".to_string(),
            slug: None,
            children: Vec::new(),
        }]);
    }
    let content = fs::read_to_string(&path)?;
    parse_opml(&content)
}

/// Write the navigation menu to meta/menu.opml.
/// Per menu.allium UpdateMenu rule: Home entry is always extracted and prepended.
pub fn write_menu(data_dir: &Path, project: &Project, items: &[MenuItem]) -> EngineResult<()> {
    let normalized = normalize_menu(items);
    let timestamp = if project.updated_at > 0 {
        project.updated_at
    } else {
        project.created_at
    };
    let opml = serialize_opml(&project.name, timestamp, &normalized)?;
    let path = data_dir.join("meta").join("menu.opml");
    atomic_write_str(&path, &opml)?;
    Ok(())
}

/// Return the default menu OPML for new projects.
pub fn default_menu_opml(project_name: &str, timestamp: i64) -> String {
    let items = vec![MenuItem {
        kind: MenuItemKind::Home,
        label: "Home".to_string(),
        slug: None,
        children: Vec::new(),
    }];
    serialize_opml(project_name, timestamp, &items).expect("writing menu XML to memory cannot fail")
}

/// Per menu.allium HomeAlwaysPresent: ensure Home is always first.
fn normalize_menu(items: &[MenuItem]) -> Vec<MenuItem> {
    let without_home: Vec<_> = items.iter().filter_map(normalize_non_home).collect();
    let mut result = vec![MenuItem {
        kind: MenuItemKind::Home,
        label: "Home".to_string(),
        slug: None,
        children: Vec::new(),
    }];
    result.extend(without_home);
    result
}

fn normalize_non_home(item: &MenuItem) -> Option<MenuItem> {
    if item.kind == MenuItemKind::Home {
        return None;
    }
    Some(MenuItem {
        kind: item.kind.clone(),
        label: item.label.clone(),
        slug: match item.kind {
            MenuItemKind::Page | MenuItemKind::CategoryArchive => item.slug.clone(),
            MenuItemKind::Home | MenuItemKind::Submenu => None,
        },
        children: if item.kind == MenuItemKind::Submenu {
            item.children
                .iter()
                .filter_map(normalize_non_home)
                .collect()
        } else {
            Vec::new()
        },
    })
}

/// Parse OPML 2.0 XML into menu items.
fn parse_opml(content: &str) -> EngineResult<Vec<MenuItem>> {
    let mut reader = Reader::from_str(content);
    reader.config_mut().trim_text(true);
    let mut items = Vec::new();
    let mut outlines: Vec<Option<MenuItem>> = Vec::new();
    let mut elements: Vec<Vec<u8>> = Vec::new();

    loop {
        match reader.read_event() {
            Ok(Event::Start(event)) => {
                let name = event.name().as_ref().to_vec();
                if name == b"outline" {
                    let collect = collect_outline(&elements, &outlines);
                    outlines.push(collect.then(|| parse_outline(&event)).transpose()?);
                }
                elements.push(name);
            }
            Ok(Event::Empty(event)) if event.name().as_ref() == b"outline" => {
                if collect_outline(&elements, &outlines) {
                    attach_outline(parse_outline(&event)?, &mut outlines, &mut items);
                }
            }
            Ok(Event::End(event)) => {
                let name = event.name().as_ref().to_vec();
                let open = elements
                    .pop()
                    .ok_or_else(|| EngineError::Parse("unexpected closing element".to_string()))?;
                if open != name {
                    return Err(EngineError::Parse("mismatched closing element".to_string()));
                }
                if name == b"outline" {
                    let item = outlines
                        .pop()
                        .ok_or_else(|| EngineError::Parse("unexpected </outline>".to_string()))?;
                    if let Some(mut item) = item {
                        if item.kind != MenuItemKind::Submenu {
                            item.children.clear();
                        }
                        attach_outline(item, &mut outlines, &mut items);
                    }
                }
            }
            Ok(Event::Eof) => break,
            Ok(_) => {}
            Err(error) => return Err(EngineError::Parse(error.to_string())),
        }
    }

    if !outlines.is_empty() || !elements.is_empty() {
        return Err(EngineError::Parse("unclosed <outline>".to_string()));
    }
    Ok(normalize_menu(&items))
}

fn collect_outline(elements: &[Vec<u8>], outlines: &[Option<MenuItem>]) -> bool {
    elements == [b"opml".as_slice(), b"body".as_slice()]
        || (elements.last().is_some_and(|name| name == b"outline")
            && outlines.last().is_some_and(Option::is_some))
}

fn parse_outline(event: &BytesStart<'_>) -> EngineResult<MenuItem> {
    let mut label = String::new();
    let mut kind_value = None;
    let mut legacy_kind = None;
    let mut page_slug = None;
    let mut category_name = None;
    let mut legacy_slug = None;
    let mut html_url = None;

    for attribute in event.attributes() {
        let attribute = attribute.map_err(|error| EngineError::Parse(error.to_string()))?;
        let value = attribute
            .decoded_and_normalized_value(XmlVersion::Implicit1_0, event.decoder())
            .map_err(|error| EngineError::Parse(error.to_string()))?
            .into_owned();
        match attribute.key.as_ref() {
            b"text" => label = value,
            b"type" => kind_value = Some(value),
            b"kind" => legacy_kind = Some(value),
            b"pageSlug" => page_slug = Some(value),
            b"categoryName" => category_name = Some(value),
            b"slug" => legacy_slug = Some(value),
            b"htmlUrl" => html_url = Some(value),
            _ => {}
        }
    }

    let kind = kind_value
        .or(legacy_kind)
        .as_deref()
        .map(MenuItemKind::from_str)
        .unwrap_or(MenuItemKind::Page);
    let slug = match kind {
        MenuItemKind::Home | MenuItemKind::Submenu => None,
        MenuItemKind::Page => page_slug.or(legacy_slug).or(html_url),
        MenuItemKind::CategoryArchive => category_name.or(legacy_slug).or(html_url),
    }
    .filter(|value| !value.is_empty());

    Ok(MenuItem {
        kind,
        label,
        slug,
        children: Vec::new(),
    })
}

fn attach_outline(item: MenuItem, parents: &mut [Option<MenuItem>], items: &mut Vec<MenuItem>) {
    if let Some(Some(parent)) = parents.last_mut() {
        parent.children.push(item);
    } else {
        items.push(item);
    }
}

/// Serialize menu items to OPML 2.0 format.
fn serialize_opml(project_name: &str, timestamp: i64, items: &[MenuItem]) -> EngineResult<String> {
    let mut writer = Writer::new_with_indent(Vec::new(), b' ', 2);
    writer.config_mut().add_space_before_slash_in_empty_elements = true;
    writer
        .write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))
        .map_err(|error| EngineError::Parse(error.to_string()))?;
    let mut opml = BytesStart::new("opml");
    opml.push_attribute(("version", "2.0"));
    writer
        .write_event(Event::Start(opml))
        .map_err(|error| EngineError::Parse(error.to_string()))?;
    writer
        .write_event(Event::Start(BytesStart::new("head")))
        .map_err(|error| EngineError::Parse(error.to_string()))?;
    write_text_element(&mut writer, "title", project_name)
        .map_err(|error| EngineError::Parse(error.to_string()))?;
    let timestamp = unix_ms_to_iso(timestamp);
    write_text_element(&mut writer, "dateCreated", &timestamp)
        .map_err(|error| EngineError::Parse(error.to_string()))?;
    write_text_element(&mut writer, "dateModified", &timestamp)
        .map_err(|error| EngineError::Parse(error.to_string()))?;
    writer
        .write_event(Event::End(BytesEnd::new("head")))
        .map_err(|error| EngineError::Parse(error.to_string()))?;
    writer
        .write_event(Event::Start(BytesStart::new("body")))
        .map_err(|error| EngineError::Parse(error.to_string()))?;
    for item in items {
        write_outline(&mut writer, item).map_err(|error| EngineError::Parse(error.to_string()))?;
    }
    writer
        .write_event(Event::End(BytesEnd::new("body")))
        .map_err(|error| EngineError::Parse(error.to_string()))?;
    writer
        .write_event(Event::End(BytesEnd::new("opml")))
        .map_err(|error| EngineError::Parse(error.to_string()))?;
    let mut rendered = String::from_utf8(writer.into_inner())
        .map_err(|error| EngineError::Parse(error.to_string()))?;
    rendered.push('\n');
    Ok(rendered)
}

fn write_text_element(
    writer: &mut Writer<Vec<u8>>,
    name: &str,
    value: &str,
) -> quick_xml::Result<()> {
    writer.write_event(Event::Start(BytesStart::new(name)))?;
    writer.write_event(Event::Text(BytesText::new(value)))?;
    writer.write_event(Event::End(BytesEnd::new(name)))?;
    Ok(())
}

fn write_outline(writer: &mut Writer<Vec<u8>>, item: &MenuItem) -> quick_xml::Result<()> {
    let label = escape(&item.label);
    let mut outline = BytesStart::new("outline");
    outline.push_attribute(("text", label.as_ref()));
    outline.push_attribute(("type", item.kind.as_str()));
    match item.kind {
        MenuItemKind::Home => outline.push_attribute(("pageSlug", "home")),
        MenuItemKind::Page => {
            if let Some(slug) = item.slug.as_deref().map(escape) {
                outline.push_attribute(("pageSlug", slug.as_ref()));
            }
        }
        MenuItemKind::CategoryArchive => {
            if let Some(slug) = item.slug.as_deref().map(escape) {
                outline.push_attribute(("categoryName", slug.as_ref()));
            }
        }
        MenuItemKind::Submenu => {}
    }
    if item.children.is_empty() {
        writer.write_event(Event::Empty(outline))?;
    } else {
        writer.write_event(Event::Start(outline))?;
        for child in &item.children {
            write_outline(writer, child)?;
        }
        writer.write_event(Event::End(BytesEnd::new("outline")))?;
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn default_opml_has_home() {
        let opml = default_menu_opml("Test Blog", 1_711_833_600_000);
        assert!(opml.contains("type=\"home\""));
        assert!(opml.contains("text=\"Home\""));
    }

    #[test]
    fn roundtrip_menu() {
        let items = vec![
            MenuItem {
                kind: MenuItemKind::Home,
                label: "Home".into(),
                slug: None,
                children: Vec::new(),
            },
            MenuItem {
                kind: MenuItemKind::Page,
                label: "About".into(),
                slug: Some("/about".into()),
                children: Vec::new(),
            },
            MenuItem {
                kind: MenuItemKind::Submenu,
                label: "Categories".into(),
                slug: None,
                children: vec![MenuItem {
                    kind: MenuItemKind::CategoryArchive,
                    label: "Tech".into(),
                    slug: Some("/category/tech/".into()),
                    children: Vec::new(),
                }],
            },
        ];
        let opml = serialize_opml("Test Blog", 1_711_833_600_000, &items).unwrap();
        let parsed = parse_opml(&opml).unwrap();
        assert_eq!(parsed.len(), 3);
        assert_eq!(parsed[0].kind, MenuItemKind::Home);
        assert_eq!(parsed[1].label, "About");
        assert_eq!(parsed[1].slug.as_deref(), Some("/about"));
        assert_eq!(parsed[2].children.len(), 1);
        assert_eq!(parsed[2].children[0].label, "Tech");
    }

    #[test]
    fn normalize_prepends_home() {
        let items = vec![MenuItem {
            kind: MenuItemKind::Page,
            label: "About".into(),
            slug: Some("/about".into()),
            children: Vec::new(),
        }];
        let normalized = normalize_menu(&items);
        assert_eq!(normalized.len(), 2);
        assert_eq!(normalized[0].kind, MenuItemKind::Home);
        assert_eq!(normalized[1].label, "About");
    }

    #[test]
    fn read_menu_missing_file_returns_home() {
        let dir = tempfile::TempDir::new().unwrap();
        std::fs::create_dir_all(dir.path().join("meta")).unwrap();
        let items = read_menu(dir.path()).unwrap();
        assert_eq!(items.len(), 1);
        assert_eq!(items[0].kind, MenuItemKind::Home);
    }

    #[test]
    fn write_and_read_menu() {
        let dir = tempfile::TempDir::new().unwrap();
        std::fs::create_dir_all(dir.path().join("meta")).unwrap();
        let items = vec![MenuItem {
            kind: MenuItemKind::Page,
            label: "Blog".into(),
            slug: Some("/blog".into()),
            children: Vec::new(),
        }];
        write_menu(
            dir.path(),
            &Project {
                id: "project-1".into(),
                name: "Test Blog".into(),
                slug: "test-blog".into(),
                description: None,
                data_path: None,
                is_active: true,
                created_at: 1_711_833_600_000,
                updated_at: 1_711_833_600_000,
            },
            &items,
        )
        .unwrap();
        let read = read_menu(dir.path()).unwrap();
        // Home is always prepended
        assert_eq!(read.len(), 2);
        assert_eq!(read[0].kind, MenuItemKind::Home);
        assert_eq!(read[1].label, "Blog");
    }

    #[test]
    fn reads_single_quoted_xml_attributes() {
        let parsed = parse_opml(
            "<opml version='2.0'><body><outline text='About' type='page' htmlUrl='/about'/></body></opml>",
        )
        .unwrap();
        assert_eq!(parsed[1].label, "About");
        assert_eq!(parsed[1].slug.as_deref(), Some("/about"));
    }

    #[test]
    fn canonical_bds2_attributes_round_trip_without_legacy_output() {
        let parsed = parse_opml(
            "<opml version='2.0'><body><outline text='Home' type='home' pageSlug='home'/><outline text='Sections' type='submenu'><outline text='About' type='page' pageSlug='about'/><outline text='Notes' type='category-archive' categoryName='notes'/></outline></body></opml>",
        )
        .unwrap();

        assert_eq!(parsed.len(), 2);
        assert_eq!(parsed[1].children[0].slug.as_deref(), Some("about"));
        assert_eq!(parsed[1].children[1].kind, MenuItemKind::CategoryArchive);
        assert_eq!(parsed[1].children[1].slug.as_deref(), Some("notes"));

        let serialized = serialize_opml("Test Blog", 1_711_833_600_000, &parsed).unwrap();
        assert!(serialized.contains("type=\"home\" pageSlug=\"home\""));
        assert!(serialized.contains("type=\"page\" pageSlug=\"about\""));
        assert!(serialized.contains("type=\"category-archive\" categoryName=\"notes\""));
        assert!(!serialized.contains("htmlUrl"));
        assert!(!serialized.contains("category_archive"));
    }

    #[test]
    fn serializer_matches_bds2_head_spacing_and_trailing_newline() {
        let items = vec![MenuItem {
            kind: MenuItemKind::Home,
            label: "Home".into(),
            slug: None,
            children: Vec::new(),
        }];
        let serialized = serialize_opml("Test Blog", 1_711_833_600_000, &items).unwrap();
        assert_eq!(
            serialized,
            concat!(
                "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n",
                "<opml version=\"2.0\">\n",
                "  <head>\n",
                "    <title>Test Blog</title>\n",
                "    <dateCreated>2024-03-30T21:20:00.000Z</dateCreated>\n",
                "    <dateModified>2024-03-30T21:20:00.000Z</dateModified>\n",
                "  </head>\n",
                "  <body>\n",
                "    <outline text=\"Home\" type=\"home\" pageSlug=\"home\" />\n",
                "  </body>\n",
                "</opml>\n",
            )
        );
    }

    #[test]
    fn parser_ignores_foreign_outlines_and_drops_children_of_non_submenus() {
        let parsed = parse_opml(
            "<opml><head><outline text='Head'/></head><body><section><outline text='Foreign'/></section><outline text='Page' type='page' pageSlug='page'><outline text='Dropped' type='page' pageSlug='dropped'/></outline><outline text='Kept' type='submenu'><outline text='Child' type='page' pageSlug='child'/></outline></body></opml>",
        )
        .unwrap();

        assert_eq!(parsed.len(), 3);
        assert_eq!(parsed[1].label, "Page");
        assert!(parsed[1].children.is_empty());
        assert_eq!(parsed[2].label, "Kept");
        assert_eq!(parsed[2].children[0].label, "Child");
    }

    #[test]
    fn normalization_removes_home_entries_at_every_depth() {
        let normalized = normalize_menu(&[
            MenuItem {
                kind: MenuItemKind::Home,
                label: "Duplicate".into(),
                slug: None,
                children: Vec::new(),
            },
            MenuItem {
                kind: MenuItemKind::Submenu,
                label: "Sections".into(),
                slug: None,
                children: vec![MenuItem {
                    kind: MenuItemKind::Home,
                    label: "Nested Home".into(),
                    slug: None,
                    children: Vec::new(),
                }],
            },
        ]);

        assert_eq!(normalized[0].kind, MenuItemKind::Home);
        assert_eq!(normalized[0].label, "Home");
        assert!(normalized[1].children.is_empty());
    }

    #[test]
    fn read_menu_rejects_malformed_xml() {
        let dir = tempfile::TempDir::new().unwrap();
        std::fs::create_dir_all(dir.path().join("meta")).unwrap();
        std::fs::write(
            dir.path().join("meta/menu.opml"),
            "<opml><body><outline></body></opml>",
        )
        .unwrap();

        assert!(read_menu(dir.path()).is_err());
    }
}
