// Copyright 2025 The Jujutsu Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

use std::io::ErrorKind;
use std::io::Write as _;

use itertools::Itertools as _;
use jj_lib::commit::Commit;
use jj_lib::file_util::IoResultExt as _;
use jj_lib::git;
use jj_lib::op_store::RefTarget;
use jj_lib::repo::Repo as _;

use crate::cli_util::CommandHelper;
use crate::cli_util::WorkspaceCommandHelper;
use crate::command_error::CommandError;
use crate::command_error::user_error;
use crate::command_error::user_error_with_message;
use crate::commands::git::maybe_add_gitignore;
use crate::git_util::is_colocated_git_workspace;
use crate::ui::Ui;

/// Show the current colocation status
#[derive(clap::Args, Clone, Debug)]
pub struct GitColocationStatusArgs {}

/// Convert into a colocated Jujutsu/Git repository
///
/// This moves the underlying Git repository that is found inside the .jj
/// directory to the root of the Jujutsu workspace. This allows you to
/// use Git commands directly in the Jujutsu workspace.
#[derive(clap::Args, Clone, Debug)]
pub struct GitColocationEnableArgs {}

/// Convert into a non-colocated Jujutsu/Git repository
///
/// This moves the Git repository that is at the root of the Jujutsu
/// workspace into the .jj directory. Once this is done you will no longer
/// be able to use Git commands directly in the Jujutsu workspace.
#[derive(clap::Args, Clone, Debug)]
pub struct GitColocationDisableArgs {}

/// Manage Jujutsu repository colocation with Git
#[derive(clap::Subcommand, Clone, Debug)]
pub enum GitColocationCommand {
    Disable(GitColocationDisableArgs),
    Enable(GitColocationEnableArgs),
    Status(GitColocationStatusArgs),
}

pub async fn cmd_git_colocation(
    ui: &mut Ui,
    command: &CommandHelper,
    subcommand: &GitColocationCommand,
) -> Result<(), CommandError> {
    match subcommand {
        GitColocationCommand::Disable(args) => cmd_git_colocation_disable(ui, command, args).await,
        GitColocationCommand::Enable(args) => cmd_git_colocation_enable(ui, command, args).await,
        GitColocationCommand::Status(args) => cmd_git_colocation_status(ui, command, args).await,
    }
}

/// Check that the repository supports colocation commands
/// which means that the repo is backed by Git and is a main workspace
fn workspace_supports_git_colocation_commands(
    workspace_command: &WorkspaceCommandHelper,
) -> Result<(), CommandError> {
    // Check if backend is Git (will show an error otherwise)
    git::get_git_backend(workspace_command.repo().store())?;

    // Ensure that this is the main workspace
    let repo_dir = workspace_command.workspace_root().join(".jj").join("repo");
    if repo_dir.is_file() {
        return Err(user_error(
            "This command cannot be used in a non-main Jujutsu workspace",
        ));
    }
    Ok(())
}

async fn cmd_git_colocation_status(
    ui: &mut Ui,
    command: &CommandHelper,
    _args: &GitColocationStatusArgs,
) -> Result<(), CommandError> {
    let workspace_command = command.workspace_helper(ui).await?;

    // Make sure that the workspace supports git colocation commands
    workspace_supports_git_colocation_commands(&workspace_command)?;

    let is_colocated = is_colocated_git_workspace(workspace_command.workspace());
    let git_head = workspace_command.repo().view().git_head();

    if is_colocated {
        writeln!(ui.stdout(), "Workspace is currently colocated with Git.")?;
    } else {
        writeln!(
            ui.stdout(),
            "Workspace is currently not colocated with Git."
        )?;
    }

    // git_head should be absent in non-colocated workspace, but print the
    // actual status so we can debug problems.
    writeln!(
        ui.stdout(),
        "Last imported/exported Git HEAD: {}",
        git_head
            .as_merge()
            .iter()
            .map(|maybe_id| match maybe_id {
                Some(id) => id.to_string(),
                None => "(none)".to_owned(),
            })
            .join(", ")
    )?;

    if is_colocated {
        writeln!(
            ui.hint_default(),
            "To disable colocation, run: `jj git colocation disable`"
        )?;
    } else if !workspace_command
        .repo_path()
        .join("store")
        .join("git")
        .exists()
    {
        writeln!(
            ui.hint_default(),
            "Colocation cannot be enabled because the workspace is backed by an external Git \
             repository."
        )?;
    } else {
        writeln!(
            ui.hint_default(),
            "To enable colocation, run: `jj git colocation enable`"
        )?;
    }

    Ok(())
}

async fn cmd_git_colocation_enable(
    ui: &mut Ui,
    command: &CommandHelper,
    _args: &GitColocationEnableArgs,
) -> Result<(), CommandError> {
    let workspace_command = command.workspace_helper(ui).await?;

    // Make sure that the workspace supports git colocation commands
    workspace_supports_git_colocation_commands(&workspace_command)?;

    // Then ensure that the workspace is not already colocated before proceeding
    if is_colocated_git_workspace(workspace_command.workspace()) {
        writeln!(ui.status(), "Workspace is already colocated with Git.")?;
        return Ok(());
    }

    // And that it has a working copy (whose parent we'll use later to set the git
    // HEAD)
    let wc_commit_id = workspace_command
        .get_wc_commit_id()
        .ok_or_else(|| user_error("This command requires a working copy"))?
        .clone();

    let workspace_root = workspace_command.workspace_root();
    let jj_repo_path = workspace_command.repo_path();
    let git_store_path = jj_repo_path.join("store").join("git");
    let git_target_path = jj_repo_path.join("store").join("git_target");
    let dot_git_path = workspace_root.join(".git");

    // Move the git repository from .jj/repo/store/git to .git
    std::fs::rename(&git_store_path, &dot_git_path).map_err(|err| match err.kind() {
        ErrorKind::AlreadyExists | ErrorKind::DirectoryNotEmpty => {
            user_error("A .git directory already exists in the workspace root. Cannot colocate.")
        }
        // An external Git repository (e.g. created by `jj git init
        // --git-repo=<path>`) isn't managed by jj and cannot be moved.
        // workspace_supports_git_colocation_commands() already ensured that
        // the backend is Git.
        ErrorKind::NotFound => {
            let git_backend = git::get_git_backend(workspace_command.repo().store()).unwrap();
            user_error(format!(
                "Cannot colocate a workspace backed by an external Git repository at {}",
                git_backend.git_repo_path().display()
            ))
        }
        _ => user_error_with_message(
            "Failed to move Git repository from .jj/repo/store/git to workspace root directory.",
            err,
        ),
    })?;

    // Update the git_target file to point to the new location of the git repo
    let git_target_content = "../../../.git";
    std::fs::write(&git_target_path, git_target_content).context(git_target_path)?;

    // Then we must make the Git repository non-bare
    set_git_repo_bare(&dot_git_path, false)?;

    // Reload the workspace command helper to ensure it picks up the changes
    let mut workspace_command = reload_workspace_helper(ui, command, workspace_command).await?;

    // Add a .jj/.gitignore file (if needed) to ensure that the colocated Git
    // repository does not track Jujutsu's repository
    maybe_add_gitignore(&workspace_command)?;

    // Finally, update git HEAD to point to the working-copy commit's parent
    let wc_commit = workspace_command
        .repo()
        .store()
        .get_commit_async(&wc_commit_id)
        .await?;
    set_git_head_to_wc_parent(ui, &mut workspace_command, &wc_commit).await?;

    writeln!(
        ui.status(),
        "Workspace successfully converted into a colocated Jujutsu/Git workspace."
    )?;

    Ok(())
}

async fn cmd_git_colocation_disable(
    ui: &mut Ui,
    command: &CommandHelper,
    _args: &GitColocationDisableArgs,
) -> Result<(), CommandError> {
    let workspace_command = command.workspace_helper(ui).await?;

    // Make sure that the repository supports git colocation commands
    workspace_supports_git_colocation_commands(&workspace_command)?;

    // Then ensure that the repo is colocated before proceeding
    if !is_colocated_git_workspace(workspace_command.workspace()) {
        writeln!(ui.status(), "Workspace is already not colocated with Git.")?;
        return Ok(());
    }

    let workspace_root = workspace_command.workspace_root();
    let dot_jj_path = workspace_root.join(".jj");
    let git_store_path = workspace_command.repo_path().join("store").join("git");
    let git_target_path = workspace_command
        .repo_path()
        .join("store")
        .join("git_target");
    let dot_git_path = workspace_root.join(".git");
    let jj_gitignore_path = dot_jj_path.join(".gitignore");

    // Move the Git repository from .git into .jj/repo/store/git
    std::fs::rename(&dot_git_path, &git_store_path).map_err(|e| {
        user_error_with_message("Failed to move Git repository to .jj/repo/store/git", e)
    })?;

    // Make the Git repository bare
    set_git_repo_bare(&git_store_path, true)?;

    // Update the git_target file to point to the internal git store
    let git_target_content = "git";
    std::fs::write(&git_target_path, git_target_content).context(&git_target_path)?;

    // Remove the .jj/.gitignore file if it exists
    std::fs::remove_file(&jj_gitignore_path).ok();

    // Reload the workspace command helper to ensure it picks up the changes
    let mut workspace_command = reload_workspace_helper(ui, command, workspace_command).await?;

    // And finally, remove the git HEAD reference
    remove_git_head(ui, &mut workspace_command).await?;

    writeln!(
        ui.status(),
        "Workspace successfully converted into a non-colocated Jujutsu/Git workspace."
    )?;

    Ok(())
}

/// Set the Git repository at `path` to be bare or non-bare
fn set_git_repo_bare(path: &std::path::Path, bare: bool) -> Result<(), CommandError> {
    let bare_str = if bare { "true" } else { "false" };
    let config_path = path.join("config");
    let mut config_file =
        gix::config::File::from_path_no_includes(config_path.clone(), gix::config::Source::Local)
            .map_err(|err| user_error_with_message("Failed to open Git config file.", err))?;

    config_file
        .set_raw_value("core.bare", bare_str)
        .map_err(|err| {
            user_error_with_message(
                format!("Failed to set core.bare to {bare_str} in Git config."),
                err,
            )
        })?;

    git::save_git_config(&config_file).map_err(|err| {
        user_error_with_message(
            format!(
                "Failed to write to Git config file at {}.",
                config_path.display()
            ),
            err,
        )
    })?;
    Ok(())
}

/// Set the git HEAD to the working copy commit's parent
async fn set_git_head_to_wc_parent(
    ui: &mut Ui,
    workspace_command: &mut WorkspaceCommandHelper,
    wc_commit: &Commit,
) -> Result<(), CommandError> {
    let mut tx = workspace_command.start_transaction();
    git::reset_head(tx.repo_mut(), wc_commit).await?;
    if tx.repo().has_changes() {
        tx.finish(ui, "set git head to working copy parent").await?;
    }
    Ok(())
}

/// Remove the git HEAD reference
async fn remove_git_head(
    ui: &mut Ui,
    workspace_command: &mut WorkspaceCommandHelper,
) -> Result<(), CommandError> {
    let mut tx = workspace_command.start_transaction();
    tx.repo_mut().set_git_head_target(RefTarget::absent());
    if tx.repo().has_changes() {
        tx.finish(ui, "remove git head reference").await?;
    }
    Ok(())
}

/// Gets an up to date workspace helper to pick up changes made to the repo
async fn reload_workspace_helper(
    ui: &mut Ui,
    command: &CommandHelper,
    workspace_command: WorkspaceCommandHelper,
) -> Result<WorkspaceCommandHelper, CommandError> {
    let workspace = command.load_workspace_at(
        workspace_command.workspace_root(),
        workspace_command.settings(),
    )?;
    let op = workspace
        .repo_loader()
        .load_operation(workspace_command.repo().op_id())
        .await?;
    let repo = workspace.repo_loader().load_at(&op).await?;
    let workspace_command = command.for_workable_repo(ui, workspace, repo)?;
    Ok(workspace_command)
}
