//! 1.2.15+ Phase R.1 — crash report writer and panic hook.
//!
//! Survivability layer: when something panics anywhere
//! in inkhaven, this module's hook
//!
//!   1. attempts to flush every dirty editor buffer
//!      to a `<path>.inkhaven-rescue` companion file
//!      via [`rescue::flush_dirty_buffers`],
//!   2. captures the panic context + project state
//!      + recent action ring + environment fingerprint
//!      into a [`CrashReport`],
//!   3. serialises the report to HJSON and writes it
//!      atomically to `inkhaven-crash-<ts>.hjson` in
//!      the current working directory,
//!   4. restores the terminal (best effort) so the
//!      user's shell isn't stuck in raw-mode +
//!      alternate-screen,
//!   5. prints a one-line breadcrumb to stderr telling
//!      the user where the report landed.
//!
//! Every step is wrapped in `let _ = …` so a failure in
//! one step (e.g. disk full while writing the report)
//! doesn't prevent the others from trying.  The hook is
//! best-effort — the user must always end up with a
//! restored terminal even when nothing else worked.
//!
//! The hook reads from a process-wide
//! [`CrashContext`] singleton that the rest of the app
//! updates on meaningful state transitions (project
//! open, paragraph open/close, save, action dispatch,
//! buffer mutation that flips dirty).  The hook itself
//! never touches App state directly — that would
//! deadlock on the lock the panicking thread is
//! holding.

pub mod actions;
pub mod rescue;
pub mod report;

use std::path::PathBuf;
use std::sync::{Mutex, OnceLock};

pub use actions::{ActionRecord, ActionRing};
// `RescueOutcome` is consumed by the recover CLI in
// R.2; the re-export keeps the public API together.
#[allow(unused_imports)]
pub use rescue::{DirtyMirror, RescueOutcome};
pub use report::CrashReport;

thread_local! {
    /// When set, the panic hook treats a panic on THIS thread as
    /// already-handled: it logs and returns without restoring the
    /// terminal, flushing rescue, or writing a report. Set only by
    /// `suppress_panic_report` around a `catch_unwind` that recovers
    /// the panic (H5 — isolating user Bund hooks so a buggy hook can't
    /// tear down the live editor).
    static SUPPRESS_PANIC_REPORT: std::cell::Cell<bool> =
        const { std::cell::Cell::new(false) };
}

/// Run `f` with the crash panic-hook suppressed on the current thread.
/// The flag is reset even if `f` panics (RAII), so a real later panic
/// on this thread is still reported normally. Pair with a
/// `catch_unwind` at the call site to actually recover.
pub(crate) fn suppress_panic_report<F: FnOnce() -> R, R>(f: F) -> R {
    struct Reset;
    impl Drop for Reset {
        fn drop(&mut self) {
            SUPPRESS_PANIC_REPORT.with(|c| c.set(false));
        }
    }
    SUPPRESS_PANIC_REPORT.with(|c| c.set(true));
    let _reset = Reset;
    f()
}

fn panic_report_suppressed() -> bool {
    SUPPRESS_PANIC_REPORT.with(|c| c.get())
}

/// Maximum size of the recent-action ring.  Each entry
/// is ~80 bytes typical, so 50 caps at ~4 KB — small
/// enough to keep around forever, large enough to
/// reconstruct what the user was doing when the panic
/// fired.
pub const ACTION_RING_CAP: usize = 50;

/// Process-wide state that the panic hook reads at
/// crash time.  Updated by the App on every meaningful
/// state transition.  The hook itself never mutates
/// this — it only snapshots.
pub struct CrashContext {
    inner: Mutex<CrashState>,
}

#[derive(Default, Debug, Clone)]
pub struct CrashState {
    pub project_path: Option<PathBuf>,
    pub open_book: Option<String>,
    pub open_paragraph: Option<String>,
    pub open_paragraph_rel_path: Option<String>,
    pub actions: ActionRing,
    /// Keyed by the paragraph's relative path under the
    /// project root.  The mirror is the
    /// last-known-good buffer state — content + cursor.
    /// Cleared on save (which means "this buffer is
    /// no longer dirty, no rescue needed").
    pub dirty_buffers: std::collections::HashMap<String, DirtyMirror>,
}

static CONTEXT: OnceLock<CrashContext> = OnceLock::new();

/// Access the process-wide crash context.  Initialises
/// on first call.
pub fn context() -> &'static CrashContext {
    CONTEXT.get_or_init(|| CrashContext {
        inner: Mutex::new(CrashState::default()),
    })
}

impl CrashContext {
    /// Update the project path.  Called once per TUI
    /// session, just after the project store opens.
    pub fn set_project(&self, path: PathBuf) {
        if let Ok(mut s) = self.inner.lock() {
            s.project_path = Some(path);
        }
    }

    /// Update the open-paragraph triple.  Called on
    /// every `load_paragraph` / `close_paragraph`.
    pub fn set_open_paragraph(
        &self,
        book: Option<String>,
        paragraph: Option<String>,
        rel_path: Option<String>,
    ) {
        if let Ok(mut s) = self.inner.lock() {
            s.open_book = book;
            s.open_paragraph = paragraph;
            s.open_paragraph_rel_path = rel_path;
        }
    }

    /// Push an action to the ring.  Capped at
    /// [`ACTION_RING_CAP`]; oldest entries drop off the
    /// front.
    pub fn push_action(&self, action: ActionRecord) {
        if let Ok(mut s) = self.inner.lock() {
            s.actions.push(action);
        }
    }

    /// Mirror a dirty buffer.  Called when a buffer
    /// transitions dirty → … (every keystroke is too
    /// noisy; callers debounce).  `rel_path` is keyed
    /// by the paragraph file's path relative to the
    /// project root.
    pub fn mirror_buffer(&self, rel_path: String, mirror: DirtyMirror) {
        if let Ok(mut s) = self.inner.lock() {
            s.dirty_buffers.insert(rel_path, mirror);
        }
    }

    /// Clear a buffer's mirror.  Called on save —
    /// "this paragraph no longer needs rescue".
    pub fn clear_mirror(&self, rel_path: &str) {
        if let Ok(mut s) = self.inner.lock() {
            s.dirty_buffers.remove(rel_path);
        }
    }

    /// Read-only snapshot for the panic hook.  Returns
    /// a clone so the hook can release the lock
    /// immediately.
    pub fn snapshot(&self) -> Option<CrashState> {
        self.inner.lock().ok().map(|s| s.clone())
    }
}

type TerminalRestore = Box<dyn Fn() + Send + Sync + 'static>;

static TERMINAL_RESTORE: OnceLock<Mutex<Option<TerminalRestore>>> = OnceLock::new();

fn terminal_restore_slot() -> &'static Mutex<Option<TerminalRestore>> {
    TERMINAL_RESTORE.get_or_init(|| Mutex::new(None))
}

/// Register a closure the panic hook should run before
/// writing the report — usually `disable_raw_mode` +
/// `LeaveAlternateScreen`.  Called by `tui::app::run`
/// just after switching the terminal into raw mode,
/// and called again with `None` on graceful TUI exit.
///
/// Stored in a process-wide slot so the hook can find
/// it without owning anything App-specific.
pub fn set_terminal_restore(restore: Option<TerminalRestore>) {
    if let Ok(mut slot) = terminal_restore_slot().lock() {
        *slot = restore;
    }
}

/// Install the crash-report panic hook.  Call exactly
/// once in `main()` before any code that might panic.
/// The previous hook is captured and chained — so the
/// default backtrace printer still runs after our
/// report writer.
///
/// Side effects: the hook will, on panic,
///   - call the terminal-restore closure registered
///     via [`set_terminal_restore`] (if any) to undo
///     raw-mode + alternate-screen,
///   - read the [`CrashContext`] snapshot,
///   - flush dirty buffers as `.inkhaven-rescue`
///     companions of the original files,
///   - write `inkhaven-crash-<ts>.hjson` to cwd,
///   - print a breadcrumb to stderr,
///   - chain to the previously-installed hook
///     (the default Rust hook prints the panic + an
///     optional backtrace).
/// True for the panic the `print!` / `println!` (and `eprintln!`) macros
/// raise when their write fails with a **broken pipe** — i.e. a reader
/// like `inkhaven … | head` closed the pipe early.  Rust ignores
/// SIGPIPE, so the failed write panics; this is normal shell behaviour,
/// not a crash, and shouldn't emit a crash report.  Deliberately narrow:
/// it requires the print-macro prefix *and* a broken-pipe error, so
/// other output failures (e.g. disk-full on a redirected stdout) still
/// surface a report.
pub(crate) fn is_broken_pipe_panic(msg: &str) -> bool {
    msg.contains("failed printing to std")
        && (msg.contains("Broken pipe") || msg.contains("os error 32"))
}

pub fn install_panic_hook() {
    let previous = std::panic::take_hook();
    std::panic::set_hook(Box::new(move |info| {
        // Step 0 — a broken pipe on stdout/stderr (a piped reader closed
        // early) is not a crash.  Restore the terminal (a no-op for the
        // one-shot CLI commands where this actually happens) and exit
        // cleanly, skipping the crash report + rescue flush.
        let payload = info.payload();
        let msg = payload
            .downcast_ref::<&str>()
            .copied()
            .or_else(|| payload.downcast_ref::<String>().map(String::as_str))
            .unwrap_or("");
        if is_broken_pipe_panic(msg) {
            if let Ok(slot) = terminal_restore_slot().lock() {
                if let Some(restore) = slot.as_ref() {
                    restore();
                }
            }
            std::process::exit(0);
        }

        // Step 0.5 (H5) — a panic inside a `suppress_panic_report`
        // section is being recovered by a `catch_unwind` at the call
        // site (a user Bund hook). Do NOT restore the terminal / flush
        // rescue / write a report — that would tear down the live TUI
        // for a fault the app is about to swallow. Just log and return;
        // the caller resumes.
        if panic_report_suppressed() {
            tracing::warn!(
                target: "inkhaven::crash",
                "recovered panic (suppressed report): {msg}"
            );
            return;
        }

        // Step 1 — restore the terminal so anything we
        // print is actually visible.  Must come first
        // because the rest of the steps may take a
        // beat.
        if let Ok(slot) = terminal_restore_slot().lock() {
            if let Some(restore) = slot.as_ref() {
                restore();
            }
        }

        // Step 2 — best-effort snapshot.  If the
        // mutex is poisoned (likely — we ARE in a
        // panic), we get an empty state and continue.
        let state = context().snapshot().unwrap_or_default();

        // Step 3 — flush dirty buffers.
        let rescue_outcomes =
            rescue::flush_dirty_buffers(state.project_path.as_deref(), &state.dirty_buffers);

        // Step 4 — build the report.
        let report = CrashReport::capture(info, &state, &rescue_outcomes);

        // Step 5 — write atomically.
        let report_path = report_target_path();
        let write_result = report.write_atomic(&report_path);

        // Step 6 — breadcrumb.
        match write_result {
            Ok(()) => {
                eprintln!(
                    "\ninkhaven crashed — crash report written to {}",
                    report_path.display()
                );
                if !rescue_outcomes.is_empty() {
                    eprintln!(
                        "  {} unsaved buffer(s) rescued.  Run `inkhaven recover {}` to restore.",
                        rescue_outcomes.len(),
                        report_path.display()
                    );
                }
            }
            Err(e) => {
                eprintln!(
                    "\ninkhaven crashed — could not write crash report ({e})",
                );
            }
        }

        // Step 7 — chain.  This is what prints the
        // panic message + backtrace.
        previous(info);
    }));
}

/// Compute the absolute path to write the crash report.
///
/// Tries `std::env::current_dir()` first; on failure
/// (very rare — typically only when the cwd was
/// deleted out from under the process), falls back to
/// `std::env::temp_dir()`.  Filename is
/// `inkhaven-crash-<UTC ISO8601 compact>.hjson`.
fn report_target_path() -> PathBuf {
    let stem = format!(
        "inkhaven-crash-{}.hjson",
        chrono::Utc::now().format("%Y%m%dT%H%M%S"),
    );
    std::env::current_dir()
        .unwrap_or_else(|_| std::env::temp_dir())
        .join(stem)
}

/// Atomic write helper used by both the report writer
/// and the rescue flush.  Delegates to the shared
/// [`crate::io_atomic::write`] primitive so the
/// editor + sidecar saves use the same code path.
pub(crate) fn write_atomic(target: &std::path::Path, body: &[u8]) -> std::io::Result<()> {
    crate::io_atomic::write(target, body)
}

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

    #[test]
    fn broken_pipe_panic_detected_narrowly() {
        // The print-macro broken-pipe panic → clean exit.
        assert!(is_broken_pipe_panic(
            "failed printing to stdout: Broken pipe (os error 32)"
        ));
        assert!(is_broken_pipe_panic(
            "failed printing to stderr: Broken pipe (os error 32)"
        ));
        // A different output failure (disk full) still reports.
        assert!(!is_broken_pipe_panic(
            "failed printing to stdout: No space left on device (os error 28)"
        ));
        // A genuine logic panic is never mistaken for a pipe close.
        assert!(!is_broken_pipe_panic(
            "index out of bounds: the len is 3 but the index is 5"
        ));
        // "Broken pipe" from somewhere that isn't a print macro stays a crash.
        assert!(!is_broken_pipe_panic("subprocess died: Broken pipe"));
    }

    #[test]
    fn context_starts_empty() {
        // First call initialises; further calls return
        // the same instance.  Don't assert on state
        // because the test process is shared.
        let c = context();
        let _ = c.snapshot();
    }

    #[test]
    fn set_project_persists_in_snapshot() {
        let c = context();
        c.set_project(PathBuf::from("/tmp/inkhaven-test-project"));
        let snap = c.snapshot().expect("snapshot succeeds");
        assert_eq!(
            snap.project_path.as_deref(),
            Some(std::path::Path::new("/tmp/inkhaven-test-project"))
        );
    }

    #[test]
    fn write_atomic_creates_target_and_removes_tmp() {
        let tmp_dir = std::env::temp_dir().join(format!(
            "inkhaven-crash-test-{}",
            std::process::id()
        ));
        std::fs::create_dir_all(&tmp_dir).unwrap();
        let target = tmp_dir.join("hello.txt");
        super::write_atomic(&target, b"hello world\n").expect("atomic write succeeds");
        assert!(target.exists(), "target file should exist");
        assert!(
            !target.with_extension("txt.tmp").exists(),
            "tmp file should have been renamed away"
        );
        let body = std::fs::read_to_string(&target).unwrap();
        assert_eq!(body, "hello world\n");
        let _ = std::fs::remove_dir_all(&tmp_dir);
    }

    #[test]
    fn target_path_has_inkhaven_crash_prefix() {
        let p = super::report_target_path();
        let name = p.file_name().unwrap().to_string_lossy().into_owned();
        assert!(
            name.starts_with("inkhaven-crash-"),
            "name = {name}"
        );
        assert!(name.ends_with(".hjson"), "name = {name}");
    }
}
