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

use std::collections::HashMap;
use std::collections::HashSet;
use std::fmt::Write as _;

use console_static_text::TextItem;
use deno_core::anyhow;
use deno_semver::Version;
use deno_semver::VersionReq;
use deno_terminal::colors;

use crate::tools::pm::deps::DepId;
use crate::tools::pm::deps::DepKind;
use crate::tools::pm::interactive_picker;

#[derive(Debug)]
pub struct PackageInfo {
  pub id: DepId,
  pub current_version_req: VersionReq,
  pub new_version: VersionReq,
  pub name: String,
  pub kind: DepKind,
}

#[derive(Debug)]
struct FormattedPackageInfo {
  dep_ids: Vec<DepId>,
  current_version_string: String,
  new_version_highlighted: String,
  formatted_name: String,
  formatted_name_len: usize,
  name: String,
}

/// Strip a leading single-version operator (`^`, `~`, or `=`) so the bare
/// version can be parsed for highlighting and displayed consistently. Range
/// requirements such as `>=1.2.3` are left untouched, since stripping the
/// operator would surface a misleading bare version; they fall through to the
/// unhighlighted display instead.
fn strip_version_operator(version_text: &str) -> &str {
  version_text.trim_start_matches(['^', '~', '='])
}

#[derive(Debug)]
struct State {
  packages: Vec<FormattedPackageInfo>,
  name_width: usize,
  current_width: usize,
}

impl From<PackageInfo> for FormattedPackageInfo {
  fn from(package: PackageInfo) -> Self {
    let new_version_string =
      strip_version_operator(package.new_version.version_text());
    let current_version_string =
      strip_version_operator(package.current_version_req.version_text());

    let new_version_highlighted = match (
      Version::parse_standard(current_version_string),
      Version::parse_standard(new_version_string),
    ) {
      (Ok(current_version), Ok(new_version)) => {
        highlight_new_version(&current_version, &new_version)
      }
      _ => new_version_string.to_string(),
    };
    FormattedPackageInfo {
      dep_ids: vec![package.id],
      current_version_string: current_version_string.to_string(),
      new_version_highlighted,
      formatted_name: format!(
        "{}{}",
        colors::gray(format!("{}:", package.kind.scheme())),
        package.name
      ),
      formatted_name_len: package.kind.scheme().len() + 1 + package.name.len(),
      name: package.name,
    }
  }
}

impl State {
  fn new(packages: Vec<PackageInfo>) -> anyhow::Result<Self> {
    let mut deduped_packages: HashMap<
      (String, VersionReq, VersionReq),
      FormattedPackageInfo,
    > = HashMap::with_capacity(packages.len());
    for package in packages {
      match deduped_packages.entry((
        package.name.clone(),
        package.current_version_req.clone(),
        package.new_version.clone(),
      )) {
        std::collections::hash_map::Entry::Occupied(mut occupied_entry) => {
          occupied_entry.get_mut().dep_ids.push(package.id)
        }
        std::collections::hash_map::Entry::Vacant(vacant_entry) => {
          vacant_entry.insert(FormattedPackageInfo::from(package));
        }
      }
    }

    let mut packages: Vec<_> = deduped_packages.into_values().collect();
    packages.sort_by(|a, b| a.name.cmp(&b.name));
    let name_width = packages
      .iter()
      .map(|p| p.formatted_name_len)
      .max()
      .unwrap_or_default();
    let current_width = packages
      .iter()
      .map(|p| p.current_version_string.len())
      .max()
      .unwrap_or_default();

    Ok(Self {
      packages,
      name_width,
      current_width,
    })
  }

  fn instructions_line() -> &'static str {
    "Select which packages to update (<space> to select, ↑/↓/j/k to navigate, a to select all, i to invert selection, enter to accept, <Ctrl-c> to cancel)"
  }
}

enum VersionDifference {
  Major,
  Minor,
  Patch,
  Prerelease,
}

fn version_diff(a: &Version, b: &Version) -> VersionDifference {
  if a.major != b.major {
    VersionDifference::Major
  } else if a.minor != b.minor {
    VersionDifference::Minor
  } else if a.patch != b.patch {
    VersionDifference::Patch
  } else {
    VersionDifference::Prerelease
  }
}

fn highlight_new_version(current: &Version, new: &Version) -> String {
  let diff = version_diff(current, new);

  let new_pre = if new.pre.is_empty() {
    String::new()
  } else {
    let mut s = String::new();
    s.push('-');
    for p in &new.pre {
      s.push_str(p);
    }
    s
  };

  match diff {
    VersionDifference::Major => format!(
      "{}.{}.{}{}",
      colors::red_bold(new.major),
      colors::red_bold(new.minor),
      colors::red_bold(new.patch),
      colors::red_bold(new_pre)
    ),
    VersionDifference::Minor => format!(
      "{}.{}.{}{}",
      new.major,
      colors::yellow_bold(new.minor),
      colors::yellow_bold(new.patch),
      colors::yellow_bold(new_pre)
    ),
    VersionDifference::Patch => format!(
      "{}.{}.{}{}",
      new.major,
      new.minor,
      colors::green_bold(new.patch),
      colors::green_bold(new_pre)
    ),
    VersionDifference::Prerelease => format!(
      "{}.{}.{}{}",
      new.major,
      new.minor,
      new.patch,
      colors::red_bold(new_pre)
    ),
  }
}

fn render_package(
  package: &FormattedPackageInfo,
  name_width: usize,
  current_width: usize,
  is_selected: bool,
  is_checked: bool,
) -> anyhow::Result<TextItem<'static>> {
  let mut line = String::new();
  let f = &mut line;

  write!(
    f,
    "{} {} ",
    if is_selected {
      colors::intense_blue("❯").to_string()
    } else {
      " ".to_string()
    },
    if is_checked { "●" } else { "○" }
  )?;

  let name_pad = " ".repeat(name_width + 2 - package.formatted_name_len);
  write!(
    f,
    "{formatted_name}{name_pad} {:<current_width$} -> {}",
    &package.current_version_string,
    &package.new_version_highlighted,
    name_pad = name_pad,
    formatted_name = package.formatted_name,
    current_width = current_width
  )?;

  Ok(TextItem::with_hanging_indent_owned(line, 1))
}

pub fn select_interactive(
  packages: Vec<PackageInfo>,
) -> anyhow::Result<Option<HashSet<DepId>>> {
  let state = State::new(packages)?;
  let name_width = state.name_width;
  let current_width = state.current_width;
  let packages = state.packages;

  let selected = interactive_picker::select_items(
    State::instructions_line(),
    &packages,
    HashSet::new(),
    |_idx, is_selected, is_checked, package| {
      render_package(
        package,
        name_width,
        current_width,
        is_selected,
        is_checked,
      )
    },
  )?;

  Ok(selected.map(|indices| {
    indices
      .into_iter()
      .flat_map(|idx| &packages[idx].dep_ids)
      .copied()
      .collect()
  }))
}
