// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license.

use deno_ast::ModuleSpecifier;
use deno_doc::DocParser;
use deno_doc::DocParserOptions;
use deno_doc::ParseOutput;
use deno_doc::diff::DocDiff;
use deno_doc::html::pages::SymbolPage;
use deno_doc::html::*;
use deno_graph::BuildOptions;
use deno_graph::GraphKind;
use deno_graph::ModuleGraph;
use deno_graph::ast::CapturingModuleAnalyzer;
use deno_graph::source::LoadError;
use deno_graph::source::LoadFuture;
use deno_graph::source::LoadOptions;
use deno_graph::source::LoadResponse;
use deno_graph::source::Loader;
use deno_graph::source::MemoryLoader;
use deno_graph::source::Source;
use futures::future;
use indexmap::IndexMap;
use std::fs;
use std::sync::Arc;

struct SourceFileLoader {}

impl Loader for SourceFileLoader {
  fn load(
    &self,
    specifier: &ModuleSpecifier,
    _options: LoadOptions,
  ) -> LoadFuture {
    let result = if specifier.scheme() == "file" {
      let path = specifier.to_file_path().unwrap();
      fs::read(path)
        .map(|content| {
          Some(LoadResponse::Module {
            specifier: specifier.clone(),
            maybe_headers: None,
            mtime: None,
            content: content.into(),
          })
        })
        .map_err(|err| LoadError::Other(Arc::new(err)))
    } else {
      Ok(None)
    };
    Box::pin(future::ready(result))
  }
}

struct EmptyResolver;

impl HrefResolver for EmptyResolver {
  fn resolve_path(
    &self,
    current: UrlResolveKind,
    target: UrlResolveKind,
  ) -> String {
    href_path_resolve(current, target)
  }

  fn resolve_global_symbol(&self, _symbol: &[String]) -> Option<String> {
    None
  }

  fn resolve_import_href(
    &self,
    _symbol: &[String],
    _src: &str,
  ) -> Option<String> {
    None
  }

  fn resolve_source(&self, _location: &deno_doc::Location) -> Option<String> {
    None
  }

  fn resolve_external_jsdoc_module(
    &self,
    _module: &str,
    _symbol: Option<&str>,
  ) -> Option<(String, String)> {
    None
  }
}

impl UsageComposer for EmptyResolver {
  fn is_single_mode(&self) -> bool {
    true
  }

  fn compose(
    &self,
    current_resolve: UrlResolveKind,
    usage_to_md: UsageToMd,
  ) -> IndexMap<UsageComposerEntry, String> {
    current_resolve
      .get_file()
      .map(|current_file| {
        IndexMap::from([(
          UsageComposerEntry {
            name: "".to_string(),
            icon: None,
          },
          usage_to_md(current_file.path.as_str(), None),
        )])
      })
      .unwrap_or_default()
  }
}

async fn get_files(subpath: &str) -> ParseOutput {
  let files = fs::read_dir(
    std::env::current_dir()
      .unwrap()
      .join("tests")
      .join("testdata")
      .join(subpath),
  )
  .unwrap();

  let mut source_files: Vec<ModuleSpecifier> = files
    .into_iter()
    .map(|entry| {
      let entry = entry.unwrap();
      ModuleSpecifier::from_file_path(entry.path()).unwrap()
    })
    .collect();
  source_files.sort();

  let loader = SourceFileLoader {};
  let analyzer = CapturingModuleAnalyzer::default();
  let mut graph = ModuleGraph::new(GraphKind::TypesOnly);
  graph
    .build(
      source_files.clone(),
      Vec::new(),
      &loader,
      BuildOptions {
        module_analyzer: &analyzer,
        ..Default::default()
      },
    )
    .await;

  DocParser::new(
    &graph,
    &analyzer,
    &source_files,
    DocParserOptions {
      diagnostics: false,
      private: false,
    },
  )
  .unwrap()
  .parse()
  .unwrap()
}

#[tokio::test]
async fn html_doc_dts() {
  let ctx = GenerateCtx::create_basic(
    GenerateOptions {
      package_name: None,
      main_entrypoint: None,
      href_resolver: Arc::new(EmptyResolver),
      usage_composer: Some(Arc::new(EmptyResolver)),
      rewrite_map: None,
      category_docs: None,
      disable_search: false,
      symbol_redirect_map: None,
      default_symbol_map: None,
      markdown_renderer: comrak::create_renderer(None, None, None),
      markdown_stripper: Arc::new(comrak::strip),
      head_inject: None,
      id_prefix: None,
      diff_only: false,
    },
    get_files("dts").await,
    None,
  )
  .unwrap();
  let files = generate(ctx).unwrap();

  let mut file_names = files.keys().collect::<Vec<_>>();
  file_names.sort();

  assert_eq!(
    file_names,
    [
      "./all_symbols.html",
      "./index.html",
      "./~/ResponseInit.html",
      "./~/ResponseInit.status.html",
      "./~/ResponseInit.statusText.html",
      "./~/WebSocket.OPEN.html",
      "./~/WebSocket.bufferedAmount.html",
      "./~/WebSocket.html",
      "./~/WebSocket.prototype.html",
      "comrak.css",
      "darkmode_toggle.js",
      "fuse.js",
      "page.css",
      "reset.css",
      "script.js",
      "search.js",
      "search_index.js",
      "styles.css"
    ]
  );

  for file_name in file_names {
    if !file_name.ends_with(".css") {
      insta::assert_snapshot!(files.get(file_name).unwrap());
    }
  }
}

#[tokio::test]
async fn html_doc_files_single() {
  let ctx = GenerateCtx::create_basic(
    GenerateOptions {
      package_name: None,
      main_entrypoint: None,
      href_resolver: Arc::new(EmptyResolver),
      usage_composer: Some(Arc::new(EmptyResolver)),
      rewrite_map: None,
      category_docs: None,
      disable_search: false,
      symbol_redirect_map: None,
      default_symbol_map: None,
      markdown_renderer: comrak::create_renderer(None, None, None),
      markdown_stripper: Arc::new(comrak::strip),
      head_inject: None,
      id_prefix: None,
      diff_only: false,
    },
    get_files(std::env::var("PROBE_DS").as_deref().unwrap_or("single")).await,
    None,
  )
  .unwrap();
  let files = generate(ctx).unwrap();

  let mut file_names = files.keys().collect::<Vec<_>>();
  file_names.sort();

  assert_eq!(
    file_names,
    [
      "./all_symbols.html",
      "./index.html",
      "./~/Bar.html",
      "./~/Bar.prototype.html",
      "./~/Foo.html",
      "./~/Foo.prototype.html",
      "./~/Foobar.html",
      "./~/Foobar.prototype.html",
      "comrak.css",
      "darkmode_toggle.js",
      "fuse.js",
      "page.css",
      "reset.css",
      "script.js",
      "search.js",
      "search_index.js",
      "styles.css",
    ]
  );

  for file_name in file_names {
    if !file_name.ends_with(".css") {
      insta::assert_snapshot!(files.get(file_name).unwrap());
    }
  }
}

#[tokio::test]
async fn html_doc_files_multiple() {
  let multiple_dir = std::env::current_dir()
    .unwrap()
    .join("tests")
    .join("testdata")
    .join("multiple");
  let mut rewrite_map = IndexMap::new();
  let main_specifier =
    ModuleSpecifier::from_file_path(multiple_dir.join("a.ts")).unwrap();
  rewrite_map.insert(main_specifier.clone(), ".".to_string());
  rewrite_map.insert(
    ModuleSpecifier::from_file_path(multiple_dir.join("b.ts")).unwrap(),
    "foo".to_string(),
  );
  rewrite_map.insert(
    ModuleSpecifier::from_file_path(multiple_dir.join("c.ts")).unwrap(),
    "c".to_string(),
  );
  rewrite_map.insert(
    ModuleSpecifier::from_file_path(multiple_dir.join("_d.ts")).unwrap(),
    "d".to_string(),
  );

  let ctx = GenerateCtx::create_basic(
    GenerateOptions {
      package_name: None,
      main_entrypoint: Some(main_specifier),
      href_resolver: Arc::new(EmptyResolver),
      usage_composer: Some(Arc::new(EmptyResolver)),
      rewrite_map: Some(rewrite_map),
      category_docs: None,
      disable_search: false,
      symbol_redirect_map: None,
      default_symbol_map: None,
      markdown_renderer: comrak::create_renderer(None, None, None),
      markdown_stripper: Arc::new(comrak::strip),
      head_inject: None,
      id_prefix: None,
      diff_only: false,
    },
    get_files("multiple").await,
    None,
  )
  .unwrap();
  let files = generate(ctx).unwrap();

  let mut file_names = files.keys().collect::<Vec<_>>();
  file_names.sort();

  assert_eq!(
    file_names,
    [
      "./all_symbols.html",
      "./index.html",
      "./~/A.html",
      "./~/A.prototype.html",
      "./~/AbstractClass.html",
      "./~/AbstractClass.prototype.foo.html",
      "./~/AbstractClass.prototype.getter.html",
      "./~/AbstractClass.prototype.html",
      "./~/AbstractClass.prototype.method.html",
      "./~/B.html",
      "./~/B.prototype.html",
      "./~/Bar.html",
      "./~/Bar.prototype.html",
      "./~/Baz.bar.html",
      "./~/Baz.foo.html",
      "./~/Baz.html",
      "./~/CompoundType.bufferedAmount.html",
      "./~/CompoundType.html",
      "./~/EmptyInterface.html",
      "./~/Enum.html",
      "./~/Enum2.html",
      "./~/Foo.bar.html",
      "./~/Foo.html",
      "./~/Foo.prototype.%22%3E%3Cimg%20src=x%20onerror=alert(1)%3E.html",
      "./~/Foo.prototype.[Symbol.iterator].html",
      "./~/Foo.prototype.foo.html",
      "./~/Foo.prototype.getter.html",
      "./~/Foo.prototype.getterAndSetter.html",
      "./~/Foo.prototype.html",
      "./~/Foo.prototype.methodWithOverloads.html",
      "./~/Foo.prototype.protectedProperty.html",
      "./~/Foo.prototype.readonlyProperty.html",
      "./~/Foo.prototype.setter.html",
      "./~/Foo.prototype.test.html",
      "./~/Foo.staticMethod.html",
      "./~/Foo.staticSetter.html",
      "./~/Foobar.html",
      "./~/Foobar.prototype.html",
      "./~/Hello.ab.html",
      "./~/Hello.computedMethod.html",
      "./~/Hello.html",
      "./~/Hello.optionalMethod.html",
      "./~/Hello.test.html",
      "./~/Hello.world.html",
      "./~/Hello.x.html",
      "./~/InterfaceWithIndexSignature.html",
      "./~/Testing.externalFunction.html",
      "./~/Testing.html",
      "./~/Testing.prototype.html",
      "./~/Testing.t.html",
      "./~/Testing.x.html",
      "./~/TypeAlias.html",
      "./~/anotherVariable.bar.html",
      "./~/anotherVariable.foo.html",
      "./~/anotherVariable.html",
      "./~/c.html",
      "./~/d.html",
      "./~/functionWithOptionalParameters.html",
      "./~/qaz.html",
      "./~/someVariable.html",
      "./~/x.html",
      "c/index.html",
      "c/~/x.html",
      "comrak.css",
      "d/index.html",
      "d/~/externalFunction.html",
      "darkmode_toggle.js",
      "foo/index.html",
      "foo/~/default.html",
      "foo/~/x.html",
      "fuse.js",
      "page.css",
      "reset.css",
      "script.js",
      "search.js",
      "search_index.js",
      "styles.css"
    ]
  );

  for file_name in file_names {
    if !file_name.ends_with(".css") {
      insta::assert_snapshot!(files.get(file_name).unwrap());
    }
  }
}

#[tokio::test]
async fn symbol_group() {
  let multiple_dir = std::env::current_dir()
    .unwrap()
    .join("tests")
    .join("testdata")
    .join("multiple");

  let doc_nodes_by_url = get_files("multiple").await;

  let mut rewrite_map = IndexMap::new();
  rewrite_map.insert(
    ModuleSpecifier::from_file_path(multiple_dir.join("a.ts")).unwrap(),
    ".".to_string(),
  );
  rewrite_map.insert(
    ModuleSpecifier::from_file_path(multiple_dir.join("b.ts")).unwrap(),
    "foo".to_string(),
  );
  rewrite_map.insert(
    ModuleSpecifier::from_file_path(multiple_dir.join("c.ts")).unwrap(),
    "c".to_string(),
  );
  rewrite_map.insert(
    ModuleSpecifier::from_file_path(multiple_dir.join("_d.ts")).unwrap(),
    "d".to_string(),
  );

  let ctx = GenerateCtx::new(
    GenerateOptions {
      package_name: None,
      main_entrypoint: Some(
        ModuleSpecifier::from_file_path(multiple_dir.join("a.ts")).unwrap(),
      ),
      href_resolver: Arc::new(EmptyResolver),
      usage_composer: Some(Arc::new(EmptyResolver)),
      rewrite_map: Some(rewrite_map),
      category_docs: None,
      disable_search: false,
      symbol_redirect_map: None,
      default_symbol_map: None,
      markdown_renderer: comrak::create_renderer(None, None, None),
      markdown_stripper: Arc::new(comrak::strip),
      head_inject: None,
      id_prefix: None,
      diff_only: false,
    },
    None,
    Default::default(),
    doc_nodes_by_url,
    None,
  )
  .unwrap();

  let mut files = vec![];

  {
    for (short_path, doc_nodes) in &ctx.doc_nodes {
      let symbol_pages =
        generate_symbol_pages_for_module(&ctx, short_path, doc_nodes);

      files.extend(symbol_pages.into_iter().map(
        |symbol_page| match symbol_page {
          SymbolPage::Symbol {
            breadcrumbs_ctx,
            symbol_group_ctx,
            toc_ctx,
            categories_panel,
          } => {
            let root = ctx.resolve_path(
              UrlResolveKind::Symbol {
                file: short_path,
                symbol: &symbol_group_ctx.name,
              },
              UrlResolveKind::Root,
            );

            let html_head_ctx = pages::HtmlHeadCtx::new(
              &ctx,
              &root,
              Some(&symbol_group_ctx.name),
              Some(short_path),
            );

            Some(pages::SymbolPageCtx {
              html_head_ctx,
              symbol_group_ctx,
              breadcrumbs_ctx,
              toc_ctx,
              disable_search: false,
              categories_panel,
            })
          }
          SymbolPage::Redirect { .. } => None,
        },
      ));
    }
  }

  insta::assert_json_snapshot!(files);
}

#[tokio::test]
async fn symbol_search() {
  let multiple_dir = std::env::current_dir()
    .unwrap()
    .join("tests")
    .join("testdata")
    .join("multiple");

  let doc_nodes_by_url = get_files("multiple").await;

  let mut rewrite_map = IndexMap::new();
  rewrite_map.insert(
    ModuleSpecifier::from_file_path(multiple_dir.join("a.ts")).unwrap(),
    ".".to_string(),
  );
  rewrite_map.insert(
    ModuleSpecifier::from_file_path(multiple_dir.join("b.ts")).unwrap(),
    "foo".to_string(),
  );
  rewrite_map.insert(
    ModuleSpecifier::from_file_path(multiple_dir.join("c.ts")).unwrap(),
    "c".to_string(),
  );
  rewrite_map.insert(
    ModuleSpecifier::from_file_path(multiple_dir.join("_d.ts")).unwrap(),
    "_d".to_string(),
  );

  let ctx = GenerateCtx::new(
    GenerateOptions {
      package_name: None,
      main_entrypoint: Some(
        ModuleSpecifier::from_file_path(multiple_dir.join("a.ts")).unwrap(),
      ),
      href_resolver: Arc::new(EmptyResolver),
      usage_composer: Some(Arc::new(EmptyResolver)),
      rewrite_map: Some(rewrite_map),
      category_docs: None,
      disable_search: false,
      symbol_redirect_map: None,
      default_symbol_map: None,
      markdown_renderer: comrak::create_renderer(None, None, None),
      markdown_stripper: Arc::new(comrak::strip),
      head_inject: None,
      id_prefix: None,
      diff_only: false,
    },
    None,
    Default::default(),
    doc_nodes_by_url,
    None,
  )
  .unwrap();

  let search_index = generate_search_index(&ctx);

  insta::assert_json_snapshot!(search_index);
}

#[tokio::test]
async fn module_doc() {
  let multiple_dir = std::env::current_dir()
    .unwrap()
    .join("tests")
    .join("testdata")
    .join("multiple");

  let doc_nodes_by_url = get_files("multiple").await;

  let mut rewrite_map = IndexMap::new();
  rewrite_map.insert(
    ModuleSpecifier::from_file_path(multiple_dir.join("a.ts")).unwrap(),
    ".".to_string(),
  );
  rewrite_map.insert(
    ModuleSpecifier::from_file_path(multiple_dir.join("b.ts")).unwrap(),
    "foo".to_string(),
  );
  rewrite_map.insert(
    ModuleSpecifier::from_file_path(multiple_dir.join("c.ts")).unwrap(),
    "c".to_string(),
  );
  rewrite_map.insert(
    ModuleSpecifier::from_file_path(multiple_dir.join("_d.ts")).unwrap(),
    "d".to_string(),
  );

  let ctx = GenerateCtx::new(
    GenerateOptions {
      package_name: None,
      main_entrypoint: Some(
        ModuleSpecifier::from_file_path(multiple_dir.join("a.ts")).unwrap(),
      ),
      href_resolver: Arc::new(EmptyResolver),
      usage_composer: Some(Arc::new(EmptyResolver)),
      rewrite_map: Some(rewrite_map),
      category_docs: None,
      disable_search: false,
      symbol_redirect_map: None,
      default_symbol_map: None,
      markdown_renderer: comrak::create_renderer(None, None, None),
      markdown_stripper: Arc::new(comrak::strip),
      head_inject: None,
      id_prefix: None,
      diff_only: false,
    },
    None,
    FileMode::Single,
    doc_nodes_by_url,
    None,
  )
  .unwrap();

  let mut module_docs = vec![];

  for (short_path, doc_nodes) in &ctx.doc_nodes {
    let render_ctx = RenderContext::new(
      &ctx,
      doc_nodes,
      UrlResolveKind::File { file: short_path },
    );
    let module_doc = jsdoc::ModuleDocCtx::new(
      &render_ctx,
      short_path,
      !short_path.is_main,
      false,
    );

    module_docs.push(module_doc);
  }

  insta::assert_json_snapshot!(module_docs);
}

async fn parse_source(source: &str) -> ParseOutput {
  let specifier = ModuleSpecifier::parse("file:///mod.ts").unwrap();
  let mut loader = MemoryLoader::default();
  loader.add_source(
    specifier.clone(),
    Source::Module {
      specifier: specifier.to_string(),
      maybe_headers: None,
      content: source.to_string(),
    },
  );

  let analyzer = CapturingModuleAnalyzer::default();
  let mut graph = ModuleGraph::new(GraphKind::TypesOnly);
  graph
    .build(
      vec![specifier.clone()],
      Vec::new(),
      &loader,
      BuildOptions {
        module_analyzer: &analyzer,
        ..Default::default()
      },
    )
    .await;

  DocParser::new(
    &graph,
    &analyzer,
    &[specifier],
    DocParserOptions {
      private: false,
      diagnostics: false,
    },
  )
  .unwrap()
  .parse()
  .unwrap()
}

async fn parse_file(path: &std::path::Path) -> ParseOutput {
  let content = fs::read_to_string(path).unwrap();
  parse_source(&content).await
}

// Regression test for https://github.com/denoland/deno_doc/issues/724:
// a member whose name comes from a string literal can contain characters that
// are invalid in a file name (Windows reserves `<>:"/\|?*`) or that break a
// URL. The generated page path must be filesystem- and URL-safe, and the link
// to it must use the same encoded form so it still resolves.
#[tokio::test]
async fn html_symbol_name_unsafe_chars_in_path() {
  let source = r#"
export class Foo {
  "a/b<c>": number = 0;
}
"#;

  let ctx = GenerateCtx::create_basic(
    GenerateOptions {
      package_name: None,
      main_entrypoint: None,
      href_resolver: Arc::new(EmptyResolver),
      usage_composer: Some(Arc::new(EmptyResolver)),
      rewrite_map: None,
      category_docs: None,
      disable_search: false,
      symbol_redirect_map: None,
      default_symbol_map: None,
      markdown_renderer: comrak::create_renderer(None, None, None),
      markdown_stripper: Arc::new(comrak::strip),
      head_inject: None,
      id_prefix: None,
      diff_only: false,
    },
    parse_source(source).await,
    None,
  )
  .unwrap();

  let files = generate(ctx).unwrap();

  // No generated file path may contain characters that are reserved on common
  // filesystems or that need escaping in a URL.
  for name in files.keys() {
    assert!(
      !name.contains(['"', '<', '>', '|', '?', '*', '\\', ' ']),
      "generated file path is not filesystem/URL safe: {name}"
    );
  }

  // The member still gets a page, under a percent-encoded name
  // (`a/b<c>` -> `a%2Fb%3Cc%3E`).
  assert!(
    files
      .keys()
      .any(|k| k.ends_with("/~/Foo.prototype.a%2Fb%3Cc%3E.html")),
    "expected a percent-encoded page for the member, got: {:?}",
    files.keys().collect::<Vec<_>>()
  );

  // No rendered page may contain the raw (unencoded) link, which would both
  // 404 and inject markup into the surrounding attribute.
  assert!(
    files.values().all(|content| !content.contains("a/b<c>")),
    "a rendered page contains an unsafe, unencoded symbol link"
  );
}

#[tokio::test]
async fn diff_kind_change() {
  let test_dir = std::env::current_dir()
    .unwrap()
    .join("tests")
    .join("testdata")
    .join("diff_kind_change");

  let old_docs = parse_file(&test_dir.join("old.ts")).await;
  let new_docs = parse_file(&test_dir.join("new.ts")).await;

  let diff = DocDiff::diff(&old_docs, &new_docs);

  let ctx = GenerateCtx::create_basic(
    GenerateOptions {
      package_name: None,
      main_entrypoint: None,
      href_resolver: Arc::new(EmptyResolver),
      usage_composer: Some(Arc::new(EmptyResolver)),
      rewrite_map: None,
      category_docs: None,
      disable_search: false,
      symbol_redirect_map: None,
      default_symbol_map: None,
      markdown_renderer: comrak::create_renderer(None, None, None),
      markdown_stripper: Arc::new(comrak::strip),
      head_inject: None,
      id_prefix: None,
      diff_only: false,
    },
    new_docs,
    Some(diff),
  )
  .unwrap();

  let json_output = generate_json(ctx).unwrap();

  let mut keys: Vec<_> = json_output.keys().collect();
  keys.sort();

  let pages: Vec<_> = keys
    .iter()
    .filter(|k| k.ends_with(".json"))
    .map(|k| (k.to_string(), json_output.get(*k).unwrap().clone()))
    .collect();

  insta::assert_json_snapshot!(pages);
}

#[tokio::test]
async fn diff_comprehensive() {
  let test_dir = std::env::current_dir()
    .unwrap()
    .join("tests")
    .join("testdata")
    .join("diff_comprehensive");

  let old_docs = parse_file(&test_dir.join("old.ts")).await;
  let new_docs = parse_file(&test_dir.join("new.ts")).await;

  let diff = DocDiff::diff(&old_docs, &new_docs);

  // Test with diff_only: false (full output with diff annotations)
  let ctx = GenerateCtx::create_basic(
    GenerateOptions {
      package_name: None,
      main_entrypoint: None,
      href_resolver: Arc::new(EmptyResolver),
      usage_composer: Some(Arc::new(EmptyResolver)),
      rewrite_map: None,
      category_docs: None,
      disable_search: false,
      symbol_redirect_map: None,
      default_symbol_map: None,
      markdown_renderer: comrak::create_renderer(None, None, None),
      markdown_stripper: Arc::new(comrak::strip),
      head_inject: None,
      id_prefix: None,
      diff_only: false,
    },
    new_docs.clone(),
    Some(diff.clone()),
  )
  .unwrap();

  let json_output = generate_json(ctx).unwrap();

  let mut keys: Vec<_> = json_output.keys().collect();
  keys.sort();

  let pages: Vec<_> = keys
    .iter()
    .filter(|k| k.ends_with(".json"))
    .map(|k| (k.to_string(), json_output.get(*k).unwrap().clone()))
    .collect();

  insta::assert_json_snapshot!("diff_comprehensive_full", pages);

  // Test with diff_only: true (only changed content)
  let ctx = GenerateCtx::create_basic(
    GenerateOptions {
      package_name: None,
      main_entrypoint: None,
      href_resolver: Arc::new(EmptyResolver),
      usage_composer: Some(Arc::new(EmptyResolver)),
      rewrite_map: None,
      category_docs: None,
      disable_search: false,
      symbol_redirect_map: None,
      default_symbol_map: None,
      markdown_renderer: comrak::create_renderer(None, None, None),
      markdown_stripper: Arc::new(comrak::strip),
      head_inject: None,
      id_prefix: None,
      diff_only: true,
    },
    new_docs,
    Some(diff),
  )
  .unwrap();

  let json_output = generate_json(ctx).unwrap();

  let mut keys: Vec<_> = json_output.keys().collect();
  keys.sort();

  let pages: Vec<_> = keys
    .iter()
    .filter(|k| k.ends_with(".json"))
    .map(|k| (k.to_string(), json_output.get(*k).unwrap().clone()))
    .collect();

  insta::assert_json_snapshot!("diff_comprehensive_diff_only", pages);
}

/// Verify that README headings in the module doc TOC:
/// 1. Appear before @example entries (matching the rendered page order)
/// 2. Are not inflated to deeper nesting levels by the offset state
#[tokio::test]
async fn readme_toc_order_with_examples() {
  let source = r#"
/**
 * ## Installation
 *
 * Install the library.
 *
 * ## Usage
 *
 * Use the library.
 *
 * ## API Reference
 *
 * The API reference.
 *
 * @example My Example
 * ```ts
 * hello();
 * ```
 *
 * @module
 */

/** A simple function. */
export function hello(): string {
  return "hello";
}
"#;

  let doc_nodes_by_url = parse_source(source).await;

  let specifier = ModuleSpecifier::parse("file:///mod.ts").unwrap();

  let ctx = GenerateCtx::create_basic(
    GenerateOptions {
      package_name: None,
      main_entrypoint: Some(specifier),
      href_resolver: Arc::new(EmptyResolver),
      usage_composer: Some(Arc::new(EmptyResolver)),
      rewrite_map: None,
      category_docs: None,
      disable_search: false,
      symbol_redirect_map: None,
      default_symbol_map: None,
      markdown_renderer: comrak::create_renderer(None, None, None),
      markdown_stripper: Arc::new(comrak::strip),
      head_inject: None,
      id_prefix: None,
      diff_only: false,
    },
    doc_nodes_by_url,
    None,
  )
  .unwrap();

  let files = generate(ctx).unwrap();
  let index_html = files.get("./index.html").unwrap();

  // README headings should appear before the Examples section in the TOC,
  // matching the page layout where the markdown body comes before @example sections.
  let readme_heading_pos = index_html
    .find("title=\"Installation\"")
    .expect("Installation heading not found in TOC");
  let examples_pos = index_html
    .find("title=\"Examples\"")
    .expect("Examples heading not found in TOC");

  assert!(
    readme_heading_pos < examples_pos,
    "README headings should appear before Examples in the TOC"
  );

  // Verify README headings are in document order
  let headings = ["Installation", "Usage", "API Reference"];
  let positions: Vec<usize> = headings
    .iter()
    .map(|h| {
      index_html
        .find(&format!("title=\"{}\"", h))
        .unwrap_or_else(|| panic!("heading '{}' not found in TOC", h))
    })
    .collect();

  for window in positions.windows(2) {
    assert!(
      window[0] < window[1],
      "TOC headings are not in document order"
    );
  }

  // Verify heading levels aren't inflated: README h2 headings should NOT be
  // nested deeper than the Examples section (level 1). If the offset leaked,
  // they'd be at level 4 and appear as deeply nested sub-items.
  // In the correct output, README headings at level 2 nest directly under the
  // top-level list, not under a third-level nested list.
  let nav_start = index_html.find("documentNavigation").unwrap();
  let nav_section = &index_html[nav_start..];
  let nav_end = nav_section.find("</nav>").unwrap();
  let nav_html = &nav_section[..nav_end];

  // Count nesting depth of the first README heading (Installation).
  // It should be in at most one <ul> nesting (the root <ul> + one sub-<ul>
  // for level 2), not two or more sub-<ul>s which would indicate inflated levels.
  let before_installation = &nav_html[..nav_html.find("Installation").unwrap()];
  let ul_depth = before_installation.matches("<ul>").count();
  assert!(
    ul_depth <= 2,
    "README headings are nested too deeply (depth {}), offset likely leaked from Examples",
    ul_depth
  );
}

// Parse every generated HTML file with a real HTML5 parser and assert it has
// no parse errors (missing closing tags, mismatched/invalid markup, etc.).
// See issue #634.
fn assert_generated_html_is_valid(
  files: &std::collections::HashMap<String, String>,
) {
  use html5ever::parse_document;
  use html5ever::tendril::TendrilSink;
  use markup5ever_rcdom::RcDom;

  let mut names: Vec<_> = files.keys().collect();
  names.sort();

  for name in names {
    if !name.ends_with(".html") {
      continue;
    }
    let content = &files[name];
    let dom = parse_document(RcDom::default(), Default::default())
      .from_utf8()
      .read_from(&mut content.as_bytes())
      .unwrap();
    assert!(
      dom.errors.is_empty(),
      "generated HTML for {name} is not valid: {:?}",
      dom.errors
    );
  }
}

#[tokio::test]
async fn html_output_is_valid() {
  // Validate the "multiple" fixture: it exercises the widest range of output
  // (classes, interfaces, enums, type aliases, namespaces, drilldown member
  // pages, redirects, and the all-symbols/index pages).
  let multiple_dir = std::env::current_dir()
    .unwrap()
    .join("tests")
    .join("testdata")
    .join("multiple");
  let mut rewrite_map = IndexMap::new();
  rewrite_map.insert(
    ModuleSpecifier::from_file_path(multiple_dir.join("a.ts")).unwrap(),
    ".".to_string(),
  );
  rewrite_map.insert(
    ModuleSpecifier::from_file_path(multiple_dir.join("b.ts")).unwrap(),
    "foo".to_string(),
  );
  rewrite_map.insert(
    ModuleSpecifier::from_file_path(multiple_dir.join("c.ts")).unwrap(),
    "c".to_string(),
  );
  rewrite_map.insert(
    ModuleSpecifier::from_file_path(multiple_dir.join("_d.ts")).unwrap(),
    "d".to_string(),
  );

  let ctx = GenerateCtx::create_basic(
    GenerateOptions {
      package_name: None,
      main_entrypoint: Some(
        ModuleSpecifier::from_file_path(multiple_dir.join("a.ts")).unwrap(),
      ),
      href_resolver: Arc::new(EmptyResolver),
      usage_composer: Some(Arc::new(EmptyResolver)),
      rewrite_map: Some(rewrite_map),
      category_docs: None,
      disable_search: false,
      symbol_redirect_map: None,
      default_symbol_map: None,
      markdown_renderer: comrak::create_renderer(None, None, None),
      markdown_stripper: Arc::new(comrak::strip),
      head_inject: None,
      id_prefix: None,
      diff_only: false,
    },
    get_files("multiple").await,
    None,
  )
  .unwrap();
  let files = generate(ctx).unwrap();

  assert_generated_html_is_valid(&files);
}
