use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Instant;

use crate::agent_meta::{self, AgentMeta};
use crate::claude::{self, ClaudeOutput};
use crate::monitor;
use crate::personalities::{self, ResolvedPersonality};
use crate::progress;
use crate::prompt::{self, PromptConfig};
use crate::room;
use crate::Cli;

/// Run the main ralph loop: iterate, build prompt, call claude, handle output.
///
/// Takes ownership of `token` so it can be updated in-place if the broker
/// restarts and a re-join is needed.
pub async fn run_loop(cli: &Cli, token: String, running: &Arc<AtomicBool>) -> Result<(), String> {
    let progress_file = progress::progress_file_path(cli.issue.as_deref(), &cli.username);
    let mut iteration: u32 = 0;
    let mut token = token;
    let socket_str = cli.socket.as_ref().map(|p| p.display().to_string());
    let socket_ref = socket_str.as_deref();
    let start_time = Instant::now();

    // Resolve personality once: builtin prompt text or file contents.
    let personality_text = resolve_personality_text(cli);
    let personality_text_ref = personality_text.as_deref();

    // Write agent metadata file so claude can read identity without re-joining.
    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
    let meta = AgentMeta {
        username: cli.username.clone(),
        token: token.clone(),
        room_id: cli.room_id.clone(),
        ralph_pid: std::process::id(),
        socket_path: socket_str.clone(),
        personality: cli.personality.clone(),
    };
    if let Err(e) = agent_meta::write_meta(&cwd, &meta) {
        tracing::warn!("failed to write agent metadata: {e}");
    }

    while running.load(Ordering::SeqCst) {
        iteration += 1;

        if at_max_iter(cli, iteration, &token, socket_ref) {
            break;
        }

        tracing::info!("--- iteration {} ---", iteration);

        maybe_send_heartbeat(cli, &token, socket_ref, iteration, start_time);

        let messages = poll_with_token_refresh(cli, &mut token, socket_ref);
        let prompt_text =
            build_iteration_prompt(cli, &messages, &progress_file, &token, personality_text_ref);

        if cli.dry_run {
            println!("=== DRY RUN: prompt ===\n{prompt_text}");
            return Ok(());
        }

        let prompt_file = write_prompt_file(&cli.username, &prompt_text)?;

        let Some(output) = try_invoke_claude(cli, &token, socket_ref, iteration, &prompt_file)
        else {
            cooldown(cli.cooldown, running).await;
            continue;
        };

        process_output(cli, &token, socket_ref, iteration, &output, &progress_file)?;
        wait_for_messages(cli, &token, socket_ref, running).await;
    }

    shutdown(cli, &token, socket_ref, iteration);

    // Clean up agent metadata file on exit.
    agent_meta::cleanup_meta(&cwd);

    Ok(())
}

/// Returns `true` and sends a shutdown message if `iteration` has exceeded
/// `cli.max_iter` (when max_iter > 0).
fn at_max_iter(cli: &Cli, iteration: u32, token: &str, socket_ref: Option<&str>) -> bool {
    if cli.max_iter > 0 && iteration > cli.max_iter {
        tracing::info!("max iterations ({}) reached, stopping", cli.max_iter);
        room::send_message(
            &cli.room_id,
            token,
            &format!("max iterations reached ({}), shutting down", cli.max_iter),
            socket_ref,
        )
        .ok();
        return true;
    }
    false
}

/// Poll room for recent messages, transparently re-joining if the token has
/// expired due to a broker restart.
fn poll_with_token_refresh(
    cli: &Cli,
    token: &mut String,
    socket_ref: Option<&str>,
) -> Vec<room_protocol::Message> {
    match room::poll_messages(&cli.room_id, token, socket_ref) {
        Ok(msgs) => msgs,
        Err(e) if room::detect_token_expiry(&e) => {
            tracing::warn!("token expired during poll, re-joining: {}", e);
            rejoin_and_poll(cli, token, socket_ref)
        }
        Err(_) => Vec::new(),
    }
}

/// Re-join the room to obtain a fresh token, then poll for messages.
fn rejoin_and_poll(
    cli: &Cli,
    token: &mut String,
    socket_ref: Option<&str>,
) -> Vec<room_protocol::Message> {
    match room::join_room(&cli.room_id, &cli.username, socket_ref) {
        Ok(result) => {
            tracing::info!("re-joined as '{}' with new token", result.username);
            *token = result.token;
            if let Err(e) = room::subscribe_room(&cli.room_id, token, socket_ref) {
                tracing::warn!("failed to subscribe after re-join: {}", e);
            }
            room::poll_messages(&cli.room_id, token, socket_ref).unwrap_or_default()
        }
        Err(join_err) => {
            tracing::error!("re-join failed: {}", join_err);
            Vec::new()
        }
    }
}

/// Build the prompt text for this iteration.
fn build_iteration_prompt(
    cli: &Cli,
    messages: &[room_protocol::Message],
    progress_file: &Path,
    token: &str,
    personality_text: Option<&str>,
) -> String {
    let config = PromptConfig {
        room_id: &cli.room_id,
        username: &cli.username,
        token,
        custom_prompt_file: cli.prompt.as_deref(),
        personality_text,
        progress_file,
        issue: cli.issue.as_deref(),
    };
    prompt::build_prompt(&config, messages)
}

/// Write the prompt to a temp file and return its path.
fn write_prompt_file(username: &str, prompt_text: &str) -> Result<PathBuf, String> {
    let path = PathBuf::from(format!("/tmp/ralph-room-prompt-{username}.txt"));
    std::fs::write(&path, prompt_text).map_err(|e| format!("failed to write prompt file: {e}"))?;
    Ok(path)
}

/// Set running-status, invoke claude, and return its output. Returns `None`
/// if spawning fails (the caller should continue to the next iteration).
fn try_invoke_claude(
    cli: &Cli,
    token: &str,
    socket_ref: Option<&str>,
    iteration: u32,
    prompt_file: &Path,
) -> Option<ClaudeOutput> {
    let status_text = match cli.issue.as_deref() {
        Some(issue) => format!("running claude — iteration {iteration} for #{issue}"),
        None => format!("running claude — iteration {iteration}"),
    };
    room::set_status(&cli.room_id, token, &status_text, socket_ref).ok();

    let model = effective_model(cli);
    tracing::info!(
        "running claude -p (model={}, iteration={})",
        model,
        iteration
    );
    let (effective_tools, effective_disallowed) = if cli.allow_all {
        tracing::info!("--allow-all: skipping all tool restrictions");
        (Vec::new(), Vec::new())
    } else {
        let profile = effective_profile(cli);
        let (profile_allow, profile_disallow) =
            claude::merge_profile_with_overrides(profile, &cli.allow_tools, &cli.disallow_tools);
        (
            claude::resolve_allowed_tools(&profile_allow),
            claude::resolve_disallowed_tools(&profile_disallow),
        )
    };

    match claude::spawn_claude(
        model,
        prompt_file,
        &cli.add_dirs,
        &effective_tools,
        &effective_disallowed,
    ) {
        Ok(output) => Some(output),
        Err(e) => {
            tracing::error!("failed to spawn claude: {}", e);
            None
        }
    }
}

/// Process claude's output: log usage, detect context exhaustion, send status
/// updates to the room.
fn process_output(
    cli: &Cli,
    token: &str,
    socket_ref: Option<&str>,
    iteration: u32,
    output: &ClaudeOutput,
    progress_file: &Path,
) -> Result<(), String> {
    tracing::info!("claude exited with code {}", output.exit_code);

    let response = claude::extract_response(&output.raw_json);
    let input_tokens = monitor::parse_usage(&output.raw_json);
    let output_tokens = monitor::parse_output_tokens(&output.raw_json);
    tracing::info!(
        "{}",
        monitor::format_usage_summary(input_tokens, output_tokens)
    );
    monitor::log_usage(progress_file, input_tokens, output_tokens, iteration).ok();

    let should_cycle = monitor::should_restart(input_tokens)
        || claude::detect_context_exhaustion(output.exit_code, &response);

    if should_cycle {
        on_context_cycle(
            cli,
            token,
            socket_ref,
            iteration,
            input_tokens,
            &response,
            progress_file,
        )
    } else if output.exit_code != 0 {
        on_claude_error(cli, token, socket_ref, iteration, output.exit_code);
        Ok(())
    } else {
        Ok(())
    }
}

/// Write progress and broadcast a context-cycle notification.
fn on_context_cycle(
    cli: &Cli,
    token: &str,
    socket_ref: Option<&str>,
    iteration: u32,
    input_tokens: u64,
    response: &str,
    progress_file: &Path,
) -> Result<(), String> {
    progress::write_progress(progress_file, iteration, cli.issue.as_deref(), response)
        .map_err(|e| format!("failed to write progress: {e}"))?;
    room::set_status(
        &cli.room_id,
        token,
        &format!("restarting — context limit at iteration {iteration}"),
        socket_ref,
    )
    .ok();
    room::send_message(
        &cli.room_id,
        token,
        &format!(
            "context limit at iteration {} (tokens: {}), restarting with fresh context",
            iteration, input_tokens
        ),
        socket_ref,
    )
    .ok();
    Ok(())
}

/// Broadcast a claude error notification when exit_code != 0.
fn on_claude_error(
    cli: &Cli,
    token: &str,
    socket_ref: Option<&str>,
    iteration: u32,
    exit_code: i32,
) {
    tracing::warn!(
        "claude failed (exit {}), will retry after cooldown",
        exit_code
    );
    room::set_status(
        &cli.room_id,
        token,
        &format!("retrying — claude error (code {exit_code}) at iteration {iteration}"),
        socket_ref,
    )
    .ok();
    room::send_message(
        &cli.room_id,
        token,
        &format!(
            "claude exited with error (code {exit_code}), retrying in {}s",
            cli.cooldown
        ),
        socket_ref,
    )
    .ok();
}

/// Send a heartbeat /set_status if the current iteration is a heartbeat tick.
///
/// Heartbeats are sent every `cli.heartbeat_interval` iterations. A value of
/// 0 disables heartbeats entirely. The status includes the iteration count
/// and wall-clock uptime since the loop started.
fn maybe_send_heartbeat(
    cli: &Cli,
    token: &str,
    socket_ref: Option<&str>,
    iteration: u32,
    start_time: Instant,
) {
    let interval = cli.heartbeat_interval;
    if interval == 0 || !iteration.is_multiple_of(interval) {
        return;
    }
    let status = format_heartbeat(iteration, start_time.elapsed(), cli.issue.as_deref());
    room::set_status(&cli.room_id, token, &status, socket_ref).ok();
    tracing::debug!("heartbeat sent: {}", status);
}

/// Format the heartbeat status message.
///
/// Includes iteration count, uptime in human-readable form, and optionally
/// the issue number being worked on.
fn format_heartbeat(iteration: u32, uptime: std::time::Duration, issue: Option<&str>) -> String {
    let uptime_str = format_duration(uptime);
    match issue {
        Some(i) => format!("heartbeat — iteration {iteration}, uptime {uptime_str}, issue #{i}"),
        None => format!("heartbeat — iteration {iteration}, uptime {uptime_str}"),
    }
}

/// Format a Duration as a human-readable string (e.g. "5m 30s", "1h 2m 3s").
fn format_duration(d: std::time::Duration) -> String {
    let total_secs = d.as_secs();
    let hours = total_secs / 3600;
    let minutes = (total_secs % 3600) / 60;
    let seconds = total_secs % 60;

    if hours > 0 {
        format!("{hours}h {minutes}m {seconds}s")
    } else if minutes > 0 {
        format!("{minutes}m {seconds}s")
    } else {
        format!("{seconds}s")
    }
}

/// Broadcast offline status and final message after the loop exits.
fn shutdown(cli: &Cli, token: &str, socket_ref: Option<&str>, iteration: u32) {
    tracing::info!("room-ralph stopped after {} iterations", iteration);
    room::set_status(&cli.room_id, token, "offline", socket_ref).ok();
    room::send_message(
        &cli.room_id,
        token,
        &format!("offline (room-ralph stopped after {iteration} iterations)"),
        socket_ref,
    )
    .ok();
}

/// Resolve the personality text from the CLI `--personality` argument.
///
/// If it matches a builtin name, returns the builtin prompt text.
/// If it looks like a file path, reads the file contents.
/// Returns `None` if no personality is set or the file cannot be read.
fn resolve_personality_text(cli: &Cli) -> Option<String> {
    let value = cli.personality.as_deref()?;
    match personalities::resolve(value) {
        ResolvedPersonality::Builtin(p) => Some(p.prompt.to_string()),
        ResolvedPersonality::File(path) => match std::fs::read_to_string(&path) {
            Ok(content) => Some(content),
            Err(e) => {
                tracing::warn!("cannot read personality file {}: {e}", path.display());
                None
            }
        },
    }
}

/// Resolve the effective profile, considering the builtin personality default.
///
/// Precedence: explicit `--profile` > builtin personality default > None.
fn effective_profile(cli: &Cli) -> Option<claude::Profile> {
    if cli.profile.is_some() {
        return cli.profile;
    }
    let value = cli.personality.as_deref()?;
    if let ResolvedPersonality::Builtin(p) = personalities::resolve(value) {
        Some(p.profile)
    } else {
        None
    }
}

/// Resolve the effective model, considering the builtin personality default.
///
/// Precedence: explicit `--model` (if not the default "opus") > builtin
/// personality default > CLI model value.
fn effective_model(cli: &Cli) -> &str {
    // Check if the user explicitly passed --model by seeing if it differs from
    // the clap default. If they didn't override it and a builtin personality
    // has a different default, use the personality's.
    if let Some(value) = cli.personality.as_deref() {
        if let ResolvedPersonality::Builtin(p) = personalities::resolve(value) {
            // Only override if the user didn't explicitly set --model.
            // clap defaults to "opus", so if model == "opus" and the personality
            // has a different default, the personality wins. If the user explicitly
            // passed --model opus, we can't distinguish — but that's fine since
            // they're getting what they asked for either way.
            if cli.model == "opus" && p.default_model != "opus" {
                return p.default_model;
            }
        }
    }
    &cli.model
}

/// Wait for new messages before starting the next iteration.
///
/// Uses `room watch` to block until a message arrives, avoiding busy-polling.
/// Falls back to a cooldown sleep if watch fails (e.g. token expiry).
/// The watch timeout matches the cooldown period — if no messages arrive
/// within that window, the next iteration runs anyway (to handle context
/// cycling and status updates).
async fn wait_for_messages(
    cli: &Cli,
    token: &str,
    socket_ref: Option<&str>,
    running: &Arc<AtomicBool>,
) {
    if !running.load(Ordering::SeqCst) {
        return;
    }
    let timeout = cli.cooldown.max(5);
    match room::watch_room(&cli.room_id, token, 2, Some(timeout), socket_ref) {
        Ok(true) => {
            tracing::debug!("watch: message arrived, proceeding to next iteration");
        }
        Ok(false) => {
            tracing::debug!("watch: timeout after {timeout}s, proceeding anyway");
        }
        Err(e) => {
            tracing::warn!("watch failed: {e}, falling back to cooldown");
            cooldown(cli.cooldown, running).await;
        }
    }
}

/// Sleep for the cooldown period, but wake early if running is set to false.
async fn cooldown(seconds: u64, running: &Arc<AtomicBool>) {
    if !running.load(Ordering::SeqCst) {
        return;
    }
    tracing::debug!("cooldown {}s", seconds);
    tokio::time::sleep(std::time::Duration::from_secs(seconds)).await;
}

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

    #[test]
    fn format_duration_seconds_only() {
        assert_eq!(format_duration(Duration::from_secs(0)), "0s");
        assert_eq!(format_duration(Duration::from_secs(42)), "42s");
        assert_eq!(format_duration(Duration::from_secs(59)), "59s");
    }

    #[test]
    fn format_duration_minutes_and_seconds() {
        assert_eq!(format_duration(Duration::from_secs(60)), "1m 0s");
        assert_eq!(format_duration(Duration::from_secs(90)), "1m 30s");
        assert_eq!(format_duration(Duration::from_secs(3599)), "59m 59s");
    }

    #[test]
    fn format_duration_hours() {
        assert_eq!(format_duration(Duration::from_secs(3600)), "1h 0m 0s");
        assert_eq!(format_duration(Duration::from_secs(3661)), "1h 1m 1s");
        assert_eq!(format_duration(Duration::from_secs(7384)), "2h 3m 4s");
    }

    #[test]
    fn format_heartbeat_with_issue() {
        let uptime = Duration::from_secs(330); // 5m 30s
        let result = format_heartbeat(10, uptime, Some("42"));
        assert_eq!(result, "heartbeat — iteration 10, uptime 5m 30s, issue #42");
    }

    #[test]
    fn format_heartbeat_without_issue() {
        let uptime = Duration::from_secs(7384); // 2h 3m 4s
        let result = format_heartbeat(25, uptime, None);
        assert_eq!(result, "heartbeat — iteration 25, uptime 2h 3m 4s");
    }

    #[test]
    fn format_heartbeat_zero_uptime() {
        let result = format_heartbeat(1, Duration::ZERO, Some("99"));
        assert_eq!(result, "heartbeat — iteration 1, uptime 0s, issue #99");
    }

    #[test]
    fn format_heartbeat_large_iteration() {
        let uptime = Duration::from_secs(86400); // 24h
        let result = format_heartbeat(500, uptime, None);
        assert_eq!(result, "heartbeat — iteration 500, uptime 24h 0m 0s");
    }
}
