use std::collections::HashMap;
use std::io::Write;
use std::path::Path;

use anyhow::Context;
use clap::Args;

use crate::commands::ls::status_label;
use crate::context::CommandContext;
use crate::environments::Environment;
use crate::usage_stats::load_env_meta;
use enwiro_sdk::gear::{Gear, LoadedGear};

#[derive(Args)]
#[command(author, version, about = "Show information about an environment")]
pub struct EnvInfoArgs {
    /// Name of the environment to query. Defaults to the active environment.
    pub name: Option<String>,

    /// Output as JSON
    #[arg(long)]
    pub json: bool,
}

fn resolve_env_name_from_daemon() -> Option<String> {
    let rt = tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .ok()?;
    rt.block_on(async {
        let client = enwiro_sdk::rpc::connect().await.ok()?;
        let result = enwiro_sdk::rpc::EnwiroRpcClient::env_current(&client)
            .await
            .ok()?;
        result.env_name
    })
}

fn classify_env<W: Write>(
    ctx: &CommandContext<W>,
    name: &str,
    is_environment: bool,
) -> Option<String> {
    if is_environment {
        return Some("environment".into());
    }
    if ctx.find_recipe_in_cache_by_name(name) {
        return Some("recipe".into());
    }
    None
}

const SKIP_FIELDS: &[&str] = &[
    "activation_buffer",
    "switch_buffer",
    "prep_buffer",
    "event_log",
    "status",
    // Surfaced instead as the resolved `path` field, not the raw meta value.
    "main_folder",
];

fn format_value(v: &serde_json::Value) -> String {
    match v {
        serde_json::Value::String(s) => s.clone(),
        serde_json::Value::Null => String::new(),
        serde_json::Value::Bool(b) => b.to_string(),
        serde_json::Value::Number(n) => n.to_string(),
        serde_json::Value::Object(map) => {
            let parts: Vec<String> = map
                .iter()
                .filter(|(_, v)| !v.is_null())
                .map(|(k, v)| format!("{}: {}", k, format_value(v)))
                .collect();
            parts.join(", ")
        }
        serde_json::Value::Array(arr) => {
            let parts: Vec<String> = arr.iter().map(format_value).collect();
            parts.join(", ")
        }
    }
}

fn load_gear(env_dir: &Path) -> HashMap<String, Gear> {
    match LoadedGear::from_env_dir(env_dir) {
        Ok(loaded) => loaded.into_map(),
        Err(_) => HashMap::new(),
    }
}

fn write_text_fields<W: Write>(
    writer: &mut W,
    extra_fields: &[(&str, Option<String>)],
    meta_value: &serde_json::Value,
) -> anyhow::Result<()> {
    let mut fields: Vec<(String, String)> = Vec::new();

    for (key, value) in extra_fields {
        if let Some(v) = value {
            fields.push((key.to_string(), v.clone()));
        }
    }

    if let serde_json::Value::Object(map) = meta_value {
        for (k, v) in map {
            if SKIP_FIELDS.contains(&k.as_str()) {
                continue;
            }
            if v.is_null() {
                continue;
            }
            if extra_fields.iter().any(|(ek, _)| *ek == k.as_str()) {
                continue;
            }
            let formatted = format_value(v);
            if !formatted.is_empty() {
                fields.push((k.clone(), formatted));
            }
        }
    }

    let max_key = fields.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
    for (k, v) in &fields {
        writeln!(
            writer,
            "{:<width$} {}",
            format!("{}:", k),
            v,
            width = max_key + 1
        )?;
    }

    Ok(())
}

fn write_gear_section<W: Write>(
    writer: &mut W,
    gear: &HashMap<String, Gear>,
) -> anyhow::Result<()> {
    if gear.is_empty() {
        return Ok(());
    }

    let mut entries: Vec<(&str, &str)> = gear
        .iter()
        .map(|(name, g)| (name.as_str(), g.description.as_str()))
        .collect();
    entries.sort_by_key(|(name, _)| *name);

    let max_name = entries.iter().map(|(n, _)| n.len()).max().unwrap_or(0);

    writeln!(writer)?;
    writeln!(writer, "Gear:")?;
    for (name, desc) in &entries {
        writeln!(writer, "  {:<width$}  {}", name, desc, width = max_name)?;
    }

    Ok(())
}

pub fn env_info<W: Write>(ctx: &mut CommandContext<W>, args: EnvInfoArgs) -> anyhow::Result<()> {
    let env_name = args
        .name
        .or_else(|| ctx.resolve_environment_name(&None).ok())
        .or_else(resolve_env_name_from_daemon);
    // Resolved once and shared by env_type/env_path below, instead of
    // re-running Environment::get_one's full directory scan for each.
    let resolved_env = env_name
        .as_deref()
        .map(|name| Environment::get_one(&ctx.config.workspaces_directory, name));

    let env_type = match (&env_name, &resolved_env) {
        (Some(name), Some(resolved)) => classify_env(ctx, name, resolved.is_ok()),
        _ => None,
    };

    // Resolved project directory (post main_folder resolution, #375) -- the
    // stable, external-tool-facing way to learn this without parsing
    // meta.json's `main_folder` field directly.
    let env_path = resolved_env.and_then(|r| r.ok()).map(|e| e.path);

    let env_dir = env_name
        .as_deref()
        .map(|name| Path::new(&ctx.config.workspaces_directory).join(name));

    let meta = env_dir.as_deref().map(load_env_meta);

    let status_str = meta
        .as_ref()
        .map(|m| status_label(m.status.as_ref()))
        .filter(|s| *s != "-");

    let gear = env_dir.as_deref().map(load_gear).unwrap_or_default();

    if args.json {
        let mut json_value = meta
            .as_ref()
            .map(|m| serde_json::to_value(m).unwrap_or_default())
            .unwrap_or(serde_json::Value::Object(serde_json::Map::new()));

        if let serde_json::Value::Object(ref mut map) = json_value {
            // Surfaced instead as the resolved `path` field, not the raw meta value.
            map.remove("main_folder");
            if let Some(name) = &env_name {
                map.insert("name".to_string(), serde_json::Value::String(name.clone()));
            }
            if let Some(t) = &env_type {
                map.insert("type".to_string(), serde_json::Value::String(t.clone()));
            }
            if let Some(p) = &env_path {
                map.insert("path".to_string(), serde_json::Value::String(p.clone()));
            }
            if !gear.is_empty() {
                let mut gear_names: Vec<&str> = gear.keys().map(|s| s.as_str()).collect();
                gear_names.sort();
                map.insert(
                    "gear".to_string(),
                    serde_json::Value::Array(
                        gear_names
                            .iter()
                            .map(|n| serde_json::Value::String(n.to_string()))
                            .collect(),
                    ),
                );
            }
        }

        let json = serde_json::to_string(&json_value).context("Failed to serialize output")?;
        write!(ctx.writer, "{json}")?;
    } else {
        let meta_value = meta
            .as_ref()
            .map(|m| serde_json::to_value(m).unwrap_or_default())
            .unwrap_or(serde_json::Value::Object(serde_json::Map::new()));

        let extra_fields: Vec<(&str, Option<String>)> = vec![
            ("name", env_name.clone()),
            ("type", env_type.clone()),
            ("path", env_path.clone()),
            ("status", status_str.map(|s| s.to_string())),
        ];

        write_text_fields(&mut ctx.writer, &extra_fields, &meta_value)?;
        write_gear_section(&mut ctx.writer, &gear)?;
    }

    Ok(())
}

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

    use crate::test_utils::test_utilities::{
        AdapterLog, FakeContext, NotificationLog, context_object,
    };
    use enwiro_daemon::meta::{CookedPhase, EnvStats, Status, UserIntentSignals};

    #[rstest]
    fn test_env_info_text_shows_name_and_type(
        context_object: (tempfile::TempDir, FakeContext, AdapterLog, NotificationLog),
    ) {
        let (_temp_dir, mut ctx, _, _) = context_object;
        ctx.create_mock_environment("my-env");

        env_info(
            &mut ctx,
            EnvInfoArgs {
                name: Some("my-env".to_string()),
                json: false,
            },
        )
        .unwrap();

        let output = ctx.get_output();
        assert!(
            output.contains("name:"),
            "output should contain name field: {output}"
        );
        assert!(
            output.contains("my-env"),
            "output should contain env name: {output}"
        );
        assert!(
            output.contains("type:"),
            "output should contain type field: {output}"
        );
        assert!(
            output.contains("environment"),
            "output should show type as environment: {output}"
        );
    }

    #[rstest]
    fn test_env_info_text_shows_resolved_path(
        context_object: (tempfile::TempDir, FakeContext, AdapterLog, NotificationLog),
    ) {
        let (temp_dir, mut ctx, _, _) = context_object;
        ctx.create_mock_environment("my-env");

        env_info(
            &mut ctx,
            EnvInfoArgs {
                name: Some("my-env".to_string()),
                json: false,
            },
        )
        .unwrap();

        let output = ctx.get_output();
        let expected_path = temp_dir.path().join("my-env").join("my-env");
        assert!(
            output.contains(expected_path.to_str().unwrap()),
            "output should contain the resolved project path: {output}"
        );
    }

    #[rstest]
    fn test_env_info_json_shows_resolved_path(
        context_object: (tempfile::TempDir, FakeContext, AdapterLog, NotificationLog),
    ) {
        let (temp_dir, mut ctx, _, _) = context_object;
        ctx.create_mock_environment("my-env");

        env_info(
            &mut ctx,
            EnvInfoArgs {
                name: Some("my-env".to_string()),
                json: true,
            },
        )
        .unwrap();

        let output = ctx.get_output();
        let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
        let expected_path = temp_dir.path().join("my-env").join("my-env");
        assert_eq!(parsed["path"], expected_path.to_str().unwrap());
    }

    #[rstest]
    fn test_env_info_does_not_leak_raw_main_folder(
        context_object: (tempfile::TempDir, FakeContext, AdapterLog, NotificationLog),
    ) {
        let (temp_dir, mut ctx, _, _) = context_object;
        ctx.create_mock_environment("my-env");
        let meta = EnvStats {
            main_folder: Some("my-env".to_string()),
            ..Default::default()
        };
        std::fs::write(
            temp_dir.path().join("my-env").join("meta.json"),
            serde_json::to_string(&meta).unwrap(),
        )
        .unwrap();

        for json in [false, true] {
            env_info(
                &mut ctx,
                EnvInfoArgs {
                    name: Some("my-env".to_string()),
                    json,
                },
            )
            .unwrap();

            let output = ctx.get_output();
            assert!(
                !output.contains("main_folder"),
                "raw main_folder should not leak into output (json={json}): {output}"
            );
        }
    }

    #[rstest]
    fn test_env_info_text_shows_metadata_fields(
        context_object: (tempfile::TempDir, FakeContext, AdapterLog, NotificationLog),
    ) {
        let (temp_dir, mut ctx, _, _) = context_object;
        ctx.create_mock_environment("my-env");

        let meta = EnvStats {
            description: Some("Fix auth bug".to_string()),
            cookbook: Some("github".to_string()),
            recipe: Some("owner/repo#42".to_string()),
            status: Some(Status::Cooked {
                phase: Some(CookedPhase::Active),
                detail: None,
            }),
            ..Default::default()
        };
        std::fs::write(
            temp_dir.path().join("my-env").join("meta.json"),
            serde_json::to_string(&meta).unwrap(),
        )
        .unwrap();

        env_info(
            &mut ctx,
            EnvInfoArgs {
                name: Some("my-env".to_string()),
                json: false,
            },
        )
        .unwrap();

        let output = ctx.get_output();
        assert!(
            output.contains("github"),
            "output should contain cookbook: {output}"
        );
        assert!(
            output.contains("owner/repo#42"),
            "output should contain recipe: {output}"
        );
        assert!(
            output.contains("Fix auth bug"),
            "output should contain description: {output}"
        );
    }

    #[rstest]
    fn test_env_info_text_omits_absent_fields(
        context_object: (tempfile::TempDir, FakeContext, AdapterLog, NotificationLog),
    ) {
        let (_temp_dir, mut ctx, _, _) = context_object;
        ctx.create_mock_environment("my-env");

        env_info(
            &mut ctx,
            EnvInfoArgs {
                name: Some("my-env".to_string()),
                json: false,
            },
        )
        .unwrap();

        let output = ctx.get_output();
        assert!(
            !output.contains("cookbook:"),
            "absent cookbook should be omitted: {output}"
        );
        assert!(
            !output.contains("recipe:"),
            "absent recipe should be omitted: {output}"
        );
        assert!(
            !output.contains("description:"),
            "absent description should be omitted: {output}"
        );
    }

    #[rstest]
    fn test_env_info_text_omits_signal_buffers(
        context_object: (tempfile::TempDir, FakeContext, AdapterLog, NotificationLog),
    ) {
        let (temp_dir, mut ctx, _, _) = context_object;
        ctx.create_mock_environment("my-env");

        let meta = EnvStats {
            signals: UserIntentSignals {
                activation_buffer: vec![(1_700_000_000, 1.0)],
                switch_buffer: vec![(1_700_000_000, 1.0)],
                prep_buffer: vec![(1_700_000_000, 1.0)],
            },
            ..Default::default()
        };
        std::fs::write(
            temp_dir.path().join("my-env").join("meta.json"),
            serde_json::to_string(&meta).unwrap(),
        )
        .unwrap();

        env_info(
            &mut ctx,
            EnvInfoArgs {
                name: Some("my-env".to_string()),
                json: false,
            },
        )
        .unwrap();

        let output = ctx.get_output();
        assert!(
            !output.contains("activation_buffer"),
            "signal buffers should be hidden: {output}"
        );
        assert!(
            !output.contains("switch_buffer"),
            "signal buffers should be hidden: {output}"
        );
        assert!(
            !output.contains("prep_buffer"),
            "signal buffers should be hidden: {output}"
        );
    }

    #[rstest]
    fn test_env_info_json_includes_all_metadata(
        context_object: (tempfile::TempDir, FakeContext, AdapterLog, NotificationLog),
    ) {
        let (temp_dir, mut ctx, _, _) = context_object;
        ctx.create_mock_environment("my-env");

        let meta = EnvStats {
            description: Some("Fix auth bug".to_string()),
            cookbook: Some("github".to_string()),
            recipe: Some("owner/repo#42".to_string()),
            status: Some(Status::Cooked {
                phase: Some(CookedPhase::Active),
                detail: None,
            }),
            signals: UserIntentSignals {
                activation_buffer: vec![(1_700_000_000, 1.0)],
                ..Default::default()
            },
            ..Default::default()
        };
        std::fs::write(
            temp_dir.path().join("my-env").join("meta.json"),
            serde_json::to_string(&meta).unwrap(),
        )
        .unwrap();

        env_info(
            &mut ctx,
            EnvInfoArgs {
                name: Some("my-env".to_string()),
                json: true,
            },
        )
        .unwrap();

        let output = ctx.get_output();
        let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
        assert_eq!(parsed["name"], "my-env");
        assert_eq!(parsed["type"], "environment");
        assert_eq!(parsed["cookbook"], "github");
        assert_eq!(parsed["recipe"], "owner/repo#42");
        assert_eq!(parsed["description"], "Fix auth bug");
        assert_eq!(parsed["status"]["type"], "cooked");
        assert_eq!(parsed["status"]["phase"], "active");
        assert!(
            parsed["activation_buffer"].is_array(),
            "JSON should include activation_buffer"
        );
    }

    #[rstest]
    fn test_env_info_json_includes_name_and_type(
        context_object: (tempfile::TempDir, FakeContext, AdapterLog, NotificationLog),
    ) {
        let (_temp_dir, mut ctx, _, _) = context_object;
        ctx.create_mock_environment("my-env");

        env_info(
            &mut ctx,
            EnvInfoArgs {
                name: Some("my-env".to_string()),
                json: true,
            },
        )
        .unwrap();

        let output = ctx.get_output();
        let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
        assert_eq!(parsed["name"], "my-env");
        assert_eq!(parsed["type"], "environment");
    }

    #[rstest]
    fn test_env_info_text_no_error_without_json_flag(
        context_object: (tempfile::TempDir, FakeContext, AdapterLog, NotificationLog),
    ) {
        let (_temp_dir, mut ctx, _, _) = context_object;
        ctx.create_mock_environment("my-env");

        let result = env_info(
            &mut ctx,
            EnvInfoArgs {
                name: Some("my-env".to_string()),
                json: false,
            },
        );
        assert!(
            result.is_ok(),
            "plain text output should work without --json"
        );
    }

    #[rstest]
    fn test_env_info_classifies_recipe(
        context_object: (tempfile::TempDir, FakeContext, AdapterLog, NotificationLog),
    ) {
        let (_temp_dir, mut ctx, _, _) = context_object;
        ctx.write_cache_entries(&[("git", "some-recipe", None)]);

        env_info(
            &mut ctx,
            EnvInfoArgs {
                name: Some("some-recipe".to_string()),
                json: false,
            },
        )
        .unwrap();

        let output = ctx.get_output();
        assert!(output.contains("recipe"), "type should be recipe: {output}");
    }

    #[test]
    fn test_format_value_string() {
        let v = serde_json::Value::String("hello".into());
        assert_eq!(format_value(&v), "hello");
    }

    #[test]
    fn test_format_value_object() {
        let v = serde_json::json!({"type": "cooked", "phase": "active"});
        let result = format_value(&v);
        assert!(result.contains("type: cooked"));
        assert!(result.contains("phase: active"));
    }

    #[test]
    fn test_format_value_object_skips_null() {
        let v = serde_json::json!({"type": "done", "outcome": null});
        let result = format_value(&v);
        assert!(result.contains("type: done"));
        assert!(!result.contains("outcome"));
    }

    #[test]
    fn test_write_text_fields_alignment() {
        let mut buf = Vec::new();
        let meta = serde_json::json!({"cookbook": "github"});
        let extra = vec![("name", Some("my-env".to_string()))];

        write_text_fields(&mut buf, &extra, &meta).unwrap();

        let output = String::from_utf8(buf).unwrap();
        let lines: Vec<&str> = output.lines().collect();
        assert_eq!(lines.len(), 2);
        let value_positions: Vec<usize> = lines
            .iter()
            .filter_map(|l| {
                let after_colon = l.find(':')? + 1;
                let rest = &l[after_colon..];
                let trimmed_start = rest.len() - rest.trim_start().len();
                Some(after_colon + trimmed_start)
            })
            .collect();
        assert!(
            value_positions.windows(2).all(|w| w[0] == w[1]),
            "values should be aligned: {:?}",
            lines
        );
    }

    #[test]
    fn test_write_text_fields_skips_none_extra() {
        let mut buf = Vec::new();
        let meta = serde_json::json!({});
        let extra = vec![("name", Some("my-env".to_string())), ("type", None)];

        write_text_fields(&mut buf, &extra, &meta).unwrap();

        let output = String::from_utf8(buf).unwrap();
        assert!(output.contains("name:"));
        assert!(!output.contains("type:"));
    }

    #[rstest]
    fn test_env_info_text_shows_status_as_label(
        context_object: (tempfile::TempDir, FakeContext, AdapterLog, NotificationLog),
    ) {
        let (temp_dir, mut ctx, _, _) = context_object;
        ctx.create_mock_environment("my-env");

        let meta = EnvStats {
            status: Some(Status::Cooked {
                phase: Some(CookedPhase::Active),
                detail: None,
            }),
            ..Default::default()
        };
        std::fs::write(
            temp_dir.path().join("my-env").join("meta.json"),
            serde_json::to_string(&meta).unwrap(),
        )
        .unwrap();

        env_info(
            &mut ctx,
            EnvInfoArgs {
                name: Some("my-env".to_string()),
                json: false,
            },
        )
        .unwrap();

        let output = ctx.get_output();
        assert!(
            output.contains("status:") && output.contains("active"),
            "status should show as 'active', not raw JSON: {output}"
        );
        assert!(
            !output.contains("cooked"),
            "status should not show raw 'cooked' tag: {output}"
        );
        assert!(
            !output.contains("phase:"),
            "status should not show raw 'phase:' key: {output}"
        );
    }

    #[rstest]
    fn test_env_info_text_shows_gear_with_descriptions(
        context_object: (tempfile::TempDir, FakeContext, AdapterLog, NotificationLog),
    ) {
        let (temp_dir, mut ctx, _, _) = context_object;
        ctx.create_mock_environment("my-env");

        let gear_dir = temp_dir.path().join("my-env").join("gear.d");
        std::fs::create_dir_all(&gear_dir).unwrap();
        std::fs::write(
            gear_dir.join("cookbook-github.json"),
            r#"{"version":1,"gear":{"issue":{"description":"Issue page"}}}"#,
        )
        .unwrap();

        env_info(
            &mut ctx,
            EnvInfoArgs {
                name: Some("my-env".to_string()),
                json: false,
            },
        )
        .unwrap();

        let output = ctx.get_output();
        assert!(
            output.contains("Gear:"),
            "output should have a Gear section: {output}"
        );
        assert!(
            output.contains("issue") && output.contains("Issue page"),
            "gear should show name and description: {output}"
        );
    }

    #[rstest]
    fn test_env_info_text_gear_section_separated(
        context_object: (tempfile::TempDir, FakeContext, AdapterLog, NotificationLog),
    ) {
        let (temp_dir, mut ctx, _, _) = context_object;
        ctx.create_mock_environment("my-env");

        let gear_dir = temp_dir.path().join("my-env").join("gear.d");
        std::fs::create_dir_all(&gear_dir).unwrap();
        std::fs::write(
            gear_dir.join("cookbook-github.json"),
            r#"{"version":1,"gear":{"issue":{"description":"Issue page"}}}"#,
        )
        .unwrap();

        env_info(
            &mut ctx,
            EnvInfoArgs {
                name: Some("my-env".to_string()),
                json: false,
            },
        )
        .unwrap();

        let output = ctx.get_output();
        let gear_pos = output.find("Gear:").expect("should have Gear: section");
        let before_gear = &output[..gear_pos];
        assert!(
            before_gear.ends_with("\n\n"),
            "gear section should be separated by a blank line: {output:?}"
        );
    }

    #[rstest]
    fn test_env_info_text_omits_gear_when_no_gear_dir(
        context_object: (tempfile::TempDir, FakeContext, AdapterLog, NotificationLog),
    ) {
        let (_temp_dir, mut ctx, _, _) = context_object;
        ctx.create_mock_environment("my-env");

        env_info(
            &mut ctx,
            EnvInfoArgs {
                name: Some("my-env".to_string()),
                json: false,
            },
        )
        .unwrap();

        let output = ctx.get_output();
        assert!(
            !output.contains("Gear:"),
            "gear section should be omitted when no gear.d dir: {output}"
        );
    }

    #[rstest]
    fn test_env_info_json_includes_gear(
        context_object: (tempfile::TempDir, FakeContext, AdapterLog, NotificationLog),
    ) {
        let (temp_dir, mut ctx, _, _) = context_object;
        ctx.create_mock_environment("my-env");

        let gear_dir = temp_dir.path().join("my-env").join("gear.d");
        std::fs::create_dir_all(&gear_dir).unwrap();
        std::fs::write(
            gear_dir.join("cookbook-github.json"),
            r#"{"version":1,"gear":{"issue":{"description":"Issue page"}}}"#,
        )
        .unwrap();

        env_info(
            &mut ctx,
            EnvInfoArgs {
                name: Some("my-env".to_string()),
                json: true,
            },
        )
        .unwrap();

        let output = ctx.get_output();
        let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
        let gear = parsed["gear"].as_array().expect("gear should be an array");
        assert!(
            gear.iter().any(|v| v == "issue"),
            "gear array should contain 'issue': {output}"
        );
    }
}
