//! Rich event → spoken-text logic, ported from `app/src/lib/narrator.ts`.
//!
//! TARS-style narration with two adjustable knobs:
//!
//! - **Humor** (0–100) — how much personality bleeds through. 0 is pure
//!   status, 75 is the TARS default, 100 is full commentary.
//! - **Act** (1, 2, or 3) — dramatic-arc position. Lines vary by act:
//!   Act I is observational + wide-angle, Act II is building tension,
//!   Act III is conclusive + measured.
//!
//! Pure functions — no state, no I/O, no audio. Channels (CLI, Bevy
//! GUI, future) call these to get spoken text and feed it to a
//! [`crate::Speaker`].
//!
//! These lines are spoken aloud. They're written to be heard, not read.

/// Tone of a narrative beat — informs downstream voice tuning. Mirrors
/// the TS NarrativeTone union.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NarrativeTone {
    Observe,
    Perceive,
    Search,
    Infer,
    Act,
    Verify,
    Learn,
    Recover,
}

#[derive(Debug, Clone)]
pub struct NarrativeBeat {
    pub tone: NarrativeTone,
    pub text: String,
}

/// Three-act structure mirrors `tokhn_voice` clients (Bevy app, etc.).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Act {
    One,
    Two,
    Three,
}

#[inline]
fn humor_check(humor: u8, threshold: u8) -> bool {
    humor >= threshold
}

/// Trim a path to its filename if too long, otherwise leave it.
fn short_path(p: &str, max: usize) -> String {
    if p.is_empty() {
        return "a file".into();
    }
    if p.chars().count() <= max {
        return p.to_string();
    }
    if let Some(last) = p.rsplit('/').next() {
        return last.to_string();
    }
    p.chars().take(max).collect()
}

fn clip(s: &str, max: usize) -> String {
    if s.chars().count() <= max {
        return s.to_string();
    }
    let mut out: String = s.chars().take(max - 1).collect();
    out.push('…');
    out
}

/// Narrate a tool call. Returns `None` for tools that don't deserve
/// spoken commentary at the current humor / act level (e.g. git_status
/// is too noisy, repeated reads are skipped at the call site).
pub fn narrate_tool_call(
    tool: &str,
    path: Option<&str>,
    pattern: Option<&str>,
    command: Option<&str>,
    commit_message: Option<&str>,
    humor: u8,
    act: Act,
) -> Option<NarrativeBeat> {
    let path_str = path.unwrap_or("");
    let pat = pattern.unwrap_or("").trim();

    match tool {
        "read_file" => {
            let p = short_path(path_str, 36);
            let text = match act {
                Act::One => format!("Surveying {p}."),
                Act::Three => format!("Final look at {p}."),
                Act::Two => format!("Reading {p}."),
            };
            Some(NarrativeBeat {
                tone: NarrativeTone::Perceive,
                text,
            })
        }
        "grep_files" => {
            if pat.is_empty() {
                return None;
            }
            let prefix = if matches!(act, Act::One) {
                "Searching for"
            } else {
                "Looking for"
            };
            Some(NarrativeBeat {
                tone: NarrativeTone::Search,
                text: format!("{prefix} \"{}\".", clip(pat, 28)),
            })
        }
        "find_files" => {
            if pat.is_empty() {
                return None;
            }
            let prefix = if matches!(act, Act::One) {
                "Mapping"
            } else {
                "Scanning for"
            };
            Some(NarrativeBeat {
                tone: NarrativeTone::Search,
                text: format!("{prefix} {}.", clip(pat, 32)),
            })
        }
        "list_dir" => {
            let bare = path_str.is_empty() || path_str == "." || path_str == "./";
            let p = short_path(path_str, 36);
            let text = match (act, bare) {
                (Act::One, true) => "Getting the lay of the land.".into(),
                (Act::One, false) => format!("Orienting. {p}."),
                (_, true) => "Surveying the workspace.".into(),
                (_, false) => format!("Checking {p}."),
            };
            Some(NarrativeBeat {
                tone: NarrativeTone::Perceive,
                text,
            })
        }
        "write_file" => {
            let p = short_path(path_str, 36);
            let text = if matches!(act, Act::Three) {
                format!("Finalizing {p}.")
            } else {
                format!("Writing {p}.")
            };
            Some(NarrativeBeat {
                tone: NarrativeTone::Act,
                text,
            })
        }
        "edit_file" => {
            let p = short_path(path_str, 36);
            let text = if matches!(act, Act::Three) && humor_check(humor, 75) {
                format!("Last touch — {p}.")
            } else {
                format!("Editing {p}.")
            };
            Some(NarrativeBeat {
                tone: NarrativeTone::Act,
                text,
            })
        }
        "shell" => {
            let raw = command.unwrap_or("").trim();
            if raw.is_empty() {
                return None;
            }
            let lower = raw.to_ascii_lowercase();
            if lower.contains("test") || lower.contains("pytest") || lower.contains("cargo test") {
                let text = if matches!(act, Act::Three) {
                    "Final verification.".into()
                } else {
                    "Running tests.".into()
                };
                return Some(NarrativeBeat {
                    tone: NarrativeTone::Verify,
                    text,
                });
            }
            if lower.contains("build") || lower.contains("make") {
                let text = if matches!(act, Act::Three) {
                    "Building — moment of truth.".into()
                } else {
                    "Building.".into()
                };
                return Some(NarrativeBeat {
                    tone: NarrativeTone::Verify,
                    text,
                });
            }
            Some(NarrativeBeat {
                tone: NarrativeTone::Act,
                text: format!("Running `{}`.", clip(raw, 40)),
            })
        }
        "git_status" => None, // too noisy
        "git_diff" => Some(NarrativeBeat {
            tone: NarrativeTone::Perceive,
            text: "Checking the diff.".into(),
        }),
        "git_commit" => {
            let msg = commit_message.unwrap_or("");
            let text = if matches!(act, Act::Three) && humor_check(humor, 75) {
                format!("Sealing it. \"{}\"", clip(msg, 36))
            } else {
                format!("Committing. \"{}\"", clip(msg, 40))
            };
            Some(NarrativeBeat {
                tone: NarrativeTone::Act,
                text,
            })
        }
        _ => None,
    }
}

/// Narrate a tool result — only for failures and a few interesting
/// successes. Most successful results stay quiet.
pub fn narrate_tool_result(
    tool: &str,
    output_first_line: &str,
    success: bool,
    humor: u8,
) -> Option<NarrativeBeat> {
    if !success {
        let short = clip(output_first_line, 60);
        let text = if humor_check(humor, 75) {
            if short.is_empty() {
                "That didn't work.".into()
            } else {
                format!("That failed. {short}")
            }
        } else if short.is_empty() {
            "Failed.".into()
        } else {
            format!("Failed. {short}")
        };
        return Some(NarrativeBeat {
            tone: NarrativeTone::Recover,
            text,
        });
    }

    match tool {
        "write_file" => humor_check(humor, 25).then(|| NarrativeBeat {
            tone: NarrativeTone::Act,
            text: "Written.".into(),
        }),
        "edit_file" => humor_check(humor, 25).then(|| NarrativeBeat {
            tone: NarrativeTone::Act,
            text: "Applied.".into(),
        }),
        _ => None,
    }
}

/// Narrate the start of an intent.
pub fn narrate_started(goal_len: usize, humor: u8) -> NarrativeBeat {
    if !humor_check(humor, 50) {
        return NarrativeBeat {
            tone: NarrativeTone::Observe,
            text: "Starting.".into(),
        };
    }
    if goal_len < 20 {
        return NarrativeBeat {
            tone: NarrativeTone::Observe,
            text: "On it.".into(),
        };
    }
    if humor_check(humor, 85) {
        return NarrativeBeat {
            tone: NarrativeTone::Observe,
            text: "Understood. Let's see what we're working with.".into(),
        };
    }
    NarrativeBeat {
        tone: NarrativeTone::Observe,
        text: "Understood. Working.".into(),
    }
}

/// Act-transition commentary — only fires when crossing act boundaries.
pub fn narrate_act_transition(
    act: Act,
    completed_steps: usize,
    humor: u8,
) -> Option<NarrativeBeat> {
    if !humor_check(humor, 50) {
        return None;
    }
    match act {
        Act::Two => {
            let text = if humor_check(humor, 75) {
                "Orientation complete. Now the real work.".into()
            } else {
                "Moving forward.".into()
            };
            Some(NarrativeBeat {
                tone: NarrativeTone::Infer,
                text,
            })
        }
        Act::Three => {
            let text = if humor_check(humor, 75) {
                format!("{completed_steps} steps in. Bringing it home.")
            } else {
                "Final stretch.".into()
            };
            Some(NarrativeBeat {
                tone: NarrativeTone::Infer,
                text,
            })
        }
        Act::One => None,
    }
}

/// Narrate the final outcome.
pub fn narrate_completed(steps: usize, total_ms: u64, success: bool, humor: u8) -> NarrativeBeat {
    let secs = total_ms as f64 / 1000.0;
    if success {
        if !humor_check(humor, 25) {
            return NarrativeBeat {
                tone: NarrativeTone::Verify,
                text: "Complete.".into(),
            };
        }
        if steps <= 3 {
            return NarrativeBeat {
                tone: NarrativeTone::Verify,
                text: format!("Done. {secs:.1} seconds. Clean."),
            };
        }
        if steps <= 8 {
            return NarrativeBeat {
                tone: NarrativeTone::Verify,
                text: format!("Mission complete. {steps} steps — {secs:.1} seconds."),
            };
        }
        if humor_check(humor, 90) {
            return NarrativeBeat {
                tone: NarrativeTone::Verify,
                text: format!("{steps} steps. {secs:.1} seconds. Not my fastest — but it's solid."),
            };
        }
        if humor_check(humor, 75) {
            return NarrativeBeat {
                tone: NarrativeTone::Verify,
                text: format!("Done. {steps} steps. Took longer than I'd like."),
            };
        }
        return NarrativeBeat {
            tone: NarrativeTone::Verify,
            text: format!("Mission complete. {steps} steps."),
        };
    }
    let text = if humor_check(humor, 75) {
        "Didn't land clean. Could retry with a different approach.".into()
    } else {
        "Incomplete.".into()
    };
    NarrativeBeat {
        tone: NarrativeTone::Recover,
        text,
    }
}

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

    #[test]
    fn read_file_act_one_says_surveying() {
        let beat = narrate_tool_call(
            "read_file",
            Some("src/lib.rs"),
            None,
            None,
            None,
            75,
            Act::One,
        )
        .unwrap();
        assert!(beat.text.contains("Surveying"));
        assert_eq!(beat.tone, NarrativeTone::Perceive);
    }

    #[test]
    fn read_file_act_three_says_final_look() {
        let beat = narrate_tool_call(
            "read_file",
            Some("src/lib.rs"),
            None,
            None,
            None,
            75,
            Act::Three,
        )
        .unwrap();
        assert!(beat.text.contains("Final look"));
    }

    #[test]
    fn git_status_is_silent() {
        let beat = narrate_tool_call("git_status", None, None, None, None, 100, Act::Two);
        assert!(beat.is_none(), "git_status should never narrate");
    }

    #[test]
    fn shell_test_command_says_running_tests_act_two() {
        let beat = narrate_tool_call(
            "shell",
            None,
            None,
            Some("cargo test --workspace"),
            None,
            75,
            Act::Two,
        )
        .unwrap();
        assert_eq!(beat.text, "Running tests.");
    }

    #[test]
    fn shell_test_command_says_final_verification_act_three() {
        let beat = narrate_tool_call(
            "shell",
            None,
            None,
            Some("cargo test"),
            None,
            75,
            Act::Three,
        )
        .unwrap();
        assert_eq!(beat.text, "Final verification.");
    }

    #[test]
    fn write_file_act_three_says_finalizing() {
        let beat = narrate_tool_call(
            "write_file",
            Some("src/main.rs"),
            None,
            None,
            None,
            50,
            Act::Three,
        )
        .unwrap();
        assert!(beat.text.starts_with("Finalizing"));
    }

    #[test]
    fn narrate_completed_high_humor_long_run_quips() {
        let beat = narrate_completed(15, 30_000, true, 95);
        assert!(beat.text.contains("Not my fastest"));
    }

    #[test]
    fn narrate_completed_zero_humor_just_says_complete() {
        let beat = narrate_completed(15, 30_000, true, 0);
        assert_eq!(beat.text, "Complete.");
    }

    #[test]
    fn narrate_act_transition_humor_zero_returns_none() {
        let beat = narrate_act_transition(Act::Two, 5, 0);
        assert!(beat.is_none());
    }

    #[test]
    fn narrate_act_transition_act_three_full_humor_says_bringing_it_home() {
        let beat = narrate_act_transition(Act::Three, 12, 100).unwrap();
        assert!(beat.text.contains("Bringing it home"));
    }

    #[test]
    fn narrate_tool_result_failure_with_high_humor() {
        let beat = narrate_tool_result("shell", "command not found: foo", false, 90).unwrap();
        assert!(beat.text.contains("That failed"));
        assert_eq!(beat.tone, NarrativeTone::Recover);
    }

    #[test]
    fn narrate_tool_result_success_is_quiet_for_most_tools() {
        let beat = narrate_tool_result("grep_files", "12 matches", true, 100);
        assert!(beat.is_none());
    }
}
