use std::path::PathBuf;
use std::process::Command;

use async_trait::async_trait;
use chrono::Utc;
use tracing::{info, warn};

use crate::backend::{MergeStatus, VcsCapabilities, VcsMetadata, WorkspaceBackend, WorkspaceInfo};
use crate::error::{Result, WorktreeError};

/// Template: prints `CONFLICT` or `CLEAN` followed by newline.
const CONFLICT_CHECK_TEMPLATE: &str = r#"if(conflict, "CONFLICT", "CLEAN") ++ "\n""#;
/// Template: prints the full change_id followed by newline.
const CHANGE_ID_TEMPLATE: &str = r#"change_id ++ "\n""#;

pub struct JjBackend {
    repo_path: PathBuf,
    workspace_base: PathBuf,
}

impl JjBackend {
    pub fn new(repo_path: PathBuf, workspace_base: PathBuf) -> Result<Self> {
        if !repo_path.exists() {
            return Err(WorktreeError::InvalidState(format!(
                "Repository path does not exist: {}",
                repo_path.display()
            )));
        }
        if !workspace_base.exists() {
            std::fs::create_dir_all(&workspace_base)?;
        }
        Ok(Self {
            repo_path,
            workspace_base,
        })
    }

    /// Run a jj command in `repo_path` and return stdout.
    ///
    /// `--no-pager` prevents jj from spawning a pager in non-TTY contexts.
    fn run_jj(&self, args: &[&str]) -> Result<String> {
        let out = Command::new("jj")
            .current_dir(&self.repo_path)
            .arg("--no-pager")
            .args(args)
            .output()
            .map_err(|e| WorktreeError::JjError(format!("jj exec: {e}")))?;

        if !out.status.success() {
            return Err(WorktreeError::JjError(
                String::from_utf8_lossy(&out.stderr).trim().to_string(),
            ));
        }
        Ok(String::from_utf8_lossy(&out.stdout).into_owned())
    }

    fn workspace_path(&self, workspace_id: &str) -> PathBuf {
        self.workspace_base.join(workspace_id)
    }

    /// Retrieve the change_id of the working-copy commit in workspace
    /// `workspace_id`.
    ///
    /// Revset: `<workspace_id>@` — jj's syntax for "working copy of named
    /// workspace".
    fn get_change_id(&self, workspace_id: &str) -> Result<String> {
        let revset = format!("{workspace_id}@");
        let out = self.run_jj(&["log", "--no-graph", "-r", &revset, "-T", CHANGE_ID_TEMPLATE])?;
        let id = out.trim().to_string();
        if id.is_empty() {
            return Err(WorktreeError::NotFound(format!(
                "jj workspace '{workspace_id}' has no working-copy commit"
            )));
        }
        Ok(id)
    }

    /// After `jj new --no-edit <target> <change_id>`, the merge commit is the
    /// only head in `change_id::` (the merge commit is a descendant of
    /// change_id and has no children yet).
    fn find_merge_commit_revset(change_id: &str) -> String {
        format!("heads({change_id}::)")
    }

    /// Files modified in `workspace_id`'s working-copy commit relative to its
    /// parent.
    fn modified_files(&self, workspace_id: &str) -> Result<Vec<PathBuf>> {
        let revset = format!("{workspace_id}@");
        // `jj diff --name-only -r <rev>` shows files changed in that commit vs its
        // parent
        let out = self.run_jj(&["diff", "--name-only", "-r", &revset])?;
        Ok(out
            .lines()
            .filter(|l| !l.is_empty())
            .map(PathBuf::from)
            .collect())
    }
}

#[async_trait]
impl WorkspaceBackend for JjBackend {
    fn name(&self) -> &'static str {
        "jj"
    }

    fn capabilities(&self) -> VcsCapabilities {
        VcsCapabilities {
            conflicts_as_data: true,
            stable_change_ids: true,
            native_workspaces: true,
            hooks_supported: false,
        }
    }

    async fn create_workspace(&self, agent_id: &str, workspace_id: &str) -> Result<WorkspaceInfo> {
        let path = self.workspace_path(workspace_id);
        let path_str = path
            .to_str()
            .ok_or_else(|| WorktreeError::InvalidState("Non-UTF8 path".into()))?;

        // `jj workspace add <path> --name <name>` — workspace_id doubles as the jj
        // workspace name
        self.run_jj(&["workspace", "add", path_str, "--name", workspace_id])?;

        let change_id = self.get_change_id(workspace_id)?;

        info!("Created jj workspace {workspace_id} for agent {agent_id} (change {change_id})");

        Ok(WorkspaceInfo {
            id: workspace_id.to_string(),
            agent_id: agent_id.to_string(),
            path,
            vcs: VcsMetadata::Jj {
                change_id,
                workspace_name: workspace_id.to_string(),
            },
            created_at: Utc::now(),
        })
    }

    /// Non-destructive. Reports `.ontology/` files modified in the workspace.
    ///
    /// Conflicts are not detected upfront — jj stores them as data in the merge
    /// commit (see `merge_workspace`). The protocol layer decides if conflict
    /// data is acceptable before calling merge.
    async fn check_merge(&self, workspace_id: &str, _target: &str) -> Result<MergeStatus> {
        let all_files = self.modified_files(workspace_id)?;
        let ontology_files: Vec<PathBuf> = all_files
            .into_iter()
            .filter(|p| p.starts_with(".ontology"))
            .collect();

        Ok(MergeStatus {
            // jj merges always produce a commit — conflicts are data, not errors
            can_merge: true,
            conflicts: vec![],
            ontology_files_modified: ontology_files,
        })
    }

    /// Creates a merge commit via `jj new --no-edit <target> <change_id>`.
    ///
    /// If the merge commit has conflicts in `.ontology/` files, aborts and
    /// abandons the merge commit. For conflicts in other files, proceeds —
    /// they are stored as jj conflict markers and resolved later.
    async fn merge_workspace(&self, workspace_id: &str, target: &str) -> Result<()> {
        let change_id = self.get_change_id(workspace_id)?;
        let merge_revset = Self::find_merge_commit_revset(&change_id);

        // Create merge commit without touching any workspace's working copy
        self.run_jj(&["new", "--no-edit", target, &change_id])?;

        // Check for conflicts in the new merge commit
        let conflict_out = self.run_jj(&[
            "log",
            "--no-graph",
            "-r",
            &merge_revset,
            "-T",
            CONFLICT_CHECK_TEMPLATE,
        ])?;

        if conflict_out.trim() == "CONFLICT" {
            // Inspect which files are conflicted
            let files_out = self
                .run_jj(&["diff", "--name-only", "-r", &merge_revset])
                .unwrap_or_default();

            let ontology_conflicts: Vec<&str> = files_out
                .lines()
                .filter(|l| l.starts_with(".ontology/"))
                .collect();

            if !ontology_conflicts.is_empty() {
                // Abandon the merge commit — `.ontology/` conflicts require NCL-aware
                // resolution
                if let Err(e) = self.run_jj(&["abandon", &merge_revset]) {
                    warn!("Failed to abandon conflicted merge commit: {e}");
                }
                return Err(WorktreeError::MergeConflict(format!(
                    ".ontology/ files have conflicts that require NCL merge: {}",
                    ontology_conflicts.join(", ")
                )));
            }

            // Non-ontology conflicts are stored as jj data — log advisory and continue
            warn!(
                "Merge commit for workspace {workspace_id} has conflicts (non-ontology). Stored \
                 as jj conflict markers; use `jj resolve` to clean up."
            );
        }

        // Advance the target bookmark to the merge commit
        self.run_jj(&["bookmark", "set", target, "-r", &merge_revset])?;

        info!("Merged jj workspace {workspace_id} into {target}");
        Ok(())
    }

    async fn remove_workspace(&self, workspace_id: &str) -> Result<()> {
        let path = self.workspace_path(workspace_id);

        // Forget the workspace from jj's registry
        if let Err(e) = self.run_jj(&["workspace", "forget", workspace_id]) {
            warn!("jj workspace forget failed for {workspace_id}: {e}");
        }

        // Remove the directory
        if path.exists() {
            std::fs::remove_dir_all(&path).map_err(|e| {
                WorktreeError::RemovalFailed(format!(
                    "Failed to remove workspace directory {}: {e}",
                    path.display()
                ))
            })?;
        }

        info!("Removed jj workspace {workspace_id}");
        Ok(())
    }

    async fn list_workspaces(&self) -> Result<Vec<WorkspaceInfo>> {
        let mut result = Vec::new();

        let entries = match std::fs::read_dir(&self.workspace_base) {
            Ok(e) => e,
            Err(_) => return Ok(result),
        };

        for entry in entries.flatten() {
            if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
                continue;
            }

            let workspace_id = entry.file_name().to_string_lossy().into_owned();
            let path = entry.path();

            // Verify the workspace is known to jj by querying its change_id
            let change_id = match self.get_change_id(&workspace_id) {
                Ok(id) => id,
                Err(_) => continue, // Directory exists but not a jj workspace
            };

            result.push(WorkspaceInfo {
                id: workspace_id.clone(),
                // agent_id is stored in .ontoref-run metadata, not in jj state
                agent_id: workspace_id.clone(),
                path,
                vcs: VcsMetadata::Jj {
                    change_id,
                    workspace_name: workspace_id,
                },
                created_at: Utc::now(),
            });
        }

        Ok(result)
    }

    /// Creates or updates bookmark `agent/<agent_id>/<workspace_id>` pointing
    /// to the workspace's current change, then pushes it to `remote`.
    ///
    /// Uses `bookmark set` (not `create`) for idempotency — safe on re-push
    /// after the workspace commit is amended.
    async fn push_to_remote(&self, workspace_id: &str, agent_id: &str, remote: &str) -> Result<()> {
        let change_id = self.get_change_id(workspace_id)?;
        let bookmark = format!("agent/{agent_id}/{workspace_id}");

        self.run_jj(&["bookmark", "set", &bookmark, "-r", &change_id])?;
        self.run_jj(&["git", "push", "--remote", remote, "--bookmark", &bookmark])?;

        info!("Pushed jj workspace {workspace_id} to {remote} as bookmark {bookmark}");
        Ok(())
    }

    async fn cleanup_orphaned(&self) -> Result<()> {
        // Get workspaces jj knows about
        let known = self.run_jj(&["workspace", "list"])?;
        let known_names: Vec<&str> = known
            .lines()
            .filter_map(|l| l.split(':').next().map(str::trim))
            .collect();

        // For each directory in workspace_base, forget if jj doesn't know it
        let entries = match std::fs::read_dir(&self.workspace_base) {
            Ok(e) => e,
            Err(_) => return Ok(()),
        };

        for entry in entries.flatten() {
            let name = entry.file_name().to_string_lossy().into_owned();
            if !known_names.contains(&name.as_str()) {
                warn!("Removing orphaned workspace directory: {name}");
                let _ = std::fs::remove_dir_all(entry.path());
            }
        }

        Ok(())
    }
}

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

    #[test]
    fn capabilities_correct() {
        let dir = tempfile::TempDir::new().unwrap();
        let ws = tempfile::TempDir::new().unwrap();
        // JjBackend::new requires the repo path to exist
        let b = JjBackend::new(dir.path().to_path_buf(), ws.path().to_path_buf()).unwrap();
        let caps = b.capabilities();
        assert!(caps.conflicts_as_data);
        assert!(caps.stable_change_ids);
        assert!(caps.native_workspaces);
        assert!(!caps.hooks_supported);
    }

    /// Integration tests that require a real jj repo are marked `#[ignore]`.
    /// Run with: cargo test -p vapora-worktree --features jj -- --ignored
    #[tokio::test]
    #[ignore]
    async fn create_and_remove_workspace_integration() {
        let repo = tempfile::TempDir::new().unwrap();
        let ws = tempfile::TempDir::new().unwrap();

        // Init jj colocated repo
        Command::new("jj")
            .current_dir(repo.path())
            .args(["git", "init", "--colocate"])
            .output()
            .unwrap();

        let b = JjBackend::new(repo.path().to_path_buf(), ws.path().to_path_buf()).unwrap();
        let info = b.create_workspace("agent-001", "ws-0001").await.unwrap();

        assert_eq!(info.id, "ws-0001");
        assert!(info.path.exists());
        assert!(matches!(info.vcs, VcsMetadata::Jj { .. }));

        let listed = b.list_workspaces().await.unwrap();
        assert!(listed.iter().any(|w| w.id == "ws-0001"));

        b.remove_workspace("ws-0001").await.unwrap();
        assert!(!info.path.exists());
    }

    #[tokio::test]
    #[ignore]
    async fn change_id_stable_after_describe() {
        let repo = tempfile::TempDir::new().unwrap();
        let ws = tempfile::TempDir::new().unwrap();

        Command::new("jj")
            .current_dir(repo.path())
            .args(["git", "init", "--colocate"])
            .output()
            .unwrap();

        let b = JjBackend::new(repo.path().to_path_buf(), ws.path().to_path_buf()).unwrap();
        let info = b.create_workspace("agent-001", "ws-0001").await.unwrap();

        let change_id_before = match &info.vcs {
            VcsMetadata::Jj { change_id, .. } => change_id.clone(),
            _ => panic!("expected jj metadata"),
        };

        // Amend the workspace commit description — change_id must stay the same
        Command::new("jj")
            .current_dir(repo.path())
            .args([
                "--no-pager",
                "describe",
                "-r",
                &format!("{change_id_before}"),
                "-m",
                "amended",
            ])
            .output()
            .unwrap();

        let change_id_after = b.get_change_id("ws-0001").unwrap();
        assert_eq!(
            change_id_before, change_id_after,
            "change_id must be stable across describe"
        );
    }

    #[tokio::test]
    #[ignore]
    async fn merge_workspace_advances_bookmark() {
        let repo = tempfile::TempDir::new().unwrap();
        let ws = tempfile::TempDir::new().unwrap();

        Command::new("jj")
            .current_dir(repo.path())
            .args(["git", "init", "--colocate"])
            .output()
            .unwrap();

        // Create a file in the repo and set main bookmark
        std::fs::write(repo.path().join("hello.txt"), "hello").unwrap();
        Command::new("jj")
            .current_dir(repo.path())
            .args(["--no-pager", "describe", "-m", "initial"])
            .output()
            .unwrap();
        Command::new("jj")
            .current_dir(repo.path())
            .args(["--no-pager", "bookmark", "create", "main", "-r", "@"])
            .output()
            .unwrap();

        let b = JjBackend::new(repo.path().to_path_buf(), ws.path().to_path_buf()).unwrap();
        let info = b.create_workspace("agent-001", "ws-0001").await.unwrap();

        // Make a change in the workspace
        std::fs::write(info.path.join("world.txt"), "world").unwrap();
        Command::new("jj")
            .current_dir(repo.path())
            .args([
                "--no-pager",
                "describe",
                "-r",
                &format!("{}-@", "ws-0001"),
                "-m",
                "add world",
            ])
            .output()
            .unwrap();

        // Merge — main bookmark must advance to the merge commit
        b.merge_workspace("ws-0001", "main").await.unwrap();

        // Verify main points to a commit that descends from both initial and the
        // workspace change
        let out = Command::new("jj")
            .current_dir(repo.path())
            .args([
                "--no-pager",
                "log",
                "--no-graph",
                "-r",
                "main",
                "-T",
                "description ++ '\n'",
            ])
            .output()
            .unwrap();
        let desc = String::from_utf8_lossy(&out.stdout);
        // Merge commit description is empty by default; main must have advanced
        assert!(
            out.status.success(),
            "main bookmark should be valid after merge"
        );
        drop(desc);
    }
}
