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

use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;

use deno_ast::EmitOptions;
use deno_ast::MediaType;
use deno_ast::SourceMapOption;
use deno_ast::TranspileModuleOptions;
use deno_core::ModuleSpecifier;
use deno_core::anyhow;
use deno_core::anyhow::Context;
use deno_core::error::AnyError;
use deno_graph::GraphKind;
use deno_graph::ModuleGraph;
use deno_resolver::emit::patch_public_decorator_access_has;
use deno_terminal::colors;
use sys_traits::PathsInErrorsExt;

use crate::args::Flags;
use crate::args::SourceMapMode;
use crate::args::TranspileFlags;
use crate::factory::CliFactory;

pub async fn transpile(
  flags: Arc<Flags>,
  transpile_flags: TranspileFlags,
) -> Result<(), AnyError> {
  let files = &transpile_flags.files;

  if files.is_empty() {
    anyhow::bail!("No input files specified");
  }

  if files.len() > 1 && transpile_flags.output.is_some() {
    anyhow::bail!(
      "Cannot use --output with multiple input files. Use --outdir instead."
    );
  }

  if transpile_flags.declaration
    && transpile_flags.output.is_none()
    && transpile_flags.output_dir.is_none()
  {
    anyhow::bail!(
      "Cannot use --declaration without --output or --outdir. Declaration files must be written to disk."
    );
  }

  let is_stdout_mode = files.len() == 1
    && transpile_flags.output.is_none()
    && transpile_flags.output_dir.is_none();

  if is_stdout_mode
    && matches!(transpile_flags.source_map, SourceMapMode::Separate)
  {
    anyhow::bail!(
      "Cannot use --source-map separate when outputting to stdout. Use --output or --outdir, or use --source-map inline instead."
    );
  }

  let factory = CliFactory::from_flags(flags.clone());
  let cli_options = factory.cli_options()?;
  let cwd = cli_options.initial_cwd();
  let real_sys = factory.sys();
  let sys = real_sys.with_paths_in_errors();

  let source_map_option = match transpile_flags.source_map {
    SourceMapMode::None => SourceMapOption::None,
    SourceMapMode::Inline => SourceMapOption::Inline,
    SourceMapMode::Separate => SourceMapOption::Separate,
  };

  // Resolve transpile options from deno.json compilerOptions
  let compiler_options_resolver = factory.compiler_options_resolver()?;

  // Collect file paths and specifiers
  let mut file_entries = Vec::new();
  for file_path_str in files {
    let file_path = cwd.join(file_path_str);

    let media_type = MediaType::from_path(&file_path);
    if !media_type.is_emittable() {
      log::warn!(
        "{} Skipping {} (not a TypeScript/JSX/TSX file)",
        colors::yellow("Warning"),
        file_path.display()
      );
      continue;
    }

    let specifier =
      ModuleSpecifier::from_file_path(&file_path).map_err(|_| {
        anyhow::anyhow!("Invalid file path: {}", file_path.display())
      })?;

    file_entries.push((file_path, specifier, media_type));
  }

  if file_entries.is_empty() {
    anyhow::bail!(
      "No emittable files found. Only TypeScript/JSX/TSX files can be transpiled."
    );
  }

  // Transpile each file (TS -> JS)
  for (file_path, specifier, media_type) in &file_entries {
    let source_bytes = sys
      .fs_read(file_path)
      .with_context(|| format!("Failed to read {}", file_path.display()))?;
    let source_code = String::from_utf8(source_bytes.into_owned())
      .with_context(|| format!("{} is not valid UTF-8", file_path.display()))?;

    let parsed_source = deno_ast::parse_module(deno_ast::ParseParams {
      specifier: specifier.clone(),
      text: source_code.into(),
      media_type: *media_type,
      capture_tokens: false,
      scope_analysis: false,
      maybe_syntax: None,
    })?;

    // Use transpile options from deno.json, with source map overridden by CLI flag
    let transpile_and_emit_options = compiler_options_resolver
      .for_specifier(specifier)
      .transpile_options()?;
    let transpile_options = &transpile_and_emit_options.transpile;

    // Compute the output path/filename so source_map_file references the
    // generated file (e.g. "out.js"), not the input (e.g. "main.ts").
    let maybe_output_path = if !is_stdout_mode {
      Some(compute_output_path(
        file_path,
        cwd,
        transpile_flags.output.as_deref(),
        transpile_flags.output_dir.as_deref(),
        *media_type,
      )?)
    } else {
      None
    };
    let source_map_filename = if let Some(output_path) = &maybe_output_path {
      output_path
        .file_name()
        .unwrap_or_default()
        .to_string_lossy()
        .into_owned()
    } else {
      // In stdout mode, use the input-derived JS filename
      let ext = js_extension_for_media_type(*media_type);
      file_path
        .with_extension(ext)
        .file_name()
        .unwrap_or_default()
        .to_string_lossy()
        .into_owned()
    };

    // Always apply the CLI source map mode. The built-in emit defaults
    // have inlineSourceMap: true (for the runtime), but `deno transpile`
    // should produce clean output by default. Users must pass --source-map
    // explicitly to enable source maps.
    let emit_options = EmitOptions {
      source_map: source_map_option,
      source_map_file: Some(source_map_filename),
      ..transpile_and_emit_options.emit.clone()
    };

    let transpile_result = parsed_source.transpile(
      transpile_options,
      &TranspileModuleOptions { module_kind: None },
      &emit_options,
    )?;

    let mut emitted = transpile_result.into_source();
    patch_public_decorator_access_has(&mut emitted.text);
    // Skip the newline restoration pass when source maps are requested,
    // since inserting newlines would shift line numbers in the generated
    // output without a corresponding adjustment to the mappings.
    let emitted_text;
    let js_text = if matches!(source_map_option, SourceMapOption::None) {
      emitted_text = restore_block_comment_newlines(&emitted.text);
      &emitted_text
    } else {
      &emitted.text
    };
    let source_map_text = &emitted.source_map;

    // Determine output path
    if is_stdout_mode {
      // Single file, no output specified: print to stdout
      deno_print::drop_write_stdout(js_text.as_bytes());
    } else {
      let output_path = maybe_output_path.unwrap();

      // Ensure parent directory exists
      if let Some(parent) = output_path.parent() {
        sys.fs_create_dir_all(parent).with_context(|| {
          format!("Failed to create directory {}", parent.display())
        })?;
      }

      sys
        .fs_write(&output_path, js_text.as_bytes())
        .with_context(|| {
          format!("Failed to write {}", output_path.display())
        })?;
      log::info!("{} {}", colors::green("Emit"), output_path.display());

      // Write separate source map file
      if let Some(sm) = source_map_text
        && matches!(transpile_flags.source_map, SourceMapMode::Separate)
      {
        let ext = output_path
          .extension()
          .unwrap_or_default()
          .to_string_lossy();
        let map_path = output_path.with_extension(format!("{ext}.map"));
        sys
          .fs_write(&map_path, sm.as_bytes())
          .with_context(|| format!("Failed to write {}", map_path.display()))?;
        log::info!("{} {}", colors::green("Emit"), map_path.display());
      }
    }
  }

  // Generate .d.ts declaration files if requested
  if transpile_flags.declaration && !file_entries.is_empty() {
    emit_declarations(&factory, &transpile_flags, &file_entries, cwd).await?;
  }

  Ok(())
}

async fn emit_declarations(
  factory: &CliFactory,
  transpile_flags: &TranspileFlags,
  file_entries: &[(PathBuf, ModuleSpecifier, MediaType)],
  cwd: &Path,
) -> Result<(), AnyError> {
  let cli_options = factory.cli_options()?;
  let real_sys = factory.sys();
  let sys = real_sys.with_paths_in_errors();

  // Build a module graph for the input files
  let root_names: Vec<(ModuleSpecifier, MediaType)> = file_entries
    .iter()
    .map(|(_, specifier, media_type)| (specifier.clone(), *media_type))
    .collect();

  let specifiers: Vec<ModuleSpecifier> =
    root_names.iter().map(|(s, _)| s.clone()).collect();

  let mut graph = ModuleGraph::new(GraphKind::All);
  let module_graph_builder = factory.module_graph_builder().await?;
  module_graph_builder
    .build_graph_roots_with_npm_resolution(
      &mut graph,
      specifiers,
      crate::graph_util::BuildGraphWithNpmOptions {
        is_dynamic: false,
        loader: None,
        npm_caching: cli_options.default_npm_caching_strategy(),
      },
    )
    .await?;

  // Validate that all files share the same compiler options scope, since
  // TSC type-checks all files in a single pass.
  let compiler_options_resolver = factory.compiler_options_resolver()?;
  let first_specifier = &root_names[0].0;
  let (first_key, _) =
    compiler_options_resolver.entry_for_specifier(first_specifier);
  for (specifier, _) in root_names.iter().skip(1) {
    let (key, _) = compiler_options_resolver.entry_for_specifier(specifier);
    if key != first_key {
      anyhow::bail!(
        "Cannot generate declarations for files with different compiler option scopes.\n\
         '{}' uses scope '{}' but '{}' uses scope '{}'.\n\
         Separate these files into different invocations.",
        first_specifier,
        first_key,
        specifier,
        key,
      );
    }
  }

  let type_checker = factory.type_checker().await?;
  let result = type_checker.emit_declarations(
    Arc::new(graph),
    root_names,
    cli_options.ts_type_lib_window(),
  )?;

  if result.diagnostics.has_diagnostic() {
    anyhow::bail!("Type checking failed:\n{}", result.diagnostics);
  }

  // Determine the output directory for .d.ts files.
  // With -o, place .d.ts next to the output file.
  // With --outdir, use the outdir.
  // Otherwise, place next to the source file (handled by TSC path).
  let output_base_dir = if let Some(output) = &transpile_flags.output {
    let output_path = cwd.join(output);
    output_path.parent().map(|p| p.to_path_buf())
  } else {
    None
  };

  // Write emitted .d.ts files
  for (file_name, content) in &result.emitted_files {
    // The file names from TSC are specifier-based, convert to output paths
    let output_path = resolve_dts_output_path(
      file_name,
      transpile_flags.output_dir.as_deref(),
      output_base_dir.as_deref(),
      cwd,
    )?;

    if let Some(parent) = output_path.parent() {
      sys.fs_create_dir_all(parent).with_context(|| {
        format!("Failed to create directory {}", parent.display())
      })?;
    }

    sys
      .fs_write(&output_path, content.as_bytes())
      .with_context(|| format!("Failed to write {}", output_path.display()))?;
    log::info!("{} {}", colors::green("Emit"), output_path.display());
  }

  if result.emitted_files.is_empty() {
    log::warn!(
      "{} No declaration files were emitted",
      colors::yellow("Warning")
    );
  }

  Ok(())
}

fn resolve_dts_output_path(
  tsc_file_name: &str,
  output_dir: Option<&str>,
  output_base_dir: Option<&Path>,
  cwd: &Path,
) -> Result<PathBuf, AnyError> {
  // TSC emits file names like "file:///path/to/file.d.ts"
  let path = if let Ok(specifier) = ModuleSpecifier::parse(tsc_file_name) {
    deno_path_util::url_to_file_path(&specifier).with_context(|| {
      format!("Cannot convert specifier to path: {tsc_file_name}")
    })?
  } else {
    PathBuf::from(tsc_file_name)
  };

  if let Some(outdir) = output_dir {
    // --outdir: preserve relative directory structure
    let outdir = cwd.join(outdir);
    let relative = path.strip_prefix(cwd).map_err(|_| {
      anyhow::anyhow!(
        "Declaration file {} is not under the current directory",
        path.display()
      )
    })?;
    Ok(outdir.join(relative))
  } else if let Some(base_dir) = output_base_dir {
    // -o: place .d.ts next to the output file
    let file_name = path
      .file_name()
      .ok_or_else(|| anyhow::anyhow!("Invalid declaration file path"))?;
    Ok(base_dir.join(file_name))
  } else {
    // No output specified: place next to source file
    Ok(path)
  }
}

/// SWC's code generator always writes a single space after a block
/// comment's closing `*/`, regardless of whether the original source had a
/// newline. For multi-line block comments (typically JSDoc) this collapses
/// the comment onto the same line as the following statement. This function
/// walks the emitted source and replaces that single space with a newline
/// (plus the comment's own leading indentation) when the preceding block
/// comment spans multiple lines.
fn restore_block_comment_newlines(input: &str) -> String {
  let bytes = input.as_bytes();
  let n = bytes.len();
  let mut out: Vec<u8> = Vec::with_capacity(n);
  let mut i = 0;

  // Stack of brace depths at which template-literal substitutions started.
  // When `}` is reached at the top of the stack, we re-enter the template.
  let mut template_stack: Vec<u32> = Vec::new();
  let mut brace_depth: u32 = 0;

  // 0 = code, 1 = single-quote string, 2 = double-quote string,
  // 3 = template literal.
  let mut mode: u8 = 0;

  while i < n {
    let c = bytes[i];
    match mode {
      0 => match c {
        b'\'' => {
          out.push(c);
          mode = 1;
          i += 1;
        }
        b'"' => {
          out.push(c);
          mode = 2;
          i += 1;
        }
        b'`' => {
          out.push(c);
          mode = 3;
          i += 1;
        }
        b'{' => {
          brace_depth += 1;
          out.push(c);
          i += 1;
        }
        b'}' => {
          if template_stack.last() == Some(&brace_depth) {
            template_stack.pop();
            mode = 3;
          }
          brace_depth = brace_depth.saturating_sub(1);
          out.push(c);
          i += 1;
        }
        b'/' if i + 1 < n && bytes[i + 1] == b'/' => {
          // Line comment: copy through end of line.
          while i < n && bytes[i] != b'\n' {
            out.push(bytes[i]);
            i += 1;
          }
        }
        b'/' if i + 1 < n && bytes[i + 1] == b'*' => {
          // Block comment: copy through `*/`, then maybe restore newline.
          let start = i;
          out.push(bytes[i]);
          out.push(bytes[i + 1]);
          i += 2;
          let mut has_newline = false;
          while i + 1 < n && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
            if bytes[i] == b'\n' {
              has_newline = true;
            }
            out.push(bytes[i]);
            i += 1;
          }
          if i + 1 < n {
            out.push(b'*');
            out.push(b'/');
            i += 2;
          }
          // If multi-line and SWC emitted the trailing space, replace it
          // with a newline + the comment's own indentation. We only act
          // when the comment is indented by pure whitespace on its line;
          // otherwise leave the output untouched to avoid breaking layout.
          if has_newline && i < n && bytes[i] == b' ' {
            // Locate the start of the line containing the comment.
            let mut line_start = start;
            while line_start > 0 && bytes[line_start - 1] != b'\n' {
              line_start -= 1;
            }
            let indent = &bytes[line_start..start];
            if indent.iter().all(|&b| b == b' ' || b == b'\t') {
              out.push(b'\n');
              out.extend_from_slice(indent);
              i += 1; // skip the space SWC wrote
            }
          }
        }
        _ => {
          out.push(c);
          i += 1;
        }
      },
      1 | 2 => {
        let quote = if mode == 1 { b'\'' } else { b'"' };
        out.push(c);
        i += 1;
        if c == b'\\' && i < n {
          out.push(bytes[i]);
          i += 1;
        } else if c == quote {
          mode = 0;
        }
      }
      3 => {
        if c == b'\\' && i + 1 < n {
          out.push(c);
          out.push(bytes[i + 1]);
          i += 2;
        } else if c == b'`' {
          out.push(c);
          mode = 0;
          i += 1;
        } else if c == b'$' && i + 1 < n && bytes[i + 1] == b'{' {
          out.push(c);
          out.push(b'{');
          brace_depth += 1;
          template_stack.push(brace_depth);
          mode = 0;
          i += 2;
        } else {
          out.push(c);
          i += 1;
        }
      }
      _ => unreachable!(),
    }
  }

  // Safety: input is valid UTF-8 and we only branch on ASCII bytes; the
  // newlines and spaces we insert are ASCII. Multi-byte UTF-8 sequences are
  // copied byte-for-byte so the output remains valid UTF-8.
  String::from_utf8(out).expect("post-processed text must remain valid UTF-8")
}

fn js_extension_for_media_type(media_type: MediaType) -> &'static str {
  match media_type {
    MediaType::Mts => "mjs",
    MediaType::Cts => "cjs",
    _ => "js",
  }
}

fn compute_output_path(
  input_path: &Path,
  cwd: &Path,
  output: Option<&str>,
  output_dir: Option<&str>,
  media_type: MediaType,
) -> Result<PathBuf, AnyError> {
  if let Some(output) = output {
    // Explicit output file
    return Ok(cwd.join(output));
  }

  let ext = js_extension_for_media_type(media_type);
  let js_filename = input_path.with_extension(ext);

  if let Some(outdir) = output_dir {
    let outdir = cwd.join(outdir);
    let relative = js_filename
      .strip_prefix(cwd)
      .map_err(|_| {
        anyhow::anyhow!(
          "Input file {} is not under the current directory. Use --output instead.",
          input_path.display()
        )
      })?;
    Ok(outdir.join(relative))
  } else {
    // Write alongside source file
    Ok(js_filename)
  }
}
