// Copyright 2018-2026 the Deno authors. MIT license.

use std::collections::HashSet;
use std::sync::Arc;

use deno_ast::ParsedSource;
use deno_ast::SourceRangedForSpanned;
use deno_ast::SourceTextInfo;
use deno_ast::swc::common::comments::CommentKind;
use deno_core::error::AnyError;
use deno_core::url::Url;
use deno_graph::ModuleEntryRef;
use deno_graph::ModuleGraph;
use deno_graph::ResolutionResolved;
use deno_graph::WalkOptions;
use deno_resolver::cache::ParsedSourceCache;
use deno_semver::jsr::JsrPackageReqReference;
use deno_semver::npm::NpmPackageReqReference;

use super::diagnostics::PublishDiagnostic;
use super::diagnostics::PublishDiagnosticsCollector;
use crate::npm::CliNpmResolver;

pub struct GraphDiagnosticsCollector {
  npm_resolver: CliNpmResolver,
  parsed_source_cache: Arc<ParsedSourceCache>,
}

impl GraphDiagnosticsCollector {
  pub fn new(
    npm_resolver: CliNpmResolver,
    parsed_source_cache: Arc<ParsedSourceCache>,
  ) -> Self {
    Self {
      npm_resolver,
      parsed_source_cache,
    }
  }

  pub fn collect_diagnostics_for_graph(
    &self,
    graph: &ModuleGraph,
    diagnostics_collector: &PublishDiagnosticsCollector,
  ) -> Result<(), AnyError> {
    let mut visited = HashSet::new();
    let mut skip_specifiers: HashSet<Url> = HashSet::new();

    let mut collect_if_invalid =
      |skip_specifiers: &mut HashSet<Url>,
       source_text: &Arc<str>,
       specifier_text: &str,
       resolution: &ResolutionResolved| {
        if visited.insert(resolution.specifier.clone()) {
          match resolution.specifier.scheme() {
            "file" | "data" | "node" | "bun" | "virtual" | "cloudflare" => {}
            "jsr" => {
              skip_specifiers.insert(resolution.specifier.clone());

              // check for a missing version constraint
              if let Ok(jsr_req_ref) =
                JsrPackageReqReference::from_specifier(&resolution.specifier)
                && jsr_req_ref.req().version_req.version_text() == "*"
              {
                let maybe_version = graph
                  .packages
                  .mappings()
                  .get(jsr_req_ref.req())
                  .map(|nv| nv.version.clone());
                diagnostics_collector.push(
                  PublishDiagnostic::MissingConstraint {
                    specifier: resolution.specifier.clone(),
                    specifier_text: specifier_text.to_string(),
                    resolved_version: maybe_version,
                    text_info: SourceTextInfo::new(source_text.clone()),
                    referrer: resolution.range.clone(),
                  },
                );
              }
            }
            "npm" => {
              skip_specifiers.insert(resolution.specifier.clone());

              // check for a missing version constraint
              if let Ok(npm_req_ref) =
                NpmPackageReqReference::from_specifier(&resolution.specifier)
                && npm_req_ref.req().version_req.version_text() == "*"
              {
                let maybe_version =
                  self.npm_resolver.as_managed().and_then(|managed| {
                    managed
                      .resolve_pkg_id_from_deno_module_req(npm_req_ref.req())
                      .ok()
                      .map(|id| id.nv.version.clone())
                  });
                diagnostics_collector.push(
                  PublishDiagnostic::MissingConstraint {
                    specifier: resolution.specifier.clone(),
                    specifier_text: specifier_text.to_string(),
                    resolved_version: maybe_version,
                    text_info: SourceTextInfo::new(source_text.clone()),
                    referrer: resolution.range.clone(),
                  },
                );
              }
            }
            "http" | "https" => {
              skip_specifiers.insert(resolution.specifier.clone());
              diagnostics_collector.push(
                PublishDiagnostic::InvalidExternalImport {
                  kind: format!("non-JSR '{}'", resolution.specifier.scheme()),
                  text_info: SourceTextInfo::new(source_text.clone()),
                  imported: resolution.specifier.clone(),
                  referrer: resolution.range.clone(),
                },
              );
            }
            _ => {
              skip_specifiers.insert(resolution.specifier.clone());
              diagnostics_collector.push(
                PublishDiagnostic::InvalidExternalImport {
                  kind: format!("'{}'", resolution.specifier.scheme()),
                  text_info: SourceTextInfo::new(source_text.clone()),
                  imported: resolution.specifier.clone(),
                  referrer: resolution.range.clone(),
                },
              );
            }
          }
        }
      };

    let options = WalkOptions {
      check_js: deno_graph::CheckJsOption::True,
      follow_dynamic: true,
      // search the entire graph and not just the fast check subset
      prefer_fast_check_graph: false,
      kind: deno_graph::GraphKind::All,
    };
    let mut iter = graph.walk(graph.roots.iter(), options);
    while let Some((specifier, entry)) = iter.next() {
      if skip_specifiers.contains(specifier) {
        iter.skip_previous_dependencies();
        continue;
      }

      let ModuleEntryRef::Module(module) = entry else {
        continue;
      };
      let Some(module) = module.js() else {
        continue;
      };

      let parsed_source = self
        .parsed_source_cache
        .get_parsed_source_from_js_module(module)?;

      // surface syntax errors
      for diagnostic in parsed_source.diagnostics() {
        diagnostics_collector
          .push(PublishDiagnostic::SyntaxError(diagnostic.clone()));
      }

      check_for_banned_triple_slash_directives(
        &parsed_source,
        diagnostics_collector,
      );

      for (specifier_text, dep) in &module.dependencies {
        // text imports are stable, but bytes imports are not yet
        for bytes_import in dep
          .imports
          .iter()
          .filter(|i| i.attributes.get("type") == Some("bytes"))
        {
          diagnostics_collector.push(PublishDiagnostic::UnstableRawImport {
            text_info: parsed_source.text_info_lazy().clone(),
            referrer: bytes_import.specifier_range.clone(),
          });
        }

        if let Some(resolved) = dep.maybe_code.ok() {
          collect_if_invalid(
            &mut skip_specifiers,
            &module.source.text,
            specifier_text,
            resolved,
          );
        }
        if let Some(resolved) = dep.maybe_type.ok() {
          collect_if_invalid(
            &mut skip_specifiers,
            &module.source.text,
            specifier_text,
            resolved,
          );
        }
      }
    }

    Ok(())
  }
}

fn check_for_banned_triple_slash_directives(
  parsed_source: &ParsedSource,
  diagnostics_collector: &PublishDiagnosticsCollector,
) {
  let triple_slash_re = lazy_regex::regex!(
    r#"^/\s+<reference\s+(no-default-lib\s*=\s*"true"|lib\s*=\s*("[^"]+"|'[^']+'))\s*/>\s*$"#
  );

  // Scan all comments, not just the ones leading the first statement. The
  // registry rejects these directives wherever they appear in the file, so a
  // directive placed after other code (e.g. after an import) must also be
  // reported during a dry run instead of only failing at publish time.
  for comment in parsed_source.comments().get_vec() {
    if comment.kind != CommentKind::Line {
      continue;
    }
    if triple_slash_re.is_match(&comment.text) {
      diagnostics_collector.push(
        PublishDiagnostic::BannedTripleSlashDirectives {
          specifier: parsed_source.specifier().clone(),
          range: comment.range(),
          text_info: parsed_source.text_info_lazy().clone(),
        },
      );
    }
  }
}
