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

use std::convert::Infallible;
use std::future::Future;
use std::net::Ipv6Addr;
use std::net::SocketAddr;
use std::net::SocketAddrV6;
use std::path::PathBuf;

use base64::Engine;
use base64::prelude::BASE64_STANDARD;
use bytes::Bytes;
use futures::FutureExt;
use futures::StreamExt;
use futures::future::LocalBoxFuture;
use http::HeaderMap;
use http::HeaderValue;
use http_body_util::BodyExt;
use http_body_util::combinators::UnsyncBoxBody;
use hyper::Request;
use hyper::Response;
use hyper::StatusCode;
use hyper::body::Incoming;
use serde_json::json;
use sha2::Digest;

use super::ServerKind;
use super::ServerOptions;
use super::custom_headers;
use super::empty_body;
use super::hyper_utils::HandlerOutput;
use super::run_server;
use super::run_server_with_acceptor;
use super::string_body;
use crate::npm;
use crate::root_path;

pub fn public_npm_registry(port: u16) -> Vec<LocalBoxFuture<'static, ()>> {
  run_npm_server(port, "npm registry server error", {
    move |req| async move {
      handle_req_for_registry(req, &npm::PUBLIC_TEST_NPM_REGISTRY).await
    }
  })
}

pub fn public_npm_jsr_registry(port: u16) -> Vec<LocalBoxFuture<'static, ()>> {
  run_npm_server(port, "npm jsr registry server error", {
    move |req| async move {
      handle_req_for_registry(req, &npm::PUBLIC_TEST_NPM_JSR_REGISTRY).await
    }
  })
}

const PRIVATE_NPM_REGISTRY_AUTH_TOKEN: &str = "private-reg-token";
const PRIVATE_NPM_REGISTRY_2_AUTH_TOKEN: &str = "private-reg-token2";

// `deno:land` encoded using base64
const PRIVATE_NPM_REGISTRY_AUTH_BASE64: &str = "ZGVubzpsYW5k";
// `deno@test.com:land` encoded using base64
const PRIVATE_NPM_REGISTRY_AUTH_EMAIL_BASE64: &str = "ZGVub0B0ZXN0LmNvbTpsYW5k";
// `deno:land2` encoded using base64
const PRIVATE_NPM_REGISTRY_2_AUTH_BASE64: &str = "ZGVubzpsYW5kMg==";

pub fn private_npm_registry1(port: u16) -> Vec<LocalBoxFuture<'static, ()>> {
  run_npm_server(
    port,
    "npm private registry server error",
    private_npm_registry1_handler,
  )
}

pub fn private_npm_registry2(port: u16) -> Vec<LocalBoxFuture<'static, ()>> {
  run_npm_server(
    port,
    "npm private registry server error",
    private_npm_registry2_handler,
  )
}

pub fn private_npm_registry3(port: u16) -> Vec<LocalBoxFuture<'static, ()>> {
  run_npm_server(
    port,
    "npm private registry server error",
    private_npm_registry3_handler,
  )
}

/// An HTTPS npm registry that requires mutual TLS (client certificate)
pub fn private_npm_registry_mtls(
  port: u16,
) -> Vec<LocalBoxFuture<'static, ()>> {
  vec![async move {
    ensure_esbuild_prebuilt().await.unwrap();
    let mut tls =
      super::super::https::get_tls_listener_stream_with_required_client_auth(
        "npm_mtls_registry",
        port,
      )
      .await;
    let tls = async_stream::stream! {
      while let Some(Ok(mut tls)) = tls.next().await {
        let handshake = tls.handshake().await?;
        match handshake.has_peer_certificates {
          true => { yield Ok(tls); },
          false => { eprintln!("npm_mtls_registry: no valid client certificate"); },
        };
      }
    };
    run_server_with_acceptor(
      tls.boxed_local(),
      |req| async move {
        handle_req_for_registry(req, &npm::PRIVATE_TEST_NPM_REGISTRY_MTLS).await
      },
      "npm mTLS registry server error",
      ServerKind::Auto,
    )
    .await
  }
  .boxed_local()]
}

fn run_npm_server<F, S>(
  port: u16,
  error_msg: &'static str,
  handler: F,
) -> Vec<LocalBoxFuture<'static, ()>>
where
  F: Fn(Request<hyper::body::Incoming>) -> S + Copy + 'static,
  S: Future<Output = HandlerOutput> + 'static,
{
  let npm_registry_addr = SocketAddr::from(([127, 0, 0, 1], port));
  let ipv6_loopback = Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 1);
  let npm_registry_ipv6_addr =
    SocketAddr::V6(SocketAddrV6::new(ipv6_loopback, port, 0, 0));
  vec![
    run_npm_server_for_addr(npm_registry_addr, error_msg, handler)
      .boxed_local(),
    // necessary because the npm binary will sometimes resolve localhost to ::1
    run_npm_server_for_addr(npm_registry_ipv6_addr, error_msg, handler)
      .boxed_local(),
  ]
}

async fn run_npm_server_for_addr<F, S>(
  addr: SocketAddr,
  error_msg: &'static str,
  handler: F,
) where
  F: Fn(Request<hyper::body::Incoming>) -> S + Copy + 'static,
  S: Future<Output = HandlerOutput> + 'static,
{
  ensure_esbuild_prebuilt().await.unwrap();
  run_server(
    ServerOptions {
      addr,
      kind: ServerKind::Auto,
      error_msg,
    },
    handler,
  )
  .await
}

async fn private_npm_registry1_handler(
  req: Request<hyper::body::Incoming>,
) -> Result<Response<UnsyncBoxBody<Bytes, Infallible>>, anyhow::Error> {
  let auth = req
    .headers()
    .get("authorization")
    .and_then(|x| x.to_str().ok())
    .unwrap_or_default();
  if auth != format!("Bearer {}", PRIVATE_NPM_REGISTRY_AUTH_TOKEN)
    && auth != format!("Basic {}", PRIVATE_NPM_REGISTRY_AUTH_BASE64)
    && auth != format!("Basic {}", PRIVATE_NPM_REGISTRY_AUTH_EMAIL_BASE64)
  {
    return Ok(
      Response::builder()
        .status(StatusCode::UNAUTHORIZED)
        .body(empty_body())
        .unwrap(),
    );
  }

  handle_req_for_registry(req, &npm::PRIVATE_TEST_NPM_REGISTRY_1).await
}

async fn private_npm_registry2_handler(
  req: Request<hyper::body::Incoming>,
) -> Result<Response<UnsyncBoxBody<Bytes, Infallible>>, anyhow::Error> {
  let auth = req
    .headers()
    .get("authorization")
    .and_then(|x| x.to_str().ok())
    .unwrap_or_default();
  if auth != format!("Bearer {}", PRIVATE_NPM_REGISTRY_2_AUTH_TOKEN)
    && auth != format!("Basic {}", PRIVATE_NPM_REGISTRY_2_AUTH_BASE64)
  {
    return Ok(
      Response::builder()
        .status(StatusCode::UNAUTHORIZED)
        .body(empty_body())
        .unwrap(),
    );
  }

  handle_req_for_registry(req, &npm::PRIVATE_TEST_NPM_REGISTRY_2).await
}

async fn private_npm_registry3_handler(
  req: Request<hyper::body::Incoming>,
) -> Result<Response<UnsyncBoxBody<Bytes, Infallible>>, anyhow::Error> {
  // Packages whose name contains "gitlab" simulate a self-hosted GitLab npm
  // registry: it only serves package metadata to requests that identify as an
  // npm client (via the `Accept: application/vnd.npm.install-v1+json` header)
  // and otherwise redirects to the public npm registry, which doesn't have the
  // private package and 404s. This reproduces the behaviour from
  // https://github.com/denoland/deno/issues/31924 where `deno outdated` dropped
  // the package because it fetched metadata with `Accept: */*`.
  let uri_path = req.uri().path();
  let is_metadata = !uri_path.ends_with(".tgz");
  if is_metadata
    && uri_path.contains("gitlab")
    && !wants_abbreviated(req.headers())
  {
    return Response::builder()
      .status(StatusCode::FOUND)
      .header(
        http::header::LOCATION,
        format!(
          "http://localhost:{}{uri_path}",
          crate::consts::PUBLIC_NPM_REGISTRY_PORT
        ),
      )
      .body(empty_body())
      .map_err(|e| e.into());
  }
  // No auth for this registry
  handle_req_for_registry(req, &npm::PRIVATE_TEST_NPM_REGISTRY_3).await
}

async fn handle_req_for_registry(
  req: Request<Incoming>,
  test_npm_registry: &npm::TestNpmRegistry,
) -> Result<Response<UnsyncBoxBody<Bytes, Infallible>>, anyhow::Error> {
  let root_dir = test_npm_registry.root_dir();

  // serve the registry package files
  let uri_path = req.uri().path();

  if uri_path == "/sub/path/-/npm/v1/security/advisories/bulk" {
    // This is for the test in `tests/specs/audit/subpath_registry/__test__.jsonc` that tests that audit works when the registry URL has a subpath.
    // This endpoint must return something different than the api endpoint at the root to verify the request is going to the correct URL with the subpath.
    return npm_security_advisories_bulk_no_vulns();
  }

  if uri_path == "/-/npm/v1/security/advisories/bulk" {
    return npm_security_advisories_bulk(req).await;
  }

  // Package requests may arrive through a registry url configured with a
  // sub-path (e.g. `http://localhost:4260/sub/path/`). Strip the known test
  // sub-path prefix so the same package files are served. This lets tests
  // exercise sub-path registry cache layouts (the on-disk cache ends up under
  // `<host>/sub/path/<pkg>` rather than `<host>/<pkg>`).
  let uri_path = uri_path.strip_prefix("/sub/path").unwrap_or(uri_path);

  let mut file_path = root_dir.to_path_buf();
  file_path.push(uri_path[1..].replace("%2f", "/").replace("%2F", "/"));

  // serve if the filepath exists
  if let Ok(file) = tokio::fs::read(&file_path).await {
    let file_resp = custom_headers(uri_path, file);
    return Ok(file_resp);
  }

  // otherwise try to serve from the registry
  if let Some(resp) = try_serve_npm_registry(
    uri_path,
    file_path.clone(),
    req.headers(),
    test_npm_registry,
  )
  .await
  {
    return resp;
  }

  Response::builder()
    .status(StatusCode::NOT_FOUND)
    .body(empty_body())
    .map_err(|e| e.into())
}

/// Returns true if the Accept header indicates the client wants the
/// abbreviated install manifest (`application/vnd.npm.install-v1+json`).
fn wants_abbreviated(headers: &HeaderMap<HeaderValue>) -> bool {
  headers
    .get(http::header::ACCEPT)
    .and_then(|v| v.to_str().ok())
    .map(|v| v.contains("application/vnd.npm.install-v1+json"))
    .unwrap_or(false)
}

/// Converts a full packument JSON into the abbreviated install manifest format.
/// Strips `time` and `scripts`, replaces scripts with `hasInstallScript`.
fn to_abbreviated_packument(full: &[u8]) -> Vec<u8> {
  let mut packument: serde_json::Value = serde_json::from_slice(full).unwrap();
  let obj = packument.as_object_mut().unwrap();

  // Remove `time` (not present in abbreviated format)
  obj.remove("time");

  // Transform each version entry
  if let Some(versions) =
    obj.get_mut("versions").and_then(|v| v.as_object_mut())
  {
    for version_info in versions.values_mut() {
      let vi = version_info.as_object_mut().unwrap();

      // Check for install lifecycle scripts before removing
      let has_install_script = vi
        .get("scripts")
        .and_then(|s| s.as_object())
        .map(|scripts| {
          scripts.contains_key("preinstall")
            || scripts.contains_key("install")
            || scripts.contains_key("postinstall")
        })
        .unwrap_or(false);

      // Remove `scripts` (not present in abbreviated format)
      vi.remove("scripts");

      // Add `hasInstallScript` if applicable
      if has_install_script {
        vi.insert("hasInstallScript".to_string(), json!(true));
      }
    }
  }

  serde_json::to_vec(&packument).unwrap()
}

fn handle_custom_npm_registry_path(
  scope_name: &str,
  path: &str,
  headers: &HeaderMap<HeaderValue>,
  test_npm_registry: &npm::TestNpmRegistry,
) -> Result<Option<Response<UnsyncBoxBody<Bytes, Infallible>>>, anyhow::Error> {
  let mut parts = path
    .split('/')
    .filter(|p| !p.is_empty())
    .collect::<Vec<_>>();
  let remainder = parts.split_off(1);
  let name = parts[0];
  let package_name = format!("{}/{}", scope_name, name);

  if remainder.len() == 1 {
    if let Some(file_bytes) = test_npm_registry
      .tarball_bytes(&package_name, remainder[0].trim_end_matches(".tgz"))?
    {
      let file_resp = custom_headers("file.tgz", file_bytes);
      return Ok(Some(file_resp));
    }
  } else if remainder.is_empty()
    && let Some(registry_file) =
      test_npm_registry.registry_file(&package_name)?
  {
    let registry_file = if wants_abbreviated(headers) {
      to_abbreviated_packument(&registry_file)
    } else {
      registry_file
    };

    let actual_etag = format!(
      "\"{}\"",
      BASE64_STANDARD.encode(sha2::Sha256::digest(&registry_file))
    );
    if headers.get("If-None-Match").and_then(|v| v.to_str().ok())
      == Some(actual_etag.as_str())
    {
      let mut response = Response::new(UnsyncBoxBody::new(
        http_body_util::Full::new(Bytes::from(vec![])),
      ));
      *response.status_mut() = StatusCode::NOT_MODIFIED;
      return Ok(Some(response));
    }

    let mut file_resp = custom_headers("registry.json", registry_file);
    file_resp.headers_mut().append(
      http::header::ETAG,
      http::header::HeaderValue::from_str(&actual_etag).unwrap(),
    );

    return Ok(Some(file_resp));
  }

  Ok(None)
}

fn should_download_npm_packages() -> bool {
  // when this env var is set, it will download and save npm packages
  // to the tests/registry/npm directory
  std::env::var("DENO_TEST_UTIL_UPDATE_NPM") == Ok("1".to_string())
}

async fn try_serve_npm_registry(
  uri_path: &str,
  mut testdata_file_path: PathBuf,
  headers: &HeaderMap<HeaderValue>,
  test_npm_registry: &npm::TestNpmRegistry,
) -> Option<Result<Response<UnsyncBoxBody<Bytes, Infallible>>, anyhow::Error>> {
  if let Some((scope_name, package_name_with_path)) = test_npm_registry
    .get_test_scope_and_package_name_with_path_from_uri_path(uri_path)
  {
    // serve all requests to the `DENOTEST_SCOPE_NAME` or `DENOTEST2_SCOPE_NAME`
    // using the file system at that path
    match handle_custom_npm_registry_path(
      scope_name,
      package_name_with_path,
      headers,
      test_npm_registry,
    ) {
      Ok(Some(response)) => return Some(Ok(response)),
      Ok(None) => {} // ignore, not found
      Err(err) => {
        return Some(
          Response::builder()
            .status(StatusCode::INTERNAL_SERVER_ERROR)
            .body(string_body(&format!("{err:#}")))
            .map_err(|e| e.into()),
        );
      }
    }
  } else {
    // otherwise, serve based on registry.json and tgz files
    let is_tarball = uri_path.ends_with(".tgz");
    if !is_tarball {
      testdata_file_path.push("registry.json");
    }
    if let Ok(file) = tokio::fs::read(&testdata_file_path).await {
      let file = if !is_tarball && wants_abbreviated(headers) {
        to_abbreviated_packument(&file)
      } else {
        file
      };
      let file_resp = custom_headers(uri_path, file);
      return Some(Ok(file_resp));
    } else if should_download_npm_packages() {
      if let Err(err) = download_npm_registry_file(
        test_npm_registry,
        uri_path,
        &testdata_file_path,
        is_tarball,
      )
      .await
      {
        return Some(
          Response::builder()
            .status(StatusCode::INTERNAL_SERVER_ERROR)
            .body(string_body(&format!("{err:#}")))
            .map_err(|e| e.into()),
        );
      };

      // serve the file
      if let Ok(file) = tokio::fs::read(&testdata_file_path).await {
        let file_resp = custom_headers(uri_path, file);
        return Some(Ok(file_resp));
      }
    }
  }

  None
}

// Replaces URL of public npm registry (`https://registry.npmjs.org/`) with
// the test registry (`http://localhost:4260`).
//
// These strings end up in `registry.json` files for each downloaded package
// that are stored in `tests/testdata/` directory.
//
// If another npm test registry wants to use them, it should replace
// these values with appropriate URL when serving.
fn replace_default_npm_registry_url_with_test_npm_registry_url(
  text: String,
  npm_registry: &npm::TestNpmRegistry,
  package_name: &str,
) -> String {
  let package_name = percent_encoding::percent_decode_str(package_name)
    .decode_utf8()
    .unwrap();
  text.replace(
    &format!("https://registry.npmjs.org/{}/-/", package_name),
    &npm_registry.package_url(&package_name),
  )
}

async fn download_npm_registry_file(
  test_npm_registry: &npm::TestNpmRegistry,
  uri_path: &str,
  testdata_file_path: &PathBuf,
  is_tarball: bool,
) -> Result<(), anyhow::Error> {
  let uri_path = uri_path.trim_start_matches('/');
  let url_parts = uri_path.split('/').collect::<Vec<_>>();
  let package_name = if url_parts[0].starts_with('@') {
    url_parts.into_iter().take(2).collect::<Vec<_>>().join("/")
  } else {
    url_parts.into_iter().take(1).collect::<Vec<_>>().join("/")
  };
  let url = if is_tarball {
    let file_name = testdata_file_path.file_name().unwrap().to_string_lossy();
    format!("https://registry.npmjs.org/{package_name}/-/{file_name}")
  } else {
    format!("https://registry.npmjs.org/{package_name}")
  };
  let client = reqwest::Client::new();
  let response = client.get(url).send().await?;
  let bytes = response.bytes().await?;
  let bytes = if is_tarball {
    bytes.to_vec()
  } else {
    replace_default_npm_registry_url_with_test_npm_registry_url(
      String::from_utf8(bytes.to_vec()).unwrap(),
      test_npm_registry,
      &package_name,
    )
    .into_bytes()
  };
  std::fs::create_dir_all(testdata_file_path.parent().unwrap())?;
  std::fs::write(testdata_file_path, bytes)?;
  Ok(())
}

const PREBUILT_URL: &str = "https://raw.githubusercontent.com/denoland/deno_third_party/d074edee0f4199f8226e1f87b88ccb7956d94454/prebuilt/";

async fn ensure_esbuild_prebuilt() -> Result<(), anyhow::Error> {
  let bin_name = match (std::env::consts::ARCH, std::env::consts::OS) {
    ("x86_64", "linux" | "macos" | "apple") => "esbuild-x64",
    ("aarch64", "linux" | "macos" | "apple") => "esbuild-aarch64",
    ("x86_64", "windows") => "esbuild-x64.exe",
    ("aarch64", "windows") => "esbuild-aarch64.exe",
    _ => return Err(anyhow::anyhow!("unsupported platform")),
  };

  let folder = match std::env::consts::OS {
    "linux" => "linux64",
    "windows" => "win",
    "macos" | "apple" => "mac",
    _ => return Err(anyhow::anyhow!("unsupported platform")),
  };
  let esbuild_prebuilt = root_path()
    .join("third_party/prebuilt")
    .join(folder)
    .join(bin_name);
  if esbuild_prebuilt.exists() {
    return Ok(());
  }
  let url = format!("{PREBUILT_URL}{folder}/{bin_name}");
  let mut attempt = 0;
  let bytes = loop {
    attempt += 1;
    let err = match download_url(&url).await {
      Ok(bytes) => break bytes,
      Err(e) => e,
    };
    if attempt >= 3 {
      return Err(anyhow::anyhow!(
        "failed to download {url} after 3 attempts: {err}"
      ));
    }
    eprintln!(
      "failed to download {url} (attempt {attempt}/3): {err}, retrying..."
    );
    tokio::time::sleep(std::time::Duration::from_secs(2)).await;
  };

  tokio::fs::create_dir_all(esbuild_prebuilt.parent()).await?;
  tokio::fs::write(&esbuild_prebuilt, bytes).await?;

  #[cfg(unix)]
  {
    use std::os::unix::fs::PermissionsExt;
    let mut perms = tokio::fs::metadata(&esbuild_prebuilt).await?.permissions();
    perms.set_mode(0o755); // rwxr-xr-x
    tokio::fs::set_permissions(&esbuild_prebuilt, perms).await?;
  }

  Ok(())
}

async fn download_url(url: &str) -> Result<bytes::Bytes, anyhow::Error> {
  let response = reqwest::get(url).await?;
  Ok(response.bytes().await?)
}

fn npm_security_advisories_bulk_no_vulns()
-> Result<Response<UnsyncBoxBody<Bytes, Infallible>>, anyhow::Error> {
  let resp_body = json!({});

  Response::builder()
    .body(string_body(&serde_json::to_string(&resp_body).unwrap()))
    .map_err(|e| e.into())
}

async fn npm_security_advisories_bulk(
  req: Request<Incoming>,
) -> Result<Response<UnsyncBoxBody<Bytes, Infallible>>, anyhow::Error> {
  // Respond with 400 for a request whose body can't be read or isn't valid
  // JSON rather than returning `Err`. A handler error makes hyper abort the
  // connection instead of sending a response, which surfaces to the client as
  // `npm registry server error: hyper::Error(User(Service), expected value at
  // line 1 column 1)` and intermittently fails unrelated npm tests on Windows
  // (denoland/deno#35060, #36130). A real registry answers a malformed bulk
  // request with a 4xx, so mirror that.
  let bad_request = || {
    Response::builder()
      .status(StatusCode::BAD_REQUEST)
      .body(empty_body())
      .map_err(|e| e.into())
  };

  let Ok(collected) = req.into_body().collect().await else {
    return bad_request();
  };
  let body = collected.to_bytes();
  let Ok(json_obj) = serde_json::from_slice::<serde_json::Value>(&body) else {
    return bad_request();
  };

  let Some(resp_body) = process_npm_security_advisories_bulk_body(json_obj)
  else {
    return bad_request();
  };

  Response::builder()
    .body(string_body(&serde_json::to_string(&resp_body).unwrap()))
    .map_err(|e| e.into())
}

fn process_npm_security_advisories_bulk_body(
  value: serde_json::Value,
) -> Option<serde_json::Value> {
  // The bulk endpoint request body is: { "pkg-name": ["ver1", "ver2"], ... }
  let packages = value.as_object()?;
  let package_names = packages.keys().cloned().collect::<Vec<_>>();

  let mut response = serde_json::Map::new();

  if package_names.contains(&"@denotest/with-vuln1".to_string()) {
    response.insert(
      "@denotest/with-vuln1".to_string(),
      json!([get_advisory_for_with_vuln1()]),
    );
  }
  if package_names.contains(&"@denotest/with-vuln2".to_string()) {
    response.insert(
      "@denotest/with-vuln2".to_string(),
      json!([get_advisory_for_with_vuln2()]),
    );
  }
  if package_names.contains(&"@denotest/with-vuln3".to_string()) {
    response.insert(
      "@denotest/with-vuln3".to_string(),
      json!([get_advisory_for_with_vuln3()]),
    );
  }

  Some(serde_json::Value::Object(response))
}

fn get_advisory_for_with_vuln1() -> serde_json::Value {
  json!({
    "url": "https://example.com/vuln/101010",
    "title": "@denotest/with-vuln1 is susceptible to prototype pollution",
    "severity": "high",
    "vulnerable_versions": "<1.1.0",
    "patched_versions": ">=1.1.0",
    "cves": ["CVE-2025-0001"],
    "cwe": ["CWE-1321"]
  })
}

fn get_advisory_for_with_vuln2() -> serde_json::Value {
  json!({
    "url": "https://example.com/vuln/202020",
    "title": "@denotest/with-vuln2 can steal crypto keys",
    "severity": "critical",
    "vulnerable_versions": "<2.0.0",
    "patched_versions": ">=2.0.0",
    "cves": ["CVE-2025-0002"],
    "cwe": ["CWE-326"]
  })
}

fn get_advisory_for_with_vuln3() -> serde_json::Value {
  json!({
    "url": "https://example.com/vuln/303030",
    "title": "@denotest/with-vuln3 has security vulnerability",
    "severity": "high",
    "vulnerable_versions": "<1.1.0",
    "patched_versions": ">=1.1.0",
    "cves": ["CVE-2025-0003"],
    "cwe": ["CWE-79"]
  })
}
