/* git/search.rs
 *
 * Copyright 2026 Christian Kruse
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 * SPDX-License-Identifier: AGPL-3.0-or-later
 */

use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};

use super::types::*;
use super::{git_err, open_repo};
use crate::search_query::SearchQuery;

const BATCH_SIZE: usize = 50;

pub fn search_commits<F>(
    repo_path: &Path,
    ref_name: &str,
    query: &SearchQuery,
    cancel: &AtomicBool,
    mut on_batch: F,
) -> Result<usize, String>
where
    F: FnMut(Vec<CommitInfo>),
{
    let repo = open_repo(repo_path)?;

    let start_oid = repo
        .revparse_single(ref_name)
        .map_err(git_err("Failed to resolve ref"))?
        .peel_to_commit()
        .map_err(git_err("Failed to peel to commit"))?
        .id();

    let mut revwalk = repo.revwalk().map_err(git_err("Failed to create revwalk"))?;
    revwalk
        .set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME)
        .map_err(git_err("Failed to set sorting"))?;

    revwalk.push(start_oid).map_err(git_err("Failed to push to revwalk"))?;

    let mut batch = Vec::with_capacity(BATCH_SIZE);
    let mut total = 0;

    let authors_lower: Vec<String> = query.authors.iter().map(|a| a.to_lowercase()).collect();
    let words_lower: Vec<String> = query.message_words.iter().map(|w| w.to_lowercase()).collect();

    for oid_result in &mut revwalk {
        if cancel.load(Ordering::Relaxed) {
            break;
        }

        let oid = oid_result.map_err(git_err("Revwalk error"))?;
        let commit = repo.find_commit(oid).map_err(git_err("Failed to find commit"))?;

        // Check since/until first (cheapest)
        let commit_time = commit.time().seconds();
        if let Some(since) = query.since
            && commit_time < since
        {
            continue;
        }
        if let Some(until) = query.until
            && commit_time > until
        {
            continue;
        }

        // next check author (less cheap)
        if !authors_lower.is_empty() {
            let author_name = commit.author().name().unwrap_or("").to_lowercase();
            if !authors_lower.iter().any(|a| author_name.contains(a.as_str())) {
                continue;
            }
        }

        // then check message words (all must match)
        if !words_lower.is_empty() {
            let commit_msg = commit.message().unwrap_or("").to_lowercase();
            if !words_lower.iter().all(|w| commit_msg.contains(w.as_str())) {
                continue;
            }
        }

        // last check files (most expensive, because of the tree diff)
        if !query.files.is_empty() && !commit_touches_files(&repo, &commit, &query.files)? {
            continue;
        }

        let message = commit.message().unwrap_or("");
        let (summary, body) = match message.split_once('\n') {
            Some((s, b)) => (s.to_string(), b.trim_start_matches('\n').to_string()),
            None => (message.to_string(), String::new()),
        };

        let author_sig = commit.author();
        let committer_sig = commit.committer();

        batch.push(CommitInfo {
            oid: oid.to_string(),
            summary,
            body,
            author: author_sig.name().unwrap_or("Unknown").to_string(),
            author_email: author_sig.email().unwrap_or("").to_string(),
            time: commit_time,
            committer: committer_sig.name().unwrap_or("Unknown").to_string(),
            committer_email: committer_sig.email().unwrap_or("").to_string(),
            committer_time: committer_sig.when().seconds(),
            parents: commit.parent_ids().map(|id| id.to_string()).collect(),
            is_merge: commit.parent_count() > 1,
        });

        total += 1;

        if batch.len() >= BATCH_SIZE {
            on_batch(std::mem::replace(&mut batch, Vec::with_capacity(BATCH_SIZE)));

            if cancel.load(Ordering::Relaxed) {
                break;
            }
        }
    }

    if !batch.is_empty() && !cancel.load(Ordering::Relaxed) {
        on_batch(batch);
    }

    Ok(total)
}

fn commit_touches_files(repo: &git2::Repository, commit: &git2::Commit, file_paths: &[String]) -> Result<bool, String> {
    let commit_tree = commit.tree().map_err(git_err("Failed to get commit tree"))?;

    let parent_tree = if commit.parent_count() > 0 {
        Some(
            commit
                .parent(0)
                .map_err(git_err("Failed to get parent commit"))?
                .tree()
                .map_err(git_err("Failed to get parent tree"))?,
        )
    } else {
        None
    };

    let mut diff_opts = git2::DiffOptions::new();
    for path in file_paths {
        diff_opts.pathspec(path);
    }

    let diff = repo
        .diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), Some(&mut diff_opts))
        .map_err(git_err("Failed to generate diff"))?;

    Ok(diff.deltas().len() > 0)
}
