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

use std::collections::BTreeMap;
use std::collections::HashSet;

use deno_config::deno_json::ConfigFile;
use deno_core::error::AnyError;
use deno_core::serde_json;
use deno_core::serde_json::json;
use serde::Serialize;

use super::ProcessedFile;
use super::extensions::ts_to_dts_extension;
use super::extensions::ts_to_js_extension;

/// Build the lookup of source paths (as written in `deno.json` exports,
/// normalized) for which fast-check generated a usable `.d.ts`. We need
/// this so the `types` field is only emitted for entries whose declarations
/// actually exist in the tarball; otherwise TypeScript would resolve a
/// missing or empty stub and conclude the module exports nothing.
fn dts_available_set(files: &[ProcessedFile]) -> HashSet<String> {
  files
    .iter()
    .filter(|f| f.dts_content.is_some())
    .map(|f| f.output_path.clone())
    .collect()
}

/// Returns true if the deno.json export source path (e.g. "./mod.ts") has
/// a corresponding generated .d.ts in the tarball.
fn has_dts(set: &HashSet<String>, source_path: &str) -> bool {
  set.contains(&ts_to_js_extension(source_path))
}

#[derive(Serialize)]
struct PackageJson {
  name: String,
  version: String,
  #[serde(rename = "type")]
  module_type: String,
  #[serde(skip_serializing_if = "Option::is_none")]
  license: Option<String>,
  #[serde(skip_serializing_if = "Option::is_none")]
  description: Option<String>,
  #[serde(skip_serializing_if = "Option::is_none")]
  main: Option<String>,
  #[serde(skip_serializing_if = "Option::is_none")]
  types: Option<String>,
  #[serde(skip_serializing_if = "Option::is_none")]
  exports: Option<serde_json::Value>,
  // BTreeMap (not HashMap): serde_json walks BTreeMap in sorted key
  // order, so the generated package.json — and therefore the tarball
  // bytes — are identical across runs even with multiple deps. See the
  // `reproducible` spec test.
  #[serde(skip_serializing_if = "Option::is_none")]
  dependencies: Option<BTreeMap<String, String>>,
}

pub fn generate_package_json(
  config_file: &ConfigFile,
  version: &str,
  files: &[ProcessedFile],
) -> Result<String, AnyError> {
  let name = config_file
    .json
    .name
    .as_ref()
    .ok_or_else(|| deno_core::anyhow::anyhow!("Missing name in config"))?;

  let dts_set = dts_available_set(files);

  let exports = convert_exports(&config_file.json.exports, &dts_set)?;
  let (main, types) =
    extract_main_and_types(&config_file.json.exports, &dts_set);

  // Collect dependencies from all files. BTreeMap so the serialized
  // package.json keys come out in sorted order (reproducibility).
  let mut dependencies: BTreeMap<String, String> = BTreeMap::new();

  // Merge dependencies from all processed files
  for file in files {
    for (name, version) in &file.dependencies {
      dependencies.insert(name.clone(), version.clone());
    }
  }

  let license = config_file
    .json
    .license
    .as_ref()
    .and_then(|l| l.as_str().map(|s| s.to_string()));

  let pkg = PackageJson {
    name: name.clone(),
    version: version.to_string(),
    module_type: "module".to_string(),
    license,
    description: None,
    main,
    types,
    exports: Some(exports),
    dependencies: if dependencies.is_empty() {
      None
    } else {
      Some(dependencies)
    },
  };

  let json = serde_json::to_string_pretty(&pkg)?;
  Ok(json)
}

fn convert_exports(
  exports: &Option<serde_json::Value>,
  dts_set: &HashSet<String>,
) -> Result<serde_json::Value, AnyError> {
  let Some(exports) = exports else {
    return Ok(json!("./mod.js"));
  };

  if let Some(s) = exports.as_str() {
    let js_path = ts_to_js_extension(s);
    let mut entry = serde_json::Map::new();
    if has_dts(dts_set, s) {
      entry.insert(
        "types".to_string(),
        json!(format!("./{}", ts_to_dts_extension(s))),
      );
    }
    entry.insert("import".to_string(), json!(format!("./{}", js_path)));
    entry.insert("default".to_string(), json!(format!("./{}", js_path)));
    return Ok(json!({ ".": entry }));
  }

  if let Some(map) = exports.as_object() {
    let mut result = serde_json::Map::new();

    for (key, value) in map.iter() {
      if let Some(path) = value.as_str() {
        let js_path = ts_to_js_extension(path);
        let mut entry = serde_json::Map::new();
        if has_dts(dts_set, path) {
          entry.insert(
            "types".to_string(),
            json!(format!("./{}", ts_to_dts_extension(path))),
          );
        }
        entry.insert("import".to_string(), json!(format!("./{}", js_path)));
        entry.insert("default".to_string(), json!(format!("./{}", js_path)));
        result.insert(key.clone(), serde_json::Value::Object(entry));
      } else if let Some(obj) = value.as_object() {
        let mut rewritten = serde_json::Map::new();
        for (condition, cond_value) in obj.iter() {
          if let Some(path) = cond_value.as_str() {
            if condition == "types" {
              if has_dts(dts_set, path) {
                rewritten.insert(
                  condition.clone(),
                  json!(format!("./{}", ts_to_dts_extension(path))),
                );
              }
              // omit `types` when no .d.ts was generated
            } else {
              rewritten.insert(
                condition.clone(),
                json!(format!("./{}", ts_to_js_extension(path))),
              );
            }
          } else {
            rewritten.insert(condition.clone(), cond_value.clone());
          }
        }
        result.insert(key.clone(), serde_json::Value::Object(rewritten));
      }
    }

    return Ok(serde_json::Value::Object(result));
  }

  deno_core::anyhow::bail!(
    "unsupported 'exports' shape in deno.json: expected a string or object, got {}",
    exports
  )
}

fn extract_main_and_types(
  exports: &Option<serde_json::Value>,
  dts_set: &HashSet<String>,
) -> (Option<String>, Option<String>) {
  let Some(exports) = exports else {
    let types = if dts_set.contains("mod.js") {
      Some("./mod.d.ts".to_string())
    } else {
      None
    };
    return (Some("./mod.js".to_string()), types);
  };

  if let Some(s) = exports.as_str() {
    let js_path = format!("./{}", ts_to_js_extension(s));
    let dts_path = if has_dts(dts_set, s) {
      Some(format!("./{}", ts_to_dts_extension(s)))
    } else {
      None
    };
    return (Some(js_path), dts_path);
  }

  if let Some(map) = exports.as_object()
    && let Some(dot_export) = map.get(".")
  {
    if let Some(path) = dot_export.as_str() {
      let js_path = format!("./{}", ts_to_js_extension(path));
      let dts_path = if has_dts(dts_set, path) {
        Some(format!("./{}", ts_to_dts_extension(path)))
      } else {
        None
      };
      return (Some(js_path), dts_path);
    } else if let Some(obj) = dot_export.as_object() {
      // Rewrite the source extension on the way out: deno.json may carry
      // `{"import": "./foo.ts"}` for the dot-export, but the tarball
      // contains the transpiled `.js`, so package.json's `main` must
      // point at the `.js` file or `npm install`-time resolution fails.
      // The string-export branch above does the same rewrite — keep this
      // branch in sync.
      let main = obj
        .get("import")
        .or_else(|| obj.get("default"))
        .and_then(|v| v.as_str())
        .map(|s| format!("./{}", ts_to_js_extension(s)));
      let types = obj
        .get("types")
        .and_then(|v| v.as_str())
        .filter(|s| has_dts(dts_set, s))
        .map(|s| format!("./{}", ts_to_dts_extension(s)));
      return (main, types);
    }
  }

  (None, None)
}
