use std::{
  cmp::Ordering,
  fmt::{
    self,
    Write as _,
  },
  num::NonZeroUsize,
};

use dix_diff::VersionPiece;
use itertools::{
  EitherOrBoth,
  Itertools,
};
use size::Size;
use unicode_width::UnicodeWidthStr as _;
use yansi::{
  Paint as _,
  Painted,
};

use crate::{
  DerivationSelectionStatus,
  DiffReport,
  DiffStatus,
  PackageDiff,
  PackageSizeDelta,
  PathStats,
  Version,
  VersionAmount,
  VersionDiff,
};

/// Writes a full diff report to the provided writer.
///
/// # Returns
///
/// Returns the number of package diffs written.
///
/// # Errors
///
/// Returns an error if writing to the output fails.
pub fn write_diff_report(
  writer: &mut impl fmt::Write,
  report: &DiffReport,
) -> Result<usize, fmt::Error> {
  writeln!(writer)?;

  let wrote = render_package_diffs(writer, report.diffs())?;

  if wrote > 0 {
    writeln!(writer)?;
  }

  write_path_stats(writer, report.path_stats())?;
  write_size_diff(writer, report.size_old(), report.size_new())?;

  Ok(wrote)
}

fn render_package_diffs(
  writer: &mut impl fmt::Write,
  diffs: &[PackageDiff],
) -> Result<usize, fmt::Error> {
  let mut diffs = diffs.iter().collect::<Vec<_>>();
  diffs.sort_by(|a, b| {
    status_group(a.status)
      .cmp(&status_group(b.status))
      .then_with(|| a.name.cmp(&b.name))
      .then_with(|| a.status.cmp(&b.status))
  });

  render_diffs(writer, &diffs)
}

fn render_diffs(
  writer: &mut impl fmt::Write,
  diffs: &[&PackageDiff],
) -> Result<usize, fmt::Error> {
  let name_width = diffs
    .iter()
    .map(|diff| diff.name.width())
    .max()
    .unwrap_or(0)
    + 1;
  let mut last_status_group = None::<StatusGroup>;

  for diff in diffs {
    let group = status_group(diff.status);
    if last_status_group.is_none_or(|last_group| last_group != group) {
      if last_status_group.is_some() {
        writeln!(writer)?;
      }

      let header = match group {
        StatusGroup::Changed => "CHANGED",
        StatusGroup::Added => "ADDED",
        StatusGroup::Removed => "REMOVED",
      }
      .bold();

      writeln!(writer, "{header}")?;
      last_status_group = Some(group);
    }

    let status_char = status_char(diff.status);
    let selection_char = selection_char(diff.selection);
    let name_painted = diff.name.paint(selection_char.style);

    write!(
      writer,
      "[{status_char}{selection_char}] {name_painted:<name_width$}"
    )?;

    let (old_str, new_str) =
      fmt_version_diffs(&diff.versions, diff.has_omitted_versions)?;
    let arrow = if !old_str.is_empty() && !new_str.is_empty() {
      " -> "
    } else {
      ""
    };
    let size_delta = fmt_package_size_delta(diff.size);
    if old_str.is_empty() && new_str.is_empty() {
      writeln!(writer, "{size_delta}")?;
    } else if size_delta.is_empty() {
      writeln!(writer, "{old_str}{arrow}{new_str}")?;
    } else {
      writeln!(writer, "{old_str}{arrow}{new_str}, {size_delta}")?;
    }
  }

  Ok(diffs.len())
}

fn fmt_package_size_delta(size: PackageSizeDelta) -> String {
  if !size.is_significant() {
    return String::new();
  }

  let delta = size.delta();
  if delta.bytes() > 0 {
    format!("+{delta}").bright_cyan().to_string()
  } else {
    delta.magenta().to_string()
  }
}

fn write_size_diff(
  writer: &mut impl fmt::Write,
  size_old: Size,
  size_new: Size,
) -> fmt::Result {
  let size_diff = size_new - size_old;

  writeln!(
    writer,
    "{header}: {size_old} -> {size_new}",
    header = "SIZE".bold(),
    size_old = size_old.red(),
    size_new = size_new.green(),
  )?;

  writeln!(
    writer,
    "{header}: {size_diff}",
    header = "DIFF".bold(),
    size_diff = match size_diff.bytes().cmp(&0) {
      Ordering::Less => size_diff.green(),
      Ordering::Equal => size_diff.resetting(),
      Ordering::Greater => size_diff.red(),
    },
  )
}

fn write_path_stats(
  writer: &mut impl fmt::Write,
  stats: PathStats,
) -> fmt::Result {
  let added = format!("+{}", stats.added_count());
  let removed = format!("-{}", stats.removed_count());

  writeln!(
    writer,
    "{header}: {old} -> {new} ({added}, {removed})",
    header = "PATHS".bold(),
    old = stats.old_count().red(),
    new = stats.new_count().green(),
    added = Painted::new(added.as_str()).green(),
    removed = Painted::new(removed.as_str()).red(),
  )
}

fn status_char(status: DiffStatus) -> Painted<&'static char> {
  match status {
    DiffStatus::Changed | DiffStatus::Mixed => 'C'.yellow().bold(),
    DiffStatus::Upgraded => 'U'.bright_cyan().bold(),
    DiffStatus::Downgraded => 'D'.magenta().bold(),
    DiffStatus::Added => 'A'.green().bold(),
    DiffStatus::Removed => 'R'.red().bold(),
  }
}

#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum StatusGroup {
  Changed,
  Added,
  Removed,
}

const fn status_group(status: DiffStatus) -> StatusGroup {
  match status {
    DiffStatus::Changed
    | DiffStatus::Mixed
    | DiffStatus::Upgraded
    | DiffStatus::Downgraded => StatusGroup::Changed,
    DiffStatus::Added => StatusGroup::Added,
    DiffStatus::Removed => StatusGroup::Removed,
  }
}

fn selection_char(status: DerivationSelectionStatus) -> Painted<&'static char> {
  match status {
    DerivationSelectionStatus::Selected => '*'.bold(),
    DerivationSelectionStatus::NewlySelected => '+'.bold(),
    DerivationSelectionStatus::Unselected => Painted::new(&'.'),
    DerivationSelectionStatus::NewlyUnselected => Painted::new(&'-'),
  }
}

fn fmt_version_diffs(
  version_diffs: &[VersionDiff],
  has_omitted_versions: bool,
) -> Result<(String, String), fmt::Error> {
  let mut old_acc = String::new();
  let mut new_acc = String::new();

  let mut old_wrote = false;
  let mut new_wrote = false;

  let append_sep = |acc: &mut String, wrote: &mut bool| {
    if *wrote {
      write!(acc, ", ")
    } else {
      *wrote = true;
      Ok(())
    }
  };

  #[expect(clippy::redundant_closure_for_method_calls)]
  for diff in version_diffs {
    match diff {
      VersionDiff::Removed(old) => {
        append_sep(&mut old_acc, &mut old_wrote)?;
        write_version_amount(&mut old_acc, old, |value| value.red())?;
      },
      VersionDiff::Added(new) => {
        append_sep(&mut new_acc, &mut new_wrote)?;
        write_version_amount(&mut new_acc, new, |value| value.green())?;
      },
      VersionDiff::Changed { old, new } => {
        if old == new {
          continue;
        }

        append_sep(&mut old_acc, &mut old_wrote)?;
        append_sep(&mut new_acc, &mut new_wrote)?;

        fmt_single_version_diff(
          &mut old_acc,
          &mut new_acc,
          &old.version,
          &new.version,
        )?;
        write_changed_amounts(
          &mut old_acc,
          &mut new_acc,
          old.amount,
          new.amount,
        )?;
      },
      VersionDiff::AmountChanged {
        version,
        old_amount,
        new_amount,
      } => {
        append_sep(&mut old_acc, &mut old_wrote)?;
        append_sep(&mut new_acc, &mut new_wrote)?;

        write_version(&mut old_acc, version, |value| value.yellow())?;
        write_version(&mut new_acc, version, |value| value.yellow())?;
        write_amount_suffix(&mut old_acc, *old_amount, |value| value.red())?;
        write_amount_suffix(&mut new_acc, *new_amount, |value| value.green())?;
      },
    }
  }
  if has_omitted_versions {
    let unchanged_str = "<unchanged>".blue().italic().to_string();
    append_sep(&mut old_acc, &mut old_wrote)?;
    append_sep(&mut new_acc, &mut new_wrote)?;
    write!(old_acc, "{unchanged_str}")?;
    write!(new_acc, "{unchanged_str}")?;
  }

  Ok((old_acc, new_acc))
}

fn write_version_amount(
  buf: &mut String,
  version: &VersionAmount,
  style: impl Copy + Fn(Painted<&str>) -> Painted<&str>,
) -> fmt::Result {
  write_version(buf, &version.version, style)?;
  write_amount_suffix(buf, version.amount, style)
}

fn write_version(
  buf: &mut String,
  version: &Version,
  style: impl Copy + Fn(Painted<&str>) -> Painted<&str>,
) -> fmt::Result {
  for piece in version {
    write_version_piece(buf, &piece, style)?;
  }

  Ok(())
}

fn write_amount_suffix(
  buf: &mut String,
  amount: NonZeroUsize,
  style: impl Fn(Painted<&str>) -> Painted<&str>,
) -> fmt::Result {
  if amount.get() > 1 {
    let amount = amount.get().to_string();
    write!(buf, " ×{}", style(Painted::new(amount.as_str())))?;
  }

  Ok(())
}

#[expect(clippy::redundant_closure_for_method_calls)]
fn write_changed_amounts(
  old_acc: &mut String,
  new_acc: &mut String,
  old_amount: NonZeroUsize,
  new_amount: NonZeroUsize,
) -> fmt::Result {
  if old_amount == new_amount {
    write_amount_suffix(old_acc, old_amount, |value| value.yellow())?;
    write_amount_suffix(new_acc, new_amount, |value| value.yellow())
  } else {
    write_amount_suffix(old_acc, old_amount, |value| value.red())?;
    write_amount_suffix(new_acc, new_amount, |value| value.green())
  }
}

fn write_version_piece(
  buf: &mut String,
  piece: &VersionPiece,
  style: impl Fn(Painted<&str>) -> Painted<&str>,
) -> fmt::Result {
  match *piece {
    VersionPiece::Component(component) => {
      write!(buf, "{}", style(Painted::new(*component)))
    },
    VersionPiece::Separator(separator) => write!(buf, "{separator}"),
  }
}

fn fmt_single_version_diff(
  old_acc: &mut String,
  new_acc: &mut String,
  old_ver: &Version,
  new_ver: &Version,
) -> fmt::Result {
  let old_parts: Vec<_> = old_ver.into_iter().collect();
  let new_parts: Vec<_> = new_ver.into_iter().collect();

  if (old_parts.is_empty() && new_parts.is_empty()) || (old_ver == new_ver) {
    return Ok(());
  }

  let prefix_len = old_parts
    .iter()
    .zip(&new_parts)
    .take_while(|&(old_part, new_part)| old_part == new_part)
    .count();

  let old_remainder = &old_parts[prefix_len..];
  let new_remainder = &new_parts[prefix_len..];

  let suffix_len = if !old_remainder.is_empty() && !new_remainder.is_empty() {
    old_remainder
      .iter()
      .rev()
      .zip(new_remainder.iter().rev())
      .take_while(|&(old_part, new_part)| old_part == new_part)
      .count()
  } else {
    0
  };

  let prefix = &old_parts[..prefix_len];
  let old_diff_end = old_parts.len() - suffix_len;
  let new_diff_end = new_parts.len() - suffix_len;

  let old_diff = &old_parts[prefix_len..old_diff_end];
  let new_diff = &new_parts[prefix_len..new_diff_end];
  let suffix = if suffix_len > 0 {
    &old_parts[old_diff_end..]
  } else {
    &[]
  };

  #[expect(clippy::redundant_closure_for_method_calls)]
  for piece in prefix {
    write_version_piece(old_acc, piece, |value| value.yellow())?;
    write_version_piece(new_acc, piece, |value| value.yellow())?;
  }

  for pair in Itertools::zip_longest(old_diff.iter(), new_diff.iter()) {
    #[expect(clippy::redundant_closure_for_method_calls)]
    match pair {
      EitherOrBoth::Left(old) => {
        write_version_piece(old_acc, old, |value| value.red())?;
      },
      EitherOrBoth::Right(new) => {
        write_version_piece(new_acc, new, |value| value.green())?;
      },
      EitherOrBoth::Both(old, new) => {
        fmt_version_piece_pair(old_acc, new_acc, old, new)?;
      },
    }
  }

  #[expect(clippy::redundant_closure_for_method_calls)]
  for piece in suffix {
    write_version_piece(old_acc, piece, |value| value.yellow())?;
    write_version_piece(new_acc, piece, |value| value.yellow())?;
  }

  Ok(())
}

fn fmt_version_piece_pair(
  old_acc: &mut String,
  new_acc: &mut String,
  old_piece: &VersionPiece,
  new_piece: &VersionPiece,
) -> fmt::Result {
  if old_piece == new_piece {
    #[expect(clippy::redundant_closure_for_method_calls)]
    return {
      write_version_piece(old_acc, old_piece, |value| value.yellow())?;
      write_version_piece(new_acc, new_piece, |value| value.yellow())
    };
  }

  match (old_piece, new_piece) {
    (&VersionPiece::Component(old_c), &VersionPiece::Component(new_c)) => {
      if old_c.len() > 20
        && new_c.len() > 20
        && old_c
          .chars()
          .zip(new_c.chars())
          .all(|(old_char, new_char)| old_char != new_char)
      {
        write!(old_acc, "{}", old_c.red())?;
        write!(new_acc, "{}", new_c.green())?;
        return Ok(());
      }

      let char_diffs = diff::chars(*old_c, *new_c);
      let mut diff_active = false;

      for res in char_diffs {
        match res {
          diff::Result::Both(left, right) => {
            if diff_active {
              write!(old_acc, "{}", left.red())?;
              write!(new_acc, "{}", right.green())?;
            } else {
              write!(old_acc, "{}", left.yellow())?;
              write!(new_acc, "{}", right.yellow())?;
            }
          },
          diff::Result::Left(left) => {
            diff_active = true;
            write!(old_acc, "{}", left.red())?;
          },
          diff::Result::Right(right) => {
            diff_active = true;
            write!(new_acc, "{}", right.green())?;
          },
        }
      }
    },
    #[expect(clippy::redundant_closure_for_method_calls)]
    (old, new) => {
      write_version_piece(old_acc, old, |value| value.red())?;
      write_version_piece(new_acc, new, |value| value.green())?;
    },
  }
  Ok(())
}

#[cfg(test)]
mod tests {
  use std::num::NonZeroUsize;

  use super::*;

  fn amount(amount: usize) -> NonZeroUsize {
    NonZeroUsize::new(amount)
      .unwrap_or_else(|| panic!("test version amount must be nonzero"))
  }

  fn package_diff(name: &str, status: DiffStatus) -> PackageDiff {
    PackageDiff {
      name: name.to_owned(),
      versions: vec![VersionDiff::Changed {
        old: VersionAmount::new("1.0.0", amount(1)),
        new: VersionAmount::new("2.0.0", amount(1)),
      }],
      status,
      selection: DerivationSelectionStatus::Unselected,
      has_omitted_versions: false,
      size: PackageSizeDelta::new(Size::from_bytes(0), Size::from_bytes(0)),
    }
  }

  #[test]
  fn render_package_diffs_sorts_alphabetically_within_status_group() {
    yansi::disable();

    let diffs = [
      package_diff("zeta", DiffStatus::Changed),
      package_diff("alpha", DiffStatus::Upgraded),
      package_diff("mango", DiffStatus::Downgraded),
    ];
    let mut output = String::new();

    render_package_diffs(&mut output, &diffs).unwrap();

    let alpha = output.find("[U.] alpha").unwrap();
    let mango = output.find("[D.] mango").unwrap();
    let zeta = output.find("[C.] zeta").unwrap();
    assert!(alpha < mango);
    assert!(mango < zeta);
  }

  #[test]
  fn render_package_diffs_formats_package_size_delta_suffixes() {
    yansi::disable();

    let diffs = [
      PackageDiff {
        name:                 "linux-firmware".to_owned(),
        versions:             vec![VersionDiff::Changed {
          old: VersionAmount::new("20260221", amount(1)),
          new: VersionAmount::new("20260309", amount(1)),
        }],
        status:               DiffStatus::Upgraded,
        selection:            DerivationSelectionStatus::Unselected,
        has_omitted_versions: false,
        size:                 PackageSizeDelta::new(
          Size::from_bytes(100),
          Size::from_bytes(10_000),
        ),
      },
      PackageDiff {
        name:                 "source".to_owned(),
        versions:             Vec::new(),
        status:               DiffStatus::Changed,
        selection:            DerivationSelectionStatus::Unselected,
        has_omitted_versions: false,
        size:                 PackageSizeDelta::new(
          Size::from_bytes(10_000),
          Size::from_bytes(100),
        ),
      },
    ];
    let mut output = String::new();

    render_package_diffs(&mut output, &diffs).unwrap();

    assert!(
      output.contains("[U.] linux-firmware 20260221 -> 20260309, +9.67 KiB")
    );
    assert!(output.contains("[C.] source         -9.67 KiB"));
  }

  #[test]
  fn fmt_version_diffs_formats_added_and_removed_amounts() {
    yansi::disable();

    let version_diffs = [
      VersionDiff::Removed(VersionAmount::new("1.0.0", amount(2))),
      VersionDiff::Added(VersionAmount::new("2.0.0", amount(3))),
    ];

    let (old, new) = fmt_version_diffs(&version_diffs, false).unwrap();

    assert_eq!(old, "1.0.0 ×2");
    assert_eq!(new, "2.0.0 ×3");
  }

  #[test]
  fn fmt_version_diffs_formats_amount_changes() {
    yansi::disable();

    let version_diffs = [VersionDiff::AmountChanged {
      version:    Version::new("1.0.0"),
      old_amount: amount(1),
      new_amount: amount(2),
    }];

    let (old, new) = fmt_version_diffs(&version_diffs, false).unwrap();

    assert_eq!(old, "1.0.0");
    assert_eq!(new, "1.0.0 ×2");
  }

  #[test]
  fn write_path_stats_formats_exact_path_changes() {
    yansi::disable();

    let mut output = String::new();

    write_path_stats(
      &mut output,
      PathStats::new_for_test(7529, 7536, 5054, 5047),
    )
    .unwrap();

    assert_eq!(output, "PATHS: 7529 -> 7536 (+5054, -5047)\n");
  }
}
