use anyhow::{bail, Context, Result};
use git2::Repository;
use std::path::Path;

pub fn open(repo_root: &Path) -> Result<Repository> {
  Repository::open(repo_root).context("failed to open git repository")
}

// ---------------------------------------------------------------------------
// Dirty tree check
// ---------------------------------------------------------------------------

pub struct DirtyFile {
  /// Single-letter status, porcelain-style: M, D, A (new), R (renamed), ? else.
  pub status: char,
  pub path: String,
}

pub struct DirtyFiles {
  pub files: Vec<DirtyFile>,
}

impl DirtyFiles {
  pub fn is_empty(&self) -> bool {
    self.files.is_empty()
  }
}

/// Maps a git2 status to a single porcelain-style letter. Index (staged) state
/// takes precedence over worktree state, matching `git status` column ordering.
fn status_char(s: git2::Status) -> char {
  use git2::Status;
  if s.intersects(Status::INDEX_NEW) {
    'A'
  } else if s.intersects(Status::INDEX_RENAMED) {
    'R'
  } else if s.intersects(Status::INDEX_DELETED | Status::WT_DELETED) {
    'D'
  } else if s.intersects(Status::INDEX_MODIFIED | Status::WT_MODIFIED) {
    'M'
  } else {
    '?'
  }
}

/// Returns paths with a pending change relative to HEAD. When
/// `include_untracked` is false only tracked modifications are reported
/// (the usual "dirty tree" notion); when true, untracked files are included
/// too — used to guarantee the working tree exactly matches the committed
/// code before recording HEAD as the applied state. Git-ignored files are
/// always excluded.
pub fn dirty_files(
  repo: &Repository,
  include_untracked: bool,
) -> Result<DirtyFiles> {
  let mut opts = git2::StatusOptions::new();
  opts
    .include_untracked(include_untracked)
    .include_ignored(false)
    .exclude_submodules(true);

  let statuses = repo.statuses(Some(&mut opts))?;
  let files = statuses
    .iter()
    .filter(|e| !e.status().is_empty())
    .filter_map(|e| {
      e.path().map(|p| DirtyFile {
        status: status_char(e.status()),
        path: p.to_string(),
      })
    })
    .collect();

  Ok(DirtyFiles { files })
}

// ---------------------------------------------------------------------------
// Commit
// ---------------------------------------------------------------------------

pub fn commit_all(repo: &Repository, message: &str) -> Result<()> {
  let mut index = repo.index()?;
  index.add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)?;
  index.write()?;

  let oid = index.write_tree()?;
  let tree = repo.find_tree(oid)?;
  let sig = repo.signature()?;
  let parent = repo.head()?.peel_to_commit()?;

  repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent])?;
  Ok(())
}

/// Fold all working-tree changes into the current HEAD commit, keeping its
/// original message and parents (equivalent to `git commit --amend --no-edit -a`).
pub fn amend_all(repo: &Repository) -> Result<()> {
  let mut index = repo.index()?;
  index.add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)?;
  index.write()?;

  let oid = index.write_tree()?;
  let tree = repo.find_tree(oid)?;
  let head = repo.head()?.peel_to_commit()?;
  let message = head.message().unwrap_or("").to_string();

  head.amend(Some("HEAD"), None, None, None, Some(&message), Some(&tree))?;
  Ok(())
}

// ---------------------------------------------------------------------------
// Version (CalVer: deploy/vYY.MM.NNNN)
// ---------------------------------------------------------------------------

/// Compute next CalVer tag for an arbitrary prefix: <prefix>/vYY.MM.NNNN
pub fn next_calver(repo: &Repository, version_prefix: &str) -> Result<String> {
  let month = chrono::Utc::now().format("%y.%m").to_string();
  let tag_prefix = format!("{version_prefix}/v{month}.");

  let mut max_n: u32 = 0;
  repo
    .tag_names(Some(&format!("{tag_prefix}*")))?
    .iter()
    .flatten()
    .for_each(|tag| {
      if let Some(suffix) = tag.strip_prefix(&tag_prefix) {
        if let Ok(n) = suffix.parse::<u32>() {
          if n > max_n {
            max_n = n;
          }
        }
      }
    });

  Ok(format!("{version_prefix}/v{month}.{:04}", max_n + 1))
}

/// Compute next SemVer tag for an arbitrary prefix.
/// Scans existing tags matching `<prefix>/v*.*.*` to find the current latest
/// base, then prompts the user for patch/minor/major bump.
/// Returns `<prefix>/vX.Y.Z`.
pub fn next_semver(
  repo: &Repository,
  version_prefix: &str,
  repo_root: &std::path::Path,
) -> Result<String> {
  let pattern = format!("{version_prefix}/v*");
  let mut versions: Vec<(u32, u32, u32)> = repo
    .tag_names(Some(&pattern))?
    .iter()
    .flatten()
    .filter_map(|tag| {
      let stripped = tag.strip_prefix(&format!("{version_prefix}/v"))?;
      // Only match pure X.Y.Z (no build counter suffix)
      let parts: Vec<&str> = stripped.split('.').collect();
      if parts.len() != 3 {
        return None;
      }
      let major = parts[0].parse::<u32>().ok()?;
      let minor = parts[1].parse::<u32>().ok()?;
      let patch = parts[2].parse::<u32>().ok()?;
      Some((major, minor, patch))
    })
    .collect();

  versions.sort_by(|a, b| b.cmp(a));

  let (major, minor, patch) = versions.into_iter().next().unwrap_or((0, 0, 0));

  let patch_v = format!("v{major}.{minor}.{}", patch + 1);
  let minor_v = format!("v{major}.{}.0", minor + 1);
  let major_v = format!("v{}.0.0", major + 1);

  let items = [
    format!("patch  → {patch_v}"),
    format!("minor  → {minor_v}"),
    format!("major  → {major_v}"),
  ];

  let theme = dialoguer::theme::ColorfulTheme {
    active_item_style: dialoguer::console::Style::new().for_stderr().red(),
    active_item_prefix: dialoguer::console::style(">".to_string())
      .for_stderr()
      .red(),
    ..dialoguer::theme::ColorfulTheme::default()
  };

  let idx = dialoguer::Select::with_theme(&theme)
    .with_prompt(format!("select version bump for '{version_prefix}'"))
    .items(&items)
    .default(0)
    .interact_on(&dialoguer::console::Term::stderr())?;

  let version_str = match idx {
    0 => patch_v,
    1 => minor_v,
    _ => major_v,
  };

  let _ = repo_root; // available for future use
  Ok(format!("{version_prefix}/{version_str}"))
}

/// Call a custom version script, capture its stdout as the full tag.
/// The script receives DOGMA_VERSION_PREFIX as an env var.
pub fn next_custom(
  script_path: &str,
  version_prefix: &str,
  repo_root: &std::path::Path,
) -> Result<String> {
  let abs = repo_root.join(script_path);
  if !abs.exists() {
    anyhow::bail!("version_script not found: {script_path}");
  }

  let out = std::process::Command::new(&abs)
    .env("DOGMA_VERSION_PREFIX", version_prefix)
    .current_dir(repo_root)
    .output()
    .with_context(|| format!("failed to run version_script: {script_path}"))?;

  if !out.status.success() {
    anyhow::bail!("version_script failed: {script_path}");
  }

  let tag = String::from_utf8(out.stdout)
    .context("version_script output is not valid UTF-8")?
    .trim()
    .to_string();

  if tag.is_empty() {
    anyhow::bail!("version_script produced empty output: {script_path}");
  }

  Ok(tag)
}

// ---------------------------------------------------------------------------
// Tag operations
// ---------------------------------------------------------------------------

/// List all `<prefix>/v*` tags, sorted newest-first.
pub fn list_pipeline_tags(
  repo: &Repository,
  version_prefix: &str,
) -> Result<Vec<String>> {
  let mut tags: Vec<String> = repo
    .tag_names(Some(&format!("{version_prefix}/v*")))?
    .iter()
    .flatten()
    .map(str::to_string)
    .collect();

  tags.sort_by(|a, b| b.cmp(a));
  Ok(tags)
}

/// Returns pipeline tags sorted newest-first, each paired with a human-readable
/// relative date string. Falls back to empty string on any error.
pub fn list_pipeline_tags_with_date(
  repo: &Repository,
  version_prefix: &str,
) -> Result<Vec<(String, String)>> {
  let tags = list_pipeline_tags(repo, version_prefix)?;
  let pairs = tags
    .into_iter()
    .map(|name| {
      let date = tag_date(repo, &name).unwrap_or_default();
      (name, date)
    })
    .collect();
  Ok(pairs)
}

fn tag_date(repo: &Repository, name: &str) -> Option<String> {
  let obj = repo.revparse_single(name).ok()?;
  let time = if let Ok(tag) = obj.clone().into_tag() {
    tag.tagger()?.when()
  } else {
    obj.peel_to_commit().ok()?.time()
  };
  let secs = time.seconds();
  let dt = chrono::DateTime::<chrono::Utc>::from_timestamp(secs, 0)?
    .with_timezone(&chrono::Local);
  let now = chrono::Local::now();
  let diff = now.signed_duration_since(dt);
  let (n, unit) = if diff.num_seconds() < 60 {
    (0u64, "just now")
  } else if diff.num_minutes() < 60 {
    (diff.num_minutes() as u64, "m ago")
  } else if diff.num_hours() < 24 {
    (diff.num_hours() as u64, "h ago")
  } else if diff.num_days() < 30 {
    (diff.num_days() as u64, "d ago")
  } else if diff.num_days() < 365 {
    ((diff.num_days() / 30) as u64, "mo ago")
  } else {
    ((diff.num_days() / 365) as u64, "y ago")
  };
  let rel = if unit == "just now" {
    "just now".to_string()
  } else {
    format!("{n:>2}{unit}")
  };
  let relative = format!("{rel:<9}");
  Some(format!("{}  {}", dt.format("%Y-%m-%d %H:%M"), relative))
}

pub fn tag_exists(repo: &Repository, name: &str) -> Result<bool> {
  Ok(
    repo
      .tag_names(Some(name))?
      .iter()
      .flatten()
      .any(|t| t == name),
  )
}

pub fn create_annotated_tag(
  repo: &Repository,
  name: &str,
  message: &str,
) -> Result<()> {
  let head = repo.head()?.peel_to_commit()?;
  let sig = repo.signature()?;
  repo
    .tag(name, head.as_object(), &sig, message, false)
    .with_context(|| format!("failed to create tag '{name}'"))?;
  Ok(())
}

pub fn create_lightweight_tag(repo: &Repository, name: &str) -> Result<()> {
  let head = repo.head()?.peel_to_commit()?;
  repo
    .tag_lightweight(name, head.as_object(), false)
    .with_context(|| format!("failed to create tag '{name}'"))?;
  Ok(())
}

/// Create or move a lightweight tag to current HEAD, overwriting any existing
/// tag of the same name. Used for moving "pointer" tags (e.g. last-applied
/// markers) rather than immutable release tags.
pub fn set_moving_tag(repo: &Repository, name: &str) -> Result<()> {
  let head = repo.head()?.peel_to_commit()?;
  repo
    .tag_lightweight(name, head.as_object(), true)
    .with_context(|| format!("failed to set tag '{name}'"))?;
  Ok(())
}

// ---------------------------------------------------------------------------
// Checkout / restore
// ---------------------------------------------------------------------------

/// The abbreviated (7-char) hex sha of the current HEAD commit.
pub fn head_short_sha(repo: &Repository) -> Result<String> {
  let sha = repo.head()?.peel_to_commit()?.id().to_string();
  Ok(sha[..sha.len().min(7)].to_string())
}

pub fn current_ref(repo: &Repository) -> Result<String> {
  match repo.head() {
    Ok(head) if head.is_branch() => {
      Ok(head.shorthand().unwrap_or("HEAD").to_string())
    }
    // detached HEAD — use short sha
    _ => head_short_sha(repo),
  }
}

pub fn checkout_tag(repo: &Repository, tag_name: &str) -> Result<()> {
  let obj = repo
    .revparse_single(tag_name)
    .with_context(|| format!("tag '{tag_name}' not found"))?;

  repo
    .checkout_tree(&obj, None)
    .with_context(|| format!("failed to checkout '{tag_name}'"))?;

  repo
    .set_head_detached(obj.peel_to_commit()?.id())
    .with_context(|| format!("failed to detach HEAD at '{tag_name}'"))?;

  Ok(())
}

pub fn restore_ref(repo: &Repository, ref_name: &str) -> Result<()> {
  // Try as branch first, fall back to detached commit
  let branch_ref = format!("refs/heads/{ref_name}");
  if repo.find_reference(&branch_ref).is_ok() {
    repo.set_head(&branch_ref)?;
  } else {
    let obj = repo.revparse_single(ref_name)?;
    repo.set_head_detached(obj.peel_to_commit()?.id())?;
  }

  repo
    .checkout_head(Some(git2::build::CheckoutBuilder::new().force()))
    .context("failed to restore HEAD")?;

  Ok(())
}

// ---------------------------------------------------------------------------
// Push
// ---------------------------------------------------------------------------

pub fn push_commits(repo: &Repository) -> Result<()> {
  // Push HEAD to its branch by name. A bare "HEAD" refspec is ambiguous on a
  // normal remote (`git push origin HEAD` can't tell which branch to update)
  // and is rejected; `HEAD:refs/heads/<branch>` is explicit and works across
  // gcrypt/ssh/https alike.
  let head = repo.head()?;
  if !head.is_branch() {
    bail!(
      "HEAD is detached — refusing to push commits without a branch to push to"
    );
  }
  let branch = head.shorthand().ok_or_else(|| {
    anyhow::anyhow!("could not determine current branch name")
  })?;
  push_refspec(repo, &format!("HEAD:refs/heads/{branch}"))
}

pub fn push_tag(repo: &Repository, tag_name: &str) -> Result<()> {
  push_refspec(repo, &format!("refs/tags/{tag_name}"))
}

/// Force-push a tag to every remote, allowing it to move (overwrite). Used for
/// moving "pointer" tags whose target changes over time.
pub fn push_tag_force(repo: &Repository, tag_name: &str) -> Result<()> {
  push_refspec(repo, &format!("+refs/tags/{tag_name}"))
}

/// Push a refspec to every remote.
///
/// Shells out to the system `git` rather than using libgit2: libgit2 only
/// supports its built-in transports and cannot push remote-helper URLs (e.g.
/// `gcrypt::rsync://...`) or SSH unless built with libssh2. The `git` binary
/// honours remote helpers and the user's ssh-agent. Failures are collected so
/// one unreachable remote still reports the others.
fn push_refspec(repo: &Repository, refspec: &str) -> Result<()> {
  let workdir = repo
    .workdir()
    .ok_or_else(|| anyhow::anyhow!("repository has no working directory"))?;

  let mut failures = Vec::new();
  for remote_name in repo.remotes()?.iter().flatten() {
    let status = std::process::Command::new("git")
      .current_dir(workdir)
      .args(["push", remote_name, refspec])
      .status();
    match status {
      Ok(s) if s.success() => {}
      Ok(s) => failures
        .push(format!("{remote_name} (exit {})", s.code().unwrap_or(-1))),
      Err(e) => failures.push(format!("{remote_name} ({e})")),
    }
  }

  if !failures.is_empty() {
    bail!("failed to push '{refspec}' to: {}", failures.join(", "));
  }
  Ok(())
}

// ---------------------------------------------------------------------------
// Suggest commit message (heuristic, mirrors suggest-commit-msg.sh)
// ---------------------------------------------------------------------------

pub fn suggest_commit_msg(dirty: &DirtyFiles) -> Option<String> {
  let files: Vec<String> = dirty.files.iter().map(|f| f.path.clone()).collect();

  if files.is_empty() {
    return None;
  }

  let all_new = dirty
    .files
    .iter()
    .all(|f| f.status == 'A' || f.status == '?');
  let commit_type = if all_new { "feat" } else { infer_type(&files) };
  let scope = infer_scope(&files);
  let desc = files
    .iter()
    .map(|f| {
      std::path::Path::new(f)
        .file_name()
        .unwrap_or_default()
        .to_string_lossy()
        .to_string()
    })
    .take(3)
    .collect::<Vec<_>>()
    .join(", ");

  let extra = if files.len() > 3 {
    format!(" +{} more", files.len() - 3)
  } else {
    String::new()
  };

  let scope_str = scope.map(|s| format!("({s})")).unwrap_or_default();
  Some(format!("{commit_type}{scope_str}: {desc}{extra}"))
}

fn infer_type(files: &[String]) -> &'static str {
  for f in files {
    let fname = std::path::Path::new(f)
      .file_stem()
      .and_then(|s| s.to_str())
      .unwrap_or("");
    if fname.starts_with("test_")
      || fname.ends_with("_test")
      || fname.ends_with("_spec")
      || fname.starts_with("spec_")
    {
      return "test";
    }
    if f.ends_with(".md") || f.starts_with("docs/") {
      return "docs";
    }
    if f.starts_with(".github/")
      || f.starts_with(".gitlab/")
      || f.starts_with(".circleci/")
      || f.ends_with(".ci.yml")
      || f.ends_with(".ci.yaml")
    {
      return "ci";
    }
    if f.starts_with("nix/") || f == "flake.nix" || f == "Makefile" {
      return "build";
    }
    if f.ends_with(".toml") || f.ends_with(".lock") || f == "dogma.yml" {
      return "chore";
    }
  }
  "chore"
}

fn infer_scope(files: &[String]) -> Option<String> {
  let dirs: Vec<&str> = files
    .iter()
    .filter_map(|f| f.split('/').next())
    .filter(|d| !d.contains('.'))
    .collect();

  let mut counts = std::collections::HashMap::new();
  for d in &dirs {
    *counts.entry(*d).or_insert(0u32) += 1;
  }

  counts
    .into_iter()
    .max_by_key(|(_, c)| *c)
    .map(|(d, _)| d.to_string())
}
