use {super::*, clap_mangen::Man};

pub const INIT_JUSTFILE: &str = "\
# https://just.systems

default:
    echo 'Hello, world!'
";

static BACKTICK_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new("(`.*?`)|(`[^`]*$)").unwrap());

const CHOOSER_CANCELLED_EXIT_STATUS: i32 = 130;

#[derive(PartialEq, Clone, Debug, IntoStaticStr)]
#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
pub(crate) enum Subcommand {
  Changelog,
  Choose {
    chooser: Option<PathBuf>,
  },
  Command {
    arguments: Vec<OsString>,
    binary: OsString,
  },
  Completions {
    shell: Shell,
  },
  Dump {
    format: DumpFormat,
  },
  Edit,
  Evaluate {
    format: EvaluateFormat,
    path: Modulepath,
  },
  Format,
  Groups,
  Init,
  List {
    path: Modulepath,
  },
  Man,
  Request {
    request: Request,
  },
  Run {
    arguments: Vec<String>,
  },
  Show {
    path: Modulepath,
  },
  Summary,
  Usage {
    path: Modulepath,
  },
  Variables,
}

impl Default for Subcommand {
  fn default() -> Self {
    Self::Run {
      arguments: Vec::new(),
    }
  }
}

impl Subcommand {
  pub(crate) fn execute<'src>(&self, config: &Config, loader: &'src Loader) -> RunResult<'src> {
    use Subcommand::*;

    match self {
      Changelog => {
        Self::changelog();
        return Ok(());
      }
      Completions { shell } => {
        Self::completions(*shell);
        return Ok(());
      }
      Init => return Self::init(config),
      Man => return Self::man(),
      Request { request } => return Self::request(request),
      _ => {}
    }

    let search = Search::search(config)?;

    if matches!(self, Edit) {
      return Self::edit(&search);
    }

    if matches!(self, Format) {
      return Self::format(config, loader, &search);
    }

    let compilation = Self::compile(config, loader, &search)?;
    let justfile = &compilation.justfile;

    match self {
      Choose { chooser } => {
        Self::choose(
          chooser.as_deref(),
          config,
          justfile,
          &compilation.overrides,
          &search,
        )?;
      }
      Command { .. } | Evaluate { .. } => {
        justfile.run(config, &search, &[], &compilation.overrides)?;
      }
      Dump { format } => Self::dump(config, compilation, *format)?,
      Groups => Self::groups(config, justfile),
      List { path } => Self::list(config, justfile, path)?,
      Run { arguments } => Self::run(config, loader, search, compilation, arguments)?,
      Show { path } => Self::show(config, justfile, path)?,
      Summary => Self::summary(config, justfile),
      Usage { path } => Self::usage(config, justfile, path)?,
      Variables => Self::variables(justfile),
      Changelog | Completions { .. } | Edit | Format | Init | Man | Request { .. } => {
        unreachable!()
      }
    }

    Ok(())
  }

  fn groups(config: &Config, justfile: &Justfile) {
    println!("Recipe groups:");
    for group in justfile.public_groups(config) {
      println!("{}{group}", config.list_prefix);
    }
  }

  pub(crate) fn name(&self) -> &'static str {
    self.into()
  }

  fn run<'src>(
    config: &Config,
    loader: &'src Loader,
    mut search: Search,
    mut compilation: Compilation<'src>,
    arguments: &[String],
  ) -> RunResult<'src> {
    let starting_parent = search.justfile.parent().as_ref().unwrap().lexiclean();

    loop {
      let justfile = &compilation.justfile;
      let fallback = justfile.settings.fallback
        && matches!(
          config.search_config,
          SearchConfig::FromInvocationDirectory | SearchConfig::FromSearchDirectory { .. }
        );

      let result = justfile.run(config, &search, arguments, &compilation.overrides);

      if fallback {
        if let Err(err @ (Error::UnknownRecipe { .. } | Error::UnknownSubmodule { .. })) = result {
          search = search.search_parent_directory(config).map_err(|_| err)?;

          if config.verbosity.loquacious() {
            eprintln!(
              "Trying {}",
              starting_parent
                .strip_prefix(search.justfile.parent().unwrap())
                .unwrap()
                .components()
                .map(|_| path::Component::ParentDir)
                .collect::<PathBuf>()
                .join(search.justfile.file_name().unwrap())
                .display()
            );
          }

          compilation = Self::compile(config, loader, &search)?;

          continue;
        }
      }

      if config.allow_missing
        && matches!(
          result,
          Err(Error::UnknownRecipe { .. } | Error::UnknownSubmodule { .. })
        )
      {
        return Ok(());
      }

      return result;
    }
  }

  fn compile<'src>(
    config: &Config,
    loader: &'src Loader,
    search: &Search,
  ) -> RunResult<'src, Compilation<'src>> {
    let compilation = Compiler::compile(config, loader, &search.justfile)?;

    compilation.justfile.check_unstable(config)?;

    if config.verbosity.loud() {
      for warning in &compilation.justfile.warnings {
        eprintln!("{}", warning.color_display(config.color.stderr()));
      }
    }

    Ok(compilation)
  }

  fn changelog() {
    write!(io::stdout(), "{}", include_str!("../CHANGELOG.md")).ok();
  }

  fn choose<'src>(
    chooser: Option<&Path>,
    config: &Config,
    justfile: &Justfile<'src>,
    overrides: &HashMap<Number, String>,
    search: &Search,
  ) -> RunResult<'src> {
    let groups = config.groups.iter().cloned().collect::<BTreeSet<String>>();
    let mut recipes = Vec::<&Recipe>::new();
    let mut stack = vec![justfile];
    while let Some(module) = stack.pop() {
      recipes.extend(module.public_recipes(config).iter().filter(|recipe| {
        recipe.min_arguments() == 0
          && (groups.is_empty() || groups.intersection(&recipe.groups()).next().is_some())
      }));
      stack.extend(module.modules.values());
    }

    if recipes.is_empty() {
      return Err(Error::NoChoosableRecipes);
    }

    let chooser = if let Some(chooser) = chooser {
      OsString::from(chooser)
    } else {
      let mut chooser = OsString::new();
      chooser.push("fzf --multi --preview 'just --unstable --color always --justfile \"");
      chooser.push(&search.justfile);
      chooser.push("\" --show {}'");
      chooser
    };

    let result = justfile
      .settings
      .shell_command(config)
      .arg(&chooser)
      .current_dir(&search.working_directory)
      .stdin(Stdio::piped())
      .stdout(Stdio::piped())
      .spawn();

    let mut child = match result {
      Ok(child) => child,
      Err(io_error) => {
        let (shell_binary, shell_arguments) = justfile.settings.shell(config);
        return Err(Error::ChooserInvoke {
          shell_binary: shell_binary.to_owned(),
          shell_arguments: shell_arguments.join(" "),
          chooser,
          io_error,
        });
      }
    };

    let stdin = child.stdin.as_mut().unwrap();
    for recipe in recipes {
      if let Err(io_error) = writeln!(stdin, "{}", recipe.spaced_recipe_path()) {
        if io_error.kind() != std::io::ErrorKind::BrokenPipe {
          return Err(Error::ChooserWrite { io_error, chooser });
        }
      }
    }

    let output = match child.wait_with_output() {
      Ok(output) => output,
      Err(io_error) => {
        return Err(Error::ChooserRead { io_error, chooser });
      }
    };

    if output.status.code() == Some(CHOOSER_CANCELLED_EXIT_STATUS) {
      return Ok(());
    }

    if !output.status.success() {
      return Err(Error::ChooserStatus {
        status: output.status,
        chooser,
      });
    }

    let stdout = String::from_utf8_lossy(&output.stdout);

    for line in stdout.lines() {
      let arguments = line
        .split_whitespace()
        .map(str::to_owned)
        .collect::<Vec<String>>();

      justfile.run(config, search, &arguments, overrides)?;
    }

    Ok(())
  }

  fn completions(shell: Shell) {
    print!("{}", shell.completion_script());
  }

  fn dump(config: &Config, compilation: Compilation, format: DumpFormat) -> RunResult<'static> {
    match format {
      DumpFormat::Json => {
        serde_json::to_writer(io::stdout(), &compilation.justfile)
          .map_err(|source| Error::DumpJson { source })?;
        println!();
      }
      DumpFormat::Just => print!(
        "{}",
        compilation
          .root_ast()
          .color_display(config.color.use_color(UseColor::Never))
      ),
    }
    Ok(())
  }

  fn edit(search: &Search) -> RunResult<'static> {
    let editor = env::var_os("VISUAL")
      .or_else(|| env::var_os("EDITOR"))
      .unwrap_or_else(|| "vim".into());

    let error = Command::resolve(&editor)
      .current_dir(&search.working_directory)
      .arg(&search.justfile)
      .status();

    let status = match error {
      Err(io_error) => return Err(Error::EditorInvoke { editor, io_error }),
      Ok(status) => status,
    };

    if !status.success() {
      return Err(Error::EditorStatus { editor, status });
    }

    Ok(())
  }

  fn format<'src>(config: &Config, loader: &'src Loader, search: &Search) -> RunResult<'src> {
    let root = search.justfile.parent().unwrap();

    let (path, src) = loader.load(config, root, &search.justfile)?;

    let ast = Parser::parse_source(
      &mut Numerator::new(),
      path,
      &Source::root(&search.justfile),
      src,
    )?;

    let formatted = ast
      .color_display(config.color.use_color(UseColor::Never))
      .to_string();

    if formatted == src {
      return Ok(());
    }

    if config.check {
      if !config.verbosity.quiet() {
        use similar::{ChangeTag, TextDiff};

        let diff = TextDiff::configure()
          .algorithm(similar::Algorithm::Patience)
          .diff_lines(src, &formatted);

        for op in diff.ops() {
          for change in diff.iter_changes(op) {
            let (symbol, color) = match change.tag() {
              ChangeTag::Delete => ("-", config.color.stdout().diff_deleted()),
              ChangeTag::Equal => (" ", config.color.stdout()),
              ChangeTag::Insert => ("+", config.color.stdout().diff_added()),
            };

            print!("{}{symbol}{change}{}", color.prefix(), color.suffix());
          }
        }
      }

      Err(Error::FormatCheckFoundDiff)
    } else {
      fs::write(&search.justfile, formatted).map_err(|io_error| Error::WriteJustfile {
        justfile: search.justfile.clone(),
        io_error,
      })?;

      if config.verbosity.loud() {
        eprintln!("Wrote justfile to `{}`", search.justfile.display());
      }

      Ok(())
    }
  }

  fn init(config: &Config) -> RunResult<'static> {
    let search = Search::init(config)?;

    if filesystem::is_file(&search.justfile)? {
      return Err(Error::InitExists {
        justfile: search.justfile,
      });
    }

    if let Err(io_error) = fs::write(&search.justfile, INIT_JUSTFILE) {
      return Err(Error::WriteJustfile {
        justfile: search.justfile,
        io_error,
      });
    }

    if config.verbosity.loud() {
      eprintln!("Wrote justfile to `{}`", search.justfile.display());
    }

    Ok(())
  }

  fn man() -> RunResult<'static> {
    let mut buffer = Vec::<u8>::new();

    Man::new(Arguments::command())
      .render(&mut buffer)
      .expect("writing to buffer cannot fail");

    let mut stdout = io::stdout().lock();

    stdout
      .write_all(&buffer)
      .map_err(|io_error| Error::StdoutIo { io_error })?;

    stdout
      .flush()
      .map_err(|io_error| Error::StdoutIo { io_error })?;

    Ok(())
  }

  fn request(request: &Request) -> RunResult<'static> {
    let response = match request {
      Request::EnvironmentVariable(key) => Response::EnvironmentVariable(env::var_os(key)),
      #[cfg(not(windows))]
      Request::Signal => {
        let sigset = nix::sys::signal::SigSet::all();

        sigset.thread_block().unwrap();

        let received = sigset.wait().unwrap();

        Response::Signal(received.as_str().into())
      }
    };

    serde_json::to_writer(io::stdout(), &response).map_err(|source| Error::DumpJson { source })?;

    Ok(())
  }

  fn list(config: &Config, mut module: &Justfile, path: &Modulepath) -> RunResult<'static> {
    for name in &path.components {
      module = module
        .modules
        .get(name)
        .ok_or_else(|| Error::UnknownSubmodule {
          path: path.to_string(),
        })?;
    }

    Self::list_module(config, 0, &config.groups, module)?;

    Ok(())
  }

  fn list_module(
    config: &Config,
    depth: usize,
    groups: &[String],
    module: &Justfile,
  ) -> RunResult<'static> {
    fn print_doc_and_aliases(
      config: &Config,
      name: &str,
      doc: Option<&str>,
      aliases: &[&str],
      max_signature_width: usize,
      signature_widths: &BTreeMap<&str, usize>,
    ) {
      let color = config.color.stdout();

      let inline_aliases = config.alias_style != AliasStyle::Separate && !aliases.is_empty();

      if inline_aliases || doc.is_some() {
        print!(
          "{:padding$}{}",
          "",
          color.doc().paint("#"),
          padding = max_signature_width.saturating_sub(signature_widths[name]) + 1,
        );
      }

      let print_aliases = || {
        print!(
          " {}",
          color.alias().paint(&format!(
            "[alias{}: {}]",
            if aliases.len() == 1 { "" } else { "es" },
            aliases.join(", ")
          ))
        );
      };

      if inline_aliases && config.alias_style == AliasStyle::Left {
        print_aliases();
      }

      if let Some(doc) = doc {
        print!(" ");
        let mut end = 0;
        for backtick in BACKTICK_RE.find_iter(doc) {
          let prefix = &doc[end..backtick.start()];
          if !prefix.is_empty() {
            print!("{}", color.doc().paint(prefix));
          }
          print!("{}", color.doc_backtick().paint(backtick.as_str()));
          end = backtick.end();
        }

        let suffix = &doc[end..];
        if !suffix.is_empty() {
          print!("{}", color.doc().paint(suffix));
        }
      }

      if inline_aliases && config.alias_style == AliasStyle::Right {
        print_aliases();
      }

      println!();
    }

    let aliases = if config.no_aliases {
      BTreeMap::new()
    } else {
      let mut aliases = BTreeMap::<&str, Vec<&str>>::new();
      for alias in module.aliases.values().filter(|alias| alias.is_public()) {
        aliases
          .entry(alias.target.name.lexeme())
          .or_default()
          .push(alias.name.lexeme());
      }
      aliases
    };

    let signature_widths = {
      let mut signature_widths: BTreeMap<&str, usize> = BTreeMap::new();

      for (name, recipe) in &module.recipes {
        if !recipe.is_public() {
          continue;
        }

        for name in iter::once(name).chain(aliases.get(name).unwrap_or(&Vec::new())) {
          signature_widths.insert(
            name,
            UnicodeWidthStr::width(
              RecipeSignature { name, recipe }
                .color_display(Color::never())
                .to_string()
                .as_str(),
            ),
          );
        }
      }
      if !config.list_submodules {
        for submodule in module.public_modules(config) {
          let name = submodule.name();
          signature_widths.insert(name, UnicodeWidthStr::width(format!("{name} ...").as_str()));
        }
      }

      signature_widths
    };

    let max_signature_width = signature_widths
      .values()
      .copied()
      .filter(|width| *width <= 50)
      .max()
      .unwrap_or(0);

    let list_prefix = config.list_prefix.repeat(depth + 1);

    if !groups.is_empty() {
      let public_groups = module.public_groups(config);
      for group in groups {
        if !public_groups.contains(group) {
          return Err(Error::UnknownGroup {
            group: group.clone(),
          });
        }
      }
    }

    if depth == 0 {
      print!("{}", config.list_heading);
    }

    let recipe_groups = {
      let mut recipe_groups = BTreeMap::<Option<String>, Vec<&Recipe>>::new();
      for recipe in module.public_recipes(config) {
        let recipe_groups_list = recipe.groups();
        if recipe_groups_list.is_empty() {
          recipe_groups.entry(None).or_default().push(recipe);
        } else {
          for group in recipe_groups_list {
            recipe_groups.entry(Some(group)).or_default().push(recipe);
          }
        }
      }
      recipe_groups
    };

    let submodule_groups = {
      let mut submodule_groups = BTreeMap::<Option<String>, Vec<&Justfile>>::new();
      for submodule in module.public_modules(config) {
        let submodule_groups_list = submodule.groups();
        if submodule_groups_list.is_empty() {
          submodule_groups.entry(None).or_default().push(submodule);
        } else {
          for group in submodule_groups_list {
            submodule_groups
              .entry(Some(group.to_string()))
              .or_default()
              .push(submodule);
          }
        }
      }
      submodule_groups
    };

    let mut ordered_groups = if groups.is_empty() {
      module
        .public_groups(config)
        .into_iter()
        .map(Some)
        .collect::<Vec<Option<String>>>()
    } else {
      groups
        .iter()
        .cloned()
        .map(Some)
        .collect::<Vec<Option<String>>>()
    };

    if groups.is_empty()
      && (recipe_groups.contains_key(&None) || submodule_groups.contains_key(&None))
    {
      ordered_groups.insert(0, None);
    }

    let no_groups =
      groups.is_empty() && ordered_groups.len() == 1 && ordered_groups.first() == Some(&None);

    let groups_count = if no_groups { 0 } else { ordered_groups.len() };

    for (i, group) in ordered_groups.into_iter().enumerate() {
      if i > 0 {
        println!();
      }

      if !no_groups {
        if let Some(group) = &group {
          println!(
            "{list_prefix}{}",
            config.color.stdout().group().paint(&format!("[{group}]"))
          );
        }
      }

      if let Some(recipes) = recipe_groups.get(&group) {
        for recipe in recipes {
          let recipe_alias_entries = if config.alias_style == AliasStyle::Separate {
            aliases.get(recipe.name())
          } else {
            None
          };

          for (i, name) in iter::once(&recipe.name())
            .chain(recipe_alias_entries.unwrap_or(&Vec::new()))
            .enumerate()
          {
            let doc = if i == 0 {
              recipe.doc().map(Cow::Borrowed)
            } else {
              Some(Cow::Owned(format!("alias for `{}`", recipe.name)))
            };

            if let Some(doc) = &doc {
              if doc.lines().count() > 1 {
                for line in doc.lines() {
                  println!(
                    "{list_prefix}{} {}",
                    config.color.stdout().doc().paint("#"),
                    config.color.stdout().doc().paint(line),
                  );
                }
              }
            }

            print!(
              "{list_prefix}{}",
              RecipeSignature { name, recipe }.color_display(config.color.stdout())
            );

            print_doc_and_aliases(
              config,
              name,
              doc.filter(|doc| doc.lines().count() <= 1).as_deref(),
              aliases
                .get(recipe.name())
                .map(Vec::as_slice)
                .unwrap_or_default(),
              max_signature_width,
              &signature_widths,
            );
          }
        }
      }

      if let Some(submodules) = submodule_groups.get(&group) {
        for (i, submodule) in submodules.iter().enumerate() {
          if config.list_submodules {
            if no_groups && (i + groups_count > 0) {
              println!();
            }
            println!("{list_prefix}{}:", submodule.name());

            Self::list_module(config, depth + 1, &[], submodule)?;
          } else {
            print!("{list_prefix}{} ...", submodule.name());
            print_doc_and_aliases(
              config,
              submodule.name(),
              submodule.doc.as_deref(),
              &[],
              max_signature_width,
              &signature_widths,
            );
          }
        }
      }
    }

    Ok(())
  }

  fn show<'src>(config: &Config, module: &Justfile<'src>, path: &Modulepath) -> RunResult<'src> {
    let (alias, recipe) = Self::resolve_path(module, path)?;

    if let Some(alias) = alias {
      println!("{alias}");
    }

    println!("{}", recipe.color_display(config.color.stdout()));

    Ok(())
  }

  fn summary(config: &Config, justfile: &Justfile) {
    let recipes = justfile.public_recipes_recursive(config);

    for (i, recipe) in recipes.iter().enumerate() {
      if i > 0 {
        print!(" ");
      }
      print!("{}", recipe.recipe_path());
    }
    println!();

    if recipes.is_empty() && config.verbosity.loud() {
      eprintln!("justfile contains no recipes");
    }
  }

  pub(crate) fn takes_arguments(&self) -> bool {
    match self {
      Self::Changelog
      | Self::Dump { .. }
      | Self::Edit
      | Self::Format
      | Self::Init
      | Self::Man
      | Self::Summary
      | Self::Variables => false,
      Self::Choose { .. }
      | Self::Command { .. }
      | Self::Completions { .. }
      | Self::Evaluate { .. }
      | Self::Groups
      | Self::List { .. }
      | Self::Request { .. }
      | Self::Run { .. }
      | Self::Show { .. }
      | Self::Usage { .. } => true,
    }
  }

  fn usage<'src>(config: &Config, module: &Justfile<'src>, path: &Modulepath) -> RunResult<'src> {
    let (alias, recipe) = Self::resolve_path(module, path)?;

    if let Some(alias) = alias {
      println!("{alias}");
    }

    println!(
      "{}",
      Usage {
        long: true,
        path,
        recipe,
      }
      .color_display(config.color.stdout()),
    );

    Ok(())
  }

  fn resolve_path<'src, 'run>(
    mut module: &'run Justfile<'src>,
    path: &Modulepath,
  ) -> RunResult<'src, (Option<&'run Alias<'src>>, &'run Recipe<'src>)> {
    for name in &path.components[0..path.components.len() - 1] {
      module = module
        .modules
        .get(name)
        .ok_or_else(|| Error::UnknownSubmodule {
          path: path.to_string(),
        })?;
    }

    let name = path.components.last().unwrap();

    if let Some(alias) = module.get_alias(name) {
      Ok((Some(alias), &alias.target))
    } else if let Some(recipe) = module.get_recipe(name) {
      Ok((None, recipe))
    } else {
      Err(Error::UnknownRecipe {
        recipe: name.to_owned(),
        suggestion: module.suggest_recipe(name),
      })
    }
  }

  fn variables(justfile: &Justfile) {
    for (i, (_, assignment)) in justfile
      .assignments
      .iter()
      .filter(|(_, binding)| !binding.private)
      .enumerate()
    {
      if i > 0 {
        print!(" ");
      }
      print!("{}", assignment.name);
    }
    println!();
  }
}

#[cfg(test)]
mod tests {
  use super::*;

  #[test]
  fn init_justfile() {
    testing::compile(INIT_JUSTFILE);
  }
}
