use std::collections::{BTreeMap, BTreeSet};
use std::ops::Range;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use futures_util::{StreamExt, TryStreamExt};
use globset::Glob;
use rustc_hash::{FxHashMap, FxHashSet};
use semver::Version;

use crate::cli::reporter::UpdateReporter;
use crate::cli::run::Selectors;
use crate::cli::update::config::write_new_config;
use crate::cli::update::display::{apply_repo_updates, warn_frozen_mismatches};
use crate::cli::update::source::{collect_repo_sources, evaluate_repo_source};
use crate::cli::{ExitStatus, RepoTagPattern};
use crate::config::{GlobPatterns, Repo};
use crate::fs::{CWD, Simplified};
use crate::printer::Printer;
use crate::run::INTERNAL_CONCURRENCY;
use crate::settings::{CliTagFilterOptions, FilesystemOptions, TagFilterOptions};
use crate::store::Store;
use crate::warn_user;
use crate::workspace::{Project, Workspace};

mod config;
mod display;
mod repository;
mod source;

/// The `rev` value to write back to config, plus an optional `# frozen:` comment.
#[derive(Default, Clone)]
struct Revision {
    /// The resolved revision string to store in `rev`.
    rev: String,
    /// The tag-like reference to preserve in a `# frozen:` comment.
    frozen: Option<String>,
}

/// One occurrence of a remote repo in a project config file.
struct RepoUsage<'a> {
    /// The project whose config contains this repo entry.
    project: &'a Project,
    /// The number of remote repos in that project config.
    remote_count: usize,
    /// The position of this remote repo among the project's remote repos.
    remote_index: usize,
    /// The 1-based line number of this repo entry's `rev` setting.
    rev_line_number: usize,
    /// The existing `# frozen:` comment for this repo entry, if present.
    current_frozen: Option<String>,
    /// The source location of the existing `# frozen:` comment, if present.
    current_frozen_site: Option<FrozenCommentSite>,
}

/// One distinct `repo + rev + hook set + update settings` target that should be evaluated.
struct RepoTarget<'a> {
    /// The repository value exactly as written in the configuration.
    repo: &'a str,
    /// The currently configured `rev` for this target.
    current_rev: &'a str,
    /// The resolved cooldown window to apply when selecting an update for this target.
    cooldown_days: u8,
    /// Whether the selected revision should be stored as a frozen commit hash.
    freeze: bool,
    /// Tag filters to apply when selecting an update for this target.
    tag_filters: TagFilters,
    /// The sorted hook ids that must still exist after updating this target.
    required_hook_ids: Vec<&'a str>,
    /// Every config usage that shares this exact target configuration.
    usages: Vec<RepoUsage<'a>>,
}

/// One fetched remote repository source with all configured revisions that use it.
struct RepoSource<'a> {
    /// The resolved repository source used for fetch and cache identity.
    source: &'a str,
    /// Distinct configured revisions that should be evaluated against this fetched repo.
    targets: Vec<RepoTarget<'a>>,
}

/// The action to take when a `# frozen:` comment no longer matches a SHA `rev`.
enum FrozenMismatchAction {
    /// Rewrite the comment to this replacement tag.
    ReplaceWith(String),
    /// Remove the stale comment because no ref points at the pinned commit.
    Remove,
    /// Warn only because we cannot safely decide a comment-only fix.
    NoReplacement,
}

/// Whether the pinned SHA is available from the refs fetched for `prek update`.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum CommitPresence {
    /// The commit is present in the fetched repository view.
    Present,
    /// The commit is not present in the fetched repository view.
    Absent,
    /// The current Git cannot disable lazy fetch, so presence could not be checked safely.
    Unknown,
}

/// Why an existing `# frozen:` comment no longer matches the configured `rev`.
enum FrozenMismatchReason {
    /// The frozen reference resolves successfully, but to a different commit than `rev`.
    ResolvesToDifferentCommit,
    /// The frozen reference could not be resolved at all.
    Unresolvable,
}

/// One stale `# frozen:` comment found for a specific repo entry.
struct FrozenMismatch<'a> {
    /// The project config that contains this stale comment.
    project: &'a Project,
    /// The number of remote repos in that project config.
    remote_size: usize,
    /// The position of this remote repo among the project's remote repos.
    remote_index: usize,
    /// The 1-based line number of the `rev` setting that owns this stale comment.
    rev_line_number: usize,
    /// The current `# frozen:` reference string from config.
    current_frozen: String,
    /// The source location of the current `# frozen:` comment.
    frozen_site: Option<FrozenCommentSite>,
    /// Why the existing frozen reference is stale.
    reason: FrozenMismatchReason,
    /// Whether the pinned SHA is available in the fetched repository view.
    current_rev_presence: CommitPresence,
    /// The action to take for this stale comment.
    action: FrozenMismatchAction,
}

/// The source location of a `# frozen:` comment value within a config line.
#[derive(Clone)]
struct FrozenCommentSite {
    /// The 1-based line number in the config file.
    line_number: usize,
    /// The full source line that contains the `# frozen:` comment.
    source_line: String,
    /// The byte range of the frozen reference value within `source_line`.
    span: Range<usize>,
}

/// Parsed frozen-comment metadata for one `rev` entry in config.
#[derive(Clone)]
struct FrozenRef {
    /// The 1-based line number of the `rev` setting.
    line_number: usize,
    /// The parsed frozen reference value, if the `rev` line has one.
    current_frozen: Option<String>,
    /// The source location of that frozen reference value, if present.
    site: Option<FrozenCommentSite>,
}

/// A tag reference with the metadata needed for cooldown selection and SHA matching.
#[derive(Clone)]
struct TagTimestamp {
    /// The tag name without the `refs/tags/` prefix.
    tag: String,
    /// The parsed semantic version, if the tag looks like one.
    version: Option<Version>,
    /// The tag timestamp used for cooldown ordering.
    timestamp: u64,
    /// The peeled commit SHA the tag ultimately points at.
    commit: String,
}

impl TagTimestamp {
    fn new(tag: String, timestamp: u64, commit: String) -> Self {
        let version = Version::parse(tag.strip_prefix('v').unwrap_or(&tag)).ok();
        Self {
            tag,
            version,
            timestamp,
            commit,
        }
    }
}

#[derive(Debug, Eq, PartialEq)]
struct SkippedDowngrade {
    /// The configured revision that was kept.
    current: String,
    /// The candidate tag that was rejected.
    candidate: String,
    /// The cooldown window used to select the candidate tag.
    cooldown_days: u8,
}

#[derive(Debug, Eq, PartialEq)]
enum RevisionSelection {
    /// No eligible revision was found.
    Unchanged,
    /// Update to this revision.
    Update(String),
    /// Keep the current revision because the candidate sorts older.
    SkippedDowngrade(SkippedDowngrade),
}

struct TagFilters {
    include: GlobPatterns,
    exclude: GlobPatterns,
}

impl TagFilters {
    fn from_options(options: TagFilterOptions) -> Result<Self> {
        Ok(Self {
            include: GlobPatterns::from_globs(options.include)
                .context("Failed to compile update include_tags patterns")?,
            exclude: GlobPatterns::from_globs(options.exclude)
                .context("Failed to compile update exclude_tags patterns")?,
        })
    }

    fn filter<'a>(&self, tags: &'a [TagTimestamp]) -> Vec<&'a TagTimestamp> {
        tags.iter()
            .filter(|tag| self.is_included(&tag.tag) && !self.is_excluded(&tag.tag))
            .collect()
    }

    fn is_included(&self, tag: &str) -> bool {
        self.include.is_empty() || self.include.is_match(Path::new(tag))
    }

    fn is_excluded(&self, tag: &str) -> bool {
        self.exclude.is_match(Path::new(tag))
    }
}

fn group_repo_tag_patterns(values: Vec<RepoTagPattern>) -> BTreeMap<String, Vec<Glob>> {
    let mut patterns_by_repo: BTreeMap<String, Vec<Glob>> = BTreeMap::new();
    for RepoTagPattern { repo, pattern } in values {
        patterns_by_repo.entry(repo).or_default().push(pattern);
    }
    patterns_by_repo
}

fn project_has_repo(project: &Project, repo: &str) -> bool {
    project.config().repos.iter().any(
        |configured_repo| matches!(configured_repo, Repo::Remote(remote) if remote.repo() == repo),
    )
}

fn warn_missing_cli_repos(
    configured_repos: &FxHashSet<&str>,
    filter_repos: &[String],
    cli_tag_filters: &CliTagFilterOptions,
) {
    let mut missing_selectors = BTreeSet::new();
    missing_selectors.extend(
        filter_repos
            .iter()
            .filter(|repo| !configured_repos.contains(repo.as_str()))
            .map(|repo| (0, format!("--repo={repo}"))),
    );
    for (order, option, patterns_by_repo) in [
        (1, "--repo-include-tag", &cli_tag_filters.repo_include),
        (2, "--repo-exclude-tag", &cli_tag_filters.repo_exclude),
    ] {
        for (repo, patterns) in patterns_by_repo {
            if configured_repos.contains(repo.as_str()) {
                continue;
            }
            missing_selectors.extend(
                patterns
                    .iter()
                    .map(|pattern| (order, format!("{option}={repo}={}", pattern.glob()))),
            );
        }
    }
    let missing_selectors = missing_selectors
        .into_iter()
        .map(|(_, selector)| selector)
        .collect::<Vec<_>>();

    match missing_selectors.as_slice() {
        [] => {}
        [selector] => {
            warn_user!("repository selector `{selector}` was not found in the configuration");
        }
        _ => {
            warn_user!("the following repository selectors were not found in the configuration:");
            for selector in missing_selectors {
                anstream::eprintln!("  - `{selector}`");
            }
        }
    }
}

fn warn_missing_project_repos(workspace: &Workspace) {
    let missing_project_repos = workspace
        .projects()
        .iter()
        .filter_map(|project| {
            let repos = project
                .config()
                .update
                .iter()
                .flat_map(|options| options.repos.keys())
                .filter(|repo| !project_has_repo(project, repo))
                .collect::<Vec<_>>();
            (!repos.is_empty()).then_some((project.config_file(), repos))
        })
        .collect::<Vec<_>>();

    if !missing_project_repos.is_empty() {
        warn_user!(
            "the following `update.repos` entries were not found in their project configurations:"
        );
        for (config_file, repos) in missing_project_repos {
            anstream::eprintln!("  `{}`:", config_file.user_display());
            for repo in repos {
                anstream::eprintln!("    - `{repo}`");
            }
        }
    }
}

fn warn_missing_repos(
    workspace: &Workspace,
    repo_sources: &[RepoSource<'_>],
    filter_repos: &[String],
    cli_tag_filters: &CliTagFilterOptions,
) {
    let configured_repos = repo_sources
        .iter()
        .flat_map(|repo_source| repo_source.targets.iter().map(|target| target.repo))
        .collect::<FxHashSet<_>>();

    warn_missing_cli_repos(&configured_repos, filter_repos, cli_tag_filters);
    warn_missing_project_repos(workspace);
}

/// The successful result of evaluating one configured `repo + rev + hook set` target.
struct ResolvedRepoUpdate<'a> {
    /// The revision data that may be written back to config.
    revision: Revision,
    /// A candidate revision that was not selected because it would move backward.
    skipped_downgrade: Option<SkippedDowngrade>,
    /// Any stale `# frozen:` comments found for this target's usages.
    frozen_mismatches: Vec<FrozenMismatch<'a>>,
}

/// The final outcome for one configured `repo + rev + hook set` target.
struct RepoUpdate<'a> {
    /// The target that was evaluated.
    target: &'a RepoTarget<'a>,
    /// The computed result for this target.
    result: Result<ResolvedRepoUpdate<'a>>,
}

#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
struct ProjectUpdateKey<'a> {
    config_file: &'a Path,
}

impl<'a> ProjectUpdateKey<'a> {
    fn config_file(self) -> &'a Path {
        self.config_file
    }
}

impl<'a> From<&'a Project> for ProjectUpdateKey<'a> {
    fn from(project: &'a Project) -> Self {
        Self {
            config_file: project.config_file(),
        }
    }
}

/// Pending config mutations grouped by project config file.
type ProjectUpdates<'a> = FxHashMap<ProjectUpdateKey<'a>, Vec<Option<Revision>>>;

struct ApplyRepoUpdatesResult {
    failure: bool,
    has_updates: bool,
}

enum DisplayEventKind {
    Update {
        current: Revision,
        next: Revision,
    },
    SkippedDowngrade {
        current: String,
        candidate: String,
        cooldown_days: u8,
    },
    FrozenUpdate {
        current: String,
        next: String,
    },
    FrozenRemove {
        current: String,
    },
    UpToDate {
        current: Revision,
    },
    Failure {
        error: String,
    },
}

#[derive(Clone, Copy, Eq, PartialEq)]
enum DisplayStream {
    Stdout,
    Stderr,
}

struct DisplayEvent<'a> {
    stream: DisplayStream,
    project: &'a Project,
    repo: &'a str,
    remote_index: usize,
    line_number: usize,
    kind: DisplayEventKind,
}

struct FrozenWarningEvent<'a> {
    project: &'a Project,
    repo: &'a str,
    current_rev: &'a str,
    remote_index: usize,
    mismatch: &'a FrozenMismatch<'a>,
}

type RepoOccurrences<'a> = FxHashMap<(&'a Path, &'a str), usize>;

/// Updates remote repo revisions and, when possible, keeps existing `# frozen:` comments in sync.
#[expect(clippy::fn_params_excessive_bools)]
pub(crate) async fn update(
    store: &Store,
    config: Option<PathBuf>,
    filter_repos: Vec<String>,
    exclude_repos: Vec<String>,
    include_tag: Vec<Glob>,
    exclude_tag: Vec<Glob>,
    repo_include_tag: Vec<RepoTagPattern>,
    repo_exclude_tag: Vec<RepoTagPattern>,
    verbose: bool,
    bleeding_edge: bool,
    freeze: bool,
    jobs: usize,
    dry_run: bool,
    exit_code: bool,
    cooldown_days: Option<u8>,
    filesystem: Option<FilesystemOptions>,
    printer: Printer,
) -> Result<ExitStatus> {
    let workspace_root = Workspace::find_root(config.as_deref(), &CWD)?;
    // TODO: support selectors?
    let selectors = Selectors::default();
    let workspace = Workspace::discover(store, workspace_root, config, Some(&selectors), true)?;

    let cli_tag_filters = CliTagFilterOptions {
        include: include_tag,
        exclude: exclude_tag,
        repo_include: group_repo_tag_patterns(repo_include_tag),
        repo_exclude: group_repo_tag_patterns(repo_exclude_tag),
    };

    let jobs = if jobs == 0 {
        *INTERNAL_CONCURRENCY
    } else {
        jobs
    };
    let reporter = UpdateReporter::new(printer);

    let mut repo_sources = collect_repo_sources(
        &workspace,
        freeze,
        cooldown_days,
        &cli_tag_filters,
        filesystem.as_ref(),
    )?;
    warn_missing_repos(&workspace, &repo_sources, &filter_repos, &cli_tag_filters);

    for repo_source in &mut repo_sources {
        repo_source.targets.retain(|target| {
            (filter_repos.is_empty() || filter_repos.iter().any(|repo| repo == target.repo))
                && !exclude_repos.iter().any(|repo| repo == target.repo)
        });
    }
    repo_sources.retain(|repo_source| !repo_source.targets.is_empty());

    let outcomes: Vec<RepoUpdate<'_>> = futures_util::stream::iter(&repo_sources)
        .map(async |repo_source| {
            let display_repo = repo_source
                .targets
                .first()
                .map_or(repo_source.source, |target| target.repo);
            let progress = reporter.on_update_start(display_repo);
            let result = evaluate_repo_source(repo_source, bleeding_edge).await;
            reporter.on_update_complete(progress);
            result
        })
        .buffer_unordered(jobs)
        .try_collect::<Vec<_>>()
        .await?
        .into_iter()
        .flatten()
        .collect();

    reporter.on_complete();

    warn_frozen_mismatches(&outcomes, printer)?;

    // Group results by project config file
    let mut project_updates: ProjectUpdates<'_> = FxHashMap::default();
    let apply_result =
        apply_repo_updates(outcomes, verbose, dry_run, printer, &mut project_updates)?;

    if !dry_run {
        for (project, revisions) in project_updates {
            if revisions.iter().any(Option::is_some) {
                write_new_config(project.config_file(), &revisions).await?;
            }
        }
    }

    if apply_result.failure || (exit_code && apply_result.has_updates) {
        return Ok(ExitStatus::Failure);
    }
    Ok(ExitStatus::Success)
}

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

    fn tag(name: &str) -> TagTimestamp {
        TagTimestamp::new(name.to_string(), 0, String::new())
    }

    fn filtered_tags(filters: &TagFilters, tags: &[TagTimestamp]) -> Vec<String> {
        filters
            .filter(tags)
            .into_iter()
            .map(|tag| tag.tag.clone())
            .collect()
    }

    fn tag_filters(include: &[&str], exclude: &[&str]) -> TagFilters {
        TagFilters::from_options(TagFilterOptions {
            include: include
                .iter()
                .map(|pattern| pattern.parse().unwrap())
                .collect(),
            exclude: exclude
                .iter()
                .map(|pattern| pattern.parse().unwrap())
                .collect(),
        })
        .unwrap()
    }

    #[test]
    fn tag_filters_keep_all_tags_without_filters() {
        let filters = tag_filters(&[], &[]);
        let tags = [tag("v1.0.0"), tag("nightly")];

        assert_eq!(filtered_tags(&filters, &tags), vec!["v1.0.0", "nightly"]);
    }

    #[test]
    fn tag_filters_apply_include_patterns() {
        let filters = tag_filters(&["v*.1.0"], &[]);
        let tags = [tag("v1.0.0"), tag("v1.1.0"), tag("v2.1.0")];

        assert_eq!(filtered_tags(&filters, &tags), vec!["v1.1.0", "v2.1.0"]);
    }

    #[test]
    fn tag_filters_apply_excludes_after_includes() {
        let filters = tag_filters(&["v*"], &["v2.*", "*-rc*"]);
        let tags = [
            tag("v1.0.0"),
            tag("v2.0.0"),
            tag("v3.0.0-rc1"),
            tag("nightly"),
        ];

        assert_eq!(filtered_tags(&filters, &tags), vec!["v1.0.0"]);
    }
}
