//! Project management — tracks known projects and resolves the active project root.
use crate::state::SessionState;
use crate::store::StoreOps;
use serde_json::json;
use std::fs;
use std::path::PathBuf;

use super::scaffold::scaffold_story_kit;

const KEY_LAST_PROJECT: &str = "last_project_path";
const KEY_KNOWN_PROJECTS: &str = "known_projects";

/// Validate that a path exists and is a directory (pure function for testing)
async fn validate_project_path(path: PathBuf) -> Result<(), String> {
    tokio::task::spawn_blocking(move || {
        if !path.exists() {
            return Err(format!("Path does not exist: {}", path.display()));
        }
        if !path.is_dir() {
            return Err(format!("Path is not a directory: {}", path.display()));
        }
        Ok(())
    })
    .await
    .map_err(|e| format!("Task failed: {}", e))?
}

pub(crate) async fn ensure_project_root_with_story_kit(
    path: PathBuf,
    port: u16,
) -> Result<(), String> {
    tokio::task::spawn_blocking(move || {
        if !path.exists() {
            fs::create_dir_all(&path)
                .map_err(|e| format!("Failed to create project directory: {}", e))?;
        }
        if !path.join(".huskies").is_dir() {
            scaffold_story_kit(&path, port)?;
        }
        // Always update .mcp.json with the current port so the bot connects to
        // the right endpoint even when HUSKIES_PORT changes between restarts.
        let mcp_content = format!(
            "{{\n  \"mcpServers\": {{\n    \"huskies\": {{\n      \"type\": \"http\",\n      \"url\": \"http://localhost:{port}/mcp\"\n    }}\n  }}\n}}\n"
        );
        fs::write(path.join(".mcp.json"), mcp_content)
            .map_err(|e| format!("Failed to write .mcp.json: {}", e))?;
        Ok(())
    })
    .await
    .map_err(|e| format!("Task failed: {}", e))?
}

pub async fn open_project(
    path: String,
    state: &SessionState,
    store: &dyn StoreOps,
    port: u16,
) -> Result<String, String> {
    let p = PathBuf::from(&path);

    ensure_project_root_with_story_kit(p.clone(), port).await?;
    validate_project_path(p.clone()).await?;

    {
        // TRACE:MERGE-DEBUG — remove once root cause is found
        crate::slog!(
            "[MERGE-DEBUG] open_project: setting project_root to {:?}",
            p
        );
        let mut root = state.project_root.lock().map_err(|e| e.to_string())?;
        *root = Some(p);
    }

    store.set(KEY_LAST_PROJECT, json!(path));

    let mut known_projects = get_known_projects(store)?;

    known_projects.retain(|p| p != &path);
    known_projects.insert(0, path.clone());
    store.set(KEY_KNOWN_PROJECTS, json!(known_projects));

    store.save()?;

    Ok(path)
}

/// Close the active project by clearing the in-memory root and stored path.
#[allow(dead_code)]
pub fn close_project(state: &SessionState, store: &dyn StoreOps) -> Result<(), String> {
    {
        // TRACE:MERGE-DEBUG — remove once root cause is found
        crate::slog!("[MERGE-DEBUG] close_project: setting project_root to None");
        let mut root = state.project_root.lock().map_err(|e| e.to_string())?;
        *root = None;
    }

    store.delete(KEY_LAST_PROJECT);
    store.save()?;

    Ok(())
}

/// Return the active project path, restoring it from the store if needed.
#[allow(dead_code)]
pub fn get_current_project(
    state: &SessionState,
    store: &dyn StoreOps,
) -> Result<Option<String>, String> {
    {
        let root = state.project_root.lock().map_err(|e| e.to_string())?;
        if let Some(path) = &*root {
            return Ok(Some(path.to_string_lossy().to_string()));
        }
    }

    if let Some(path_str) = store
        .get(KEY_LAST_PROJECT)
        .as_ref()
        .and_then(|val| val.as_str())
    {
        let p = PathBuf::from(path_str);
        if p.exists() && p.is_dir() {
            // TRACE:MERGE-DEBUG — remove once root cause is found
            crate::slog!(
                "[MERGE-DEBUG] get_current_project: project_root was None, \
                 restoring from store to {:?}",
                p
            );
            let mut root = state.project_root.lock().map_err(|e| e.to_string())?;
            *root = Some(p);
            return Ok(Some(path_str.to_string()));
        }
    }

    Ok(None)
}

/// List all previously-opened project paths from the store.
#[allow(dead_code)]
pub fn get_known_projects(store: &dyn StoreOps) -> Result<Vec<String>, String> {
    let projects = store
        .get(KEY_KNOWN_PROJECTS)
        .and_then(|val| val.as_array().cloned())
        .unwrap_or_default()
        .into_iter()
        .filter_map(|val| val.as_str().map(|s| s.to_string()))
        .collect();

    Ok(projects)
}

/// Remove a project path from the known-projects list in the store.
#[allow(dead_code)]
pub fn forget_known_project(path: String, store: &dyn StoreOps) -> Result<(), String> {
    let mut known_projects = get_known_projects(store)?;
    let original_len = known_projects.len();

    known_projects.retain(|p| p != &path);

    if known_projects.len() == original_len {
        return Ok(());
    }

    store.set(KEY_KNOWN_PROJECTS, json!(known_projects));
    store.save()?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::store::JsonFileStore;
    use tempfile::tempdir;

    fn make_store(dir: &tempfile::TempDir) -> JsonFileStore {
        JsonFileStore::new(dir.path().join("test_store.json")).unwrap()
    }

    fn make_state_with_root(path: PathBuf) -> SessionState {
        let state = SessionState::default();
        {
            let mut root = state.project_root.lock().unwrap();
            *root = Some(path);
        }
        state
    }

    // --- open/close/get project ---

    #[tokio::test]
    async fn open_project_sets_root_and_persists() {
        let dir = tempdir().unwrap();
        let project_dir = dir.path().join("myproject");
        fs::create_dir_all(&project_dir).unwrap();
        let store = make_store(&dir);
        let state = SessionState::default();

        let result = open_project(
            project_dir.to_string_lossy().to_string(),
            &state,
            &store,
            3001,
        )
        .await;

        assert!(result.is_ok());
        let root = state.get_project_root().unwrap();
        assert_eq!(root, project_dir);
    }

    #[tokio::test]
    async fn open_project_updates_mcp_json_with_current_port() {
        // .mcp.json must always be updated with the actual running port so the
        // bot connects to the right MCP endpoint even when HUSKIES_PORT changes.
        let dir = tempdir().unwrap();
        let project_dir = dir.path().join("myproject");
        fs::create_dir_all(&project_dir).unwrap();
        // Pre-write .mcp.json with a different port to simulate a stale file.
        let mcp_path = project_dir.join(".mcp.json");
        fs::write(&mcp_path, "{\"stale\": true}").unwrap();
        let store = make_store(&dir);
        let state = SessionState::default();

        open_project(
            project_dir.to_string_lossy().to_string(),
            &state,
            &store,
            3002,
        )
        .await
        .unwrap();

        let content = fs::read_to_string(&mcp_path).unwrap();
        assert!(
            content.contains("3002"),
            "open_project must update .mcp.json with the actual running port"
        );
        assert!(
            content.contains("localhost"),
            "mcp.json must reference localhost"
        );
    }

    #[tokio::test]
    async fn open_project_writes_mcp_json_when_missing() {
        let dir = tempdir().unwrap();
        let project_dir = dir.path().join("myproject");
        fs::create_dir_all(&project_dir).unwrap();
        let store = make_store(&dir);
        let state = SessionState::default();

        open_project(
            project_dir.to_string_lossy().to_string(),
            &state,
            &store,
            3001,
        )
        .await
        .unwrap();

        let mcp_path = project_dir.join(".mcp.json");
        assert!(
            mcp_path.exists(),
            "open_project should write .mcp.json for new projects"
        );
        let content = fs::read_to_string(&mcp_path).unwrap();
        assert!(
            content.contains("3001"),
            "mcp.json should reference the server port"
        );
        assert!(
            content.contains("localhost"),
            "mcp.json should reference localhost"
        );
    }

    /// Regression test for bug 371: no-arg `huskies` in empty directory skips scaffold.
    /// `open_project` on a directory without `.huskies/` must create all required scaffold
    /// files — the same files that `huskies .` produces.
    #[tokio::test]
    async fn open_project_on_empty_dir_creates_full_scaffold() {
        let dir = tempdir().unwrap();
        let project_dir = dir.path().join("myproject");
        fs::create_dir_all(&project_dir).unwrap();
        let store = make_store(&dir);
        let state = SessionState::default();

        open_project(
            project_dir.to_string_lossy().to_string(),
            &state,
            &store,
            3001,
        )
        .await
        .unwrap();

        assert!(
            project_dir.join(".huskies/project.toml").exists(),
            "open_project must create .huskies/project.toml"
        );
        assert!(
            project_dir.join(".mcp.json").exists(),
            "open_project must create .mcp.json"
        );
        assert!(
            project_dir.join("CLAUDE.md").exists(),
            "open_project must create CLAUDE.md"
        );
        assert!(
            project_dir.join("script/test").exists(),
            "open_project must create script/test"
        );
    }

    #[tokio::test]
    async fn close_project_clears_root() {
        let dir = tempdir().unwrap();
        let project_dir = dir.path().join("myproject");
        fs::create_dir_all(&project_dir).unwrap();
        let store = make_store(&dir);
        let state = make_state_with_root(project_dir);

        close_project(&state, &store).unwrap();

        let root = state.project_root.lock().unwrap();
        assert!(root.is_none());
    }

    #[tokio::test]
    async fn get_current_project_returns_none_when_no_project() {
        let dir = tempdir().unwrap();
        let store = make_store(&dir);
        let state = SessionState::default();

        let result = get_current_project(&state, &store).unwrap();
        assert!(result.is_none());
    }

    #[tokio::test]
    async fn get_current_project_returns_active_root() {
        let dir = tempdir().unwrap();
        let store = make_store(&dir);
        let state = make_state_with_root(dir.path().to_path_buf());

        let result = get_current_project(&state, &store).unwrap();
        assert!(result.is_some());
    }

    // --- known projects ---

    #[test]
    fn known_projects_empty_by_default() {
        let dir = tempdir().unwrap();
        let store = make_store(&dir);
        let projects = get_known_projects(&store).unwrap();
        assert!(projects.is_empty());
    }

    #[tokio::test]
    async fn open_project_adds_to_known_projects() {
        let dir = tempdir().unwrap();
        let project_dir = dir.path().join("proj1");
        fs::create_dir_all(&project_dir).unwrap();
        let store = make_store(&dir);
        let state = SessionState::default();

        open_project(
            project_dir.to_string_lossy().to_string(),
            &state,
            &store,
            3001,
        )
        .await
        .unwrap();

        let projects = get_known_projects(&store).unwrap();
        assert_eq!(projects.len(), 1);
    }

    #[test]
    fn forget_known_project_removes_it() {
        let dir = tempdir().unwrap();
        let store = make_store(&dir);

        store.set(KEY_KNOWN_PROJECTS, json!(["/a", "/b", "/c"]));
        forget_known_project("/b".to_string(), &store).unwrap();

        let projects = get_known_projects(&store).unwrap();
        assert_eq!(projects, vec!["/a", "/c"]);
    }

    #[test]
    fn forget_unknown_project_is_noop() {
        let dir = tempdir().unwrap();
        let store = make_store(&dir);

        store.set(KEY_KNOWN_PROJECTS, json!(["/a"]));
        forget_known_project("/nonexistent".to_string(), &store).unwrap();

        let projects = get_known_projects(&store).unwrap();
        assert_eq!(projects, vec!["/a"]);
    }

    // --- validate_project_path ---

    #[tokio::test]
    async fn validate_project_path_rejects_missing() {
        let result = validate_project_path(PathBuf::from("/nonexistent/path")).await;
        assert!(result.is_err());
    }

    #[tokio::test]
    async fn validate_project_path_rejects_file() {
        let dir = tempdir().unwrap();
        let file = dir.path().join("not_a_dir.txt");
        fs::write(&file, "").unwrap();

        let result = validate_project_path(file).await;
        assert!(result.is_err());
    }

    #[tokio::test]
    async fn validate_project_path_accepts_directory() {
        let dir = tempdir().unwrap();
        let result = validate_project_path(dir.path().to_path_buf()).await;
        assert!(result.is_ok());
    }

    // --- open_project scaffolding ---

    #[tokio::test]
    async fn open_project_scaffolds_when_story_kit_missing() {
        let dir = tempdir().unwrap();
        let project_dir = dir.path().join("myproject");
        fs::create_dir_all(&project_dir).unwrap();
        let store = make_store(&dir);
        let state = SessionState::default();

        open_project(
            project_dir.to_string_lossy().to_string(),
            &state,
            &store,
            3001,
        )
        .await
        .unwrap();

        // .huskies/ should have been created automatically
        assert!(project_dir.join(".huskies").is_dir());
    }

    #[tokio::test]
    async fn open_project_does_not_overwrite_existing_story_kit() {
        let dir = tempdir().unwrap();
        let project_dir = dir.path().join("myproject");
        let sk_dir = project_dir.join(".huskies");
        fs::create_dir_all(&sk_dir).unwrap();
        let readme = sk_dir.join("README.md");
        fs::write(&readme, "custom content").unwrap();
        let store = make_store(&dir);
        let state = SessionState::default();

        open_project(
            project_dir.to_string_lossy().to_string(),
            &state,
            &store,
            3001,
        )
        .await
        .unwrap();

        // Existing .huskies/ content should not be overwritten
        assert_eq!(fs::read_to_string(&readme).unwrap(), "custom content");
    }
}
