use std::collections::BTreeMap;
use std::fs;
use std::path::Path;

use crate::epub::write_epub;
use crate::error::{EbookmError, Result};
use crate::extract::{InspectResult, inspect_article};
use crate::graph::{EntryLinkMetadata, build_link_policies};
use crate::manifest::{Manifest, SourceDefinition};
use crate::normalize::{Asset, NormalizedDocument};
use crate::source::{SourceSpec, load_source};
use crate::template::INIT_MANIFEST;

#[derive(Debug, Clone)]
pub struct BuiltChapter {
    pub nav_title: String,
    pub xhtml: String,
}

#[derive(Debug, Clone, Copy)]
struct ChapterHeaderOptions {
    include_author: bool,
    include_date: bool,
    include_source_url: bool,
}

#[derive(Debug, Clone)]
pub struct BuiltEntry {
    pub id: String,
    pub hidden_from_toc: bool,
    pub section_anchor: Option<String>,
    pub chapter: BuiltChapter,
    pub assets: Vec<Asset>,
}

pub fn load_manifest(path: &Path) -> Result<Manifest> {
    let contents = fs::read_to_string(path).map_err(|source| EbookmError::Io {
        path: path.display().to_string(),
        source,
    })?;
    serde_yaml::from_str(&contents).map_err(|source| EbookmError::ManifestParse {
        path: path.display().to_string(),
        source,
    })
}

pub fn validate_manifest(manifest: &Manifest) -> Result<Vec<String>> {
    let mut issues = Vec::new();
    let mut warnings = Vec::new();

    if manifest.book.title.trim().is_empty() {
        issues.push("book.title must not be empty".to_string());
    }
    if manifest.output.path.trim().is_empty() {
        issues.push("output.path must not be empty".to_string());
    }
    if manifest.sections.is_empty() {
        issues.push("at least one section is required".to_string());
    }
    if manifest.entries.is_empty() {
        issues.push("at least one entry is required".to_string());
    }

    for section in &manifest.sections {
        if section.entries.is_empty() {
            warnings.push(format!("section {} has no entries", section.id));
        }
        for entry_id in &section.entries {
            if !manifest.entries.contains_key(entry_id) {
                issues.push(format!(
                    "section {} references unknown entry {}",
                    section.id, entry_id
                ));
            }
        }
    }

    for (entry_id, entry) in &manifest.entries {
        for target in &entry.links.allow_to {
            if !manifest.entries.contains_key(target) {
                issues.push(format!(
                    "entry {entry_id} allow_to target {target} does not exist"
                ));
            }
        }
        for target in &entry.links.block_to {
            if !manifest.entries.contains_key(target) {
                issues.push(format!(
                    "entry {entry_id} block_to target {target} does not exist"
                ));
            }
        }
    }

    for rule in &manifest.link_rules.rules {
        validate_selectors(manifest, &rule.from, "from", &mut issues);
        validate_selectors(manifest, &rule.to, "to", &mut issues);
    }

    for entry_id in manifest.entries.keys() {
        if !manifest.sections.iter().any(|section| {
            section
                .entries
                .iter()
                .any(|candidate| candidate == entry_id)
        }) {
            warnings.push(format!("entry {entry_id} is not referenced by any section"));
        }
    }

    if issues.is_empty() {
        Ok(warnings)
    } else {
        Err(EbookmError::Validation { issues })
    }
}

pub fn inspect_source(source: &str) -> Result<InspectResult> {
    let spec = if source.starts_with("http://") || source.starts_with("https://") {
        SourceSpec::from_definition(
            &SourceDefinition::Substack {
                url: source.to_string(),
            },
            Path::new("."),
        )?
    } else {
        SourceSpec::from_definition(
            &SourceDefinition::Html {
                path: source.to_string(),
            },
            Path::new("."),
        )?
    };
    let loaded = load_source(&spec)?;
    inspect_article(&loaded)
}

pub fn build_epub(manifest: &Manifest, manifest_path: &Path) -> Result<()> {
    let manifest_dir = manifest_path.parent().unwrap_or_else(|| Path::new("."));

    let mut entry_specs = BTreeMap::new();
    let mut loaded_sources = BTreeMap::new();
    let mut extracted = BTreeMap::new();
    let mut metadata = BTreeMap::new();

    for (entry_id, entry) in &manifest.entries {
        let spec = SourceSpec::from_definition(&entry.source, manifest_dir)?;
        let loaded = load_source(&spec)?;
        let article = crate::extract::extract_article(&loaded)?;
        let source_url = match &spec {
            SourceSpec::SubstackUrl(url) => Some(url.clone()),
            SourceSpec::LocalHtml(_) => None,
        };

        metadata.insert(
            entry_id.clone(),
            EntryLinkMetadata {
                source_url,
                canonical_url: article.canonical_url.clone(),
            },
        );
        entry_specs.insert(entry_id.clone(), spec);
        loaded_sources.insert(entry_id.clone(), loaded);
        extracted.insert(entry_id.clone(), article);
    }

    let policies = build_link_policies(manifest, &metadata);
    let mut built_entries = Vec::new();

    for section in &manifest.sections {
        for entry_id in &section.entries {
            let entry = &manifest.entries[entry_id];
            let loaded = loaded_sources.get(entry_id).expect("entry was loaded");
            let article = extracted
                .get(entry_id)
                .expect("entry was extracted")
                .clone();
            let policy = policies.get(entry_id).expect("policy was built");

            let normalized = crate::normalize::normalize_document(
                entry_id,
                entry,
                &manifest.defaults,
                &loaded.origin,
                article,
                policy,
                &metadata,
            )?;

            let section_anchor = section
                .entries
                .first()
                .filter(|first_entry_id| *first_entry_id == entry_id)
                .map(|_| format!("section-{}", section.id));

            built_entries.push(BuiltEntry {
                id: entry_id.clone(),
                hidden_from_toc: entry.toc.hidden,
                section_anchor: section_anchor.clone(),
                chapter: build_chapter(
                    entry_id,
                    entry,
                    &manifest.defaults,
                    &normalized,
                    section_anchor.as_deref(),
                ),
                assets: normalized.assets,
            });
        }
    }

    let cover = manifest
        .output
        .cover_image
        .as_ref()
        .map(|path| load_cover(path, manifest_dir))
        .transpose()?;
    let output_path = manifest_dir.join(&manifest.output.path);
    write_epub(manifest, &built_entries, &output_path, cover)?;
    Ok(())
}

pub fn render_init_manifest() -> &'static str {
    INIT_MANIFEST
}

fn build_chapter(
    entry_id: &str,
    entry: &crate::manifest::EntryDefinition,
    defaults: &crate::manifest::DefaultsConfig,
    doc: &NormalizedDocument,
    section_anchor: Option<&str>,
) -> BuiltChapter {
    let nav_title = entry.toc.title.clone().unwrap_or_else(|| doc.title.clone());
    let header = resolve_header_options(entry, defaults);
    let author = doc.author.clone().unwrap_or_default();
    let published = doc
        .published
        .map(|date| date.to_string())
        .unwrap_or_default();
    let mut meta_lines = Vec::new();
    if header.include_author && !author.is_empty() {
        meta_lines.push(format!("<p>{}</p>", escape_html(&author)));
    }
    if header.include_date && !published.is_empty() {
        meta_lines.push(format!("<p>{}</p>", escape_html(&published)));
    }
    if header.include_source_url {
        if let Some(url) = doc.canonical_url.as_ref() {
            let escaped = escape_html(url.as_str());
            meta_lines.push(format!(r#"<p><a href="{0}">{0}</a></p>"#, escaped));
        }
    }

    let meta_block = if meta_lines.is_empty() {
        String::new()
    } else {
        format!(r#"<div class="chapter-meta">{}</div>"#, meta_lines.join(""))
    };

    let section_anchor_markup = section_anchor
        .map(|anchor| format!(r#"<div id="{}"></div>"#, escape_html(anchor)))
        .unwrap_or_default();

    let xhtml = format!(
        r#"<?xml version="1.0" encoding="UTF-8"?>
<html xmlns="http://www.w3.org/1999/xhtml">
  <head>
    <title>{}</title>
    <link rel="stylesheet" type="text/css" href="../styles/book.css"/>
  </head>
  <body id="{}">
    {}
    <h1>{}</h1>
    {}
    {}
  </body>
</html>"#,
        escape_html(&doc.title),
        escape_html(entry_id),
        section_anchor_markup,
        escape_html(&doc.title),
        meta_block,
        doc.body_xhtml
    );

    BuiltChapter { nav_title, xhtml }
}

fn validate_selectors(
    manifest: &Manifest,
    selectors: &[String],
    field: &str,
    issues: &mut Vec<String>,
) {
    for selector in selectors {
        if selector == "*" {
            continue;
        }
        if manifest.entries.contains_key(selector) {
            continue;
        }
        if let Some(section_id) = selector.strip_prefix("section:") {
            if manifest
                .sections
                .iter()
                .any(|section| section.id == section_id)
            {
                continue;
            }
        }
        issues.push(format!("unknown {field} selector {selector}"));
    }
}

fn load_cover(path: &str, manifest_dir: &Path) -> Result<(String, Vec<u8>)> {
    let full_path = manifest_dir.join(path);
    let bytes = fs::read(&full_path).map_err(|source| EbookmError::Io {
        path: full_path.display().to_string(),
        source,
    })?;
    let extension = full_path
        .extension()
        .and_then(|value| value.to_str())
        .unwrap_or("jpg");
    Ok((format!("assets/cover.{extension}"), bytes))
}

fn escape_html(value: &str) -> String {
    value
        .replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
}

fn resolve_header_options(
    entry: &crate::manifest::EntryDefinition,
    defaults: &crate::manifest::DefaultsConfig,
) -> ChapterHeaderOptions {
    ChapterHeaderOptions {
        include_author: entry
            .processing
            .include_author
            .unwrap_or(defaults.processing.include_author),
        include_date: entry
            .processing
            .include_date
            .unwrap_or(defaults.processing.include_date),
        include_source_url: entry
            .processing
            .include_source_url
            .unwrap_or(defaults.processing.include_source_url),
    }
}

#[cfg(test)]
mod tests {
    use std::fs;

    use tempfile::tempdir;
    use zip::ZipArchive;

    use super::*;

    #[test]
    fn validates_and_builds_local_html_manifest() {
        let temp = tempdir().expect("tempdir");
        let root = temp.path();

        fs::write(
            root.join("article.html"),
            r#"<!doctype html>
<html>
  <head>
    <title>Local Essay</title>
    <meta name="author" content="Local Author" />
    <meta property="article:published_time" content="2025-01-10T00:00:00Z" />
  </head>
  <body>
    <article>
      <p>Hello world.</p>
      <img src="author.jpg" alt="Author" />
    </article>
  </body>
</html>"#,
        )
        .expect("write html");
        fs::write(root.join("author.jpg"), b"fake-jpeg-data").expect("write image");

        let manifest_path = root.join("book.yaml");
        fs::write(
            &manifest_path,
            r#"book:
  title: "Local Book"
  author: "Editor"
  language: "en"
  identifier: "urn:uuid:test-book"
output:
  path: "dist/test.epub"
defaults:
  fetch_images: true
  normalize_substack_embeds: true
  processing:
    include_author: true
    include_date: false
    include_source_url: false
    skip_first_paragraphs: 0
sections:
  - id: "part-1"
    title: "Part 1"
    entries:
      - "essay"
entries:
  essay:
    source:
      kind: "html"
      path: "article.html"
link_rules:
  mode: "auto"
"#,
        )
        .expect("write manifest");

        let manifest = load_manifest(&manifest_path).expect("manifest");
        validate_manifest(&manifest).expect("manifest valid");
        build_epub(&manifest, &manifest_path).expect("build epub");

        let epub_path = root.join("dist/test.epub");
        assert!(epub_path.exists());

        let file = fs::File::open(&epub_path).expect("epub file");
        let mut archive = ZipArchive::new(file).expect("zip");
        assert!(archive.by_name("mimetype").is_ok());
        assert!(archive.by_name("OEBPS/content.opf").is_ok());
        let mut chapter = archive
            .by_name("OEBPS/text/essay.xhtml")
            .expect("chapter file");
        let mut chapter_contents = String::new();
        use std::io::Read;
        chapter
            .read_to_string(&mut chapter_contents)
            .expect("read chapter");
        assert!(chapter_contents.contains("<p>Local Author</p>"));
        assert!(!chapter_contents.contains("<p>2025-01-10</p>"));
        assert!(!chapter_contents.contains("urn:uuid:test-book"));
        assert!(chapter_contents.contains("../assets/"));
        drop(chapter);
        assert!(
            archive
                .file_names()
                .any(|name| name.starts_with("OEBPS/assets/") && name.ends_with(".jpg"))
        );
    }
}
