// instance_buffer.rs --- T M5.6f *pmacs-instance* observability buffer renderer.

//! `*pmacs-instance*` buffer rendering ([T M5.6f]).
//!
//! The Rust side of the `editor.describe-instance` and
//! `editor.describe-instance-buffer` commands. Mirrors the renderer
//! pattern in [`crate::workers_buffer`]: a description goes in,
//! formatted text replaces the buffer's contents, the buffer is marked
//! clean.
//!
//! # Two surfaces
//!
//! * [`format_echo_line`] returns a single-line summary suitable for
//!   the status line. Used by `editor.describe-instance`.
//! * [`render`] writes a multi-section detail view into the
//!   `*pmacs-instance*` buffer. Used by
//!   `editor.describe-instance-buffer`.
//!
//! Both consume an [`InstanceIdentity`] for the running process plus
//! an optional [`AttachmentHandle`]. In v0.1 Local mode the attachment
//! is always `None` (the process is its own instance), so the
//! formatters degrade gracefully to "describe self." Once the v0.2
//! reconnect / outbound attach path lands, the same formatters work
//! for the attached case without changes.

use std::fmt::Write;

use crate::buffer::{Buffer, BufferId, EditOp};
use crate::buffer_registry::BufferRegistry;
use crate::protocol::{AttachmentHandle, InstanceIdentity};

/// Canonical name for the instance description buffer.
pub const INSTANCE_BUFFER_NAME: &str = "*pmacs-instance*";

/// One-line summary for the status row.
///
/// Two shapes, depending on whether `attachment` is set:
///
/// * `None`: `pmacs <ver> [<name>]: <cwd> (uptime <duration>)` — the
///   running process describes itself.
/// * `Some(_)`: `pmacs <ver> attached to <attached_name> (<target>)
///   from <cwd>, uptime <duration>` — the running process is talking
///   to a remote instance whose identity it has captured.
#[must_use]
pub fn format_echo_line(
    identity: &InstanceIdentity,
    attachment: Option<&AttachmentHandle>,
) -> String {
    let uptime = format_uptime(identity.uptime_secs);
    match attachment {
        None => {
            let name = identity.instance_name.as_deref().unwrap_or("local");
            format!(
                "pmacs {ver} [{name}]: {cwd} (uptime {uptime})",
                ver = identity.pmacs_version,
                cwd = identity.working_directory,
            )
        }
        Some(h) => {
            let attached_name = h.identity.instance_name.as_deref().unwrap_or("anonymous");
            format!(
                "pmacs {ver} attached to {attached_name} ({target}) from {cwd}, uptime {uptime}",
                ver = identity.pmacs_version,
                target = h.target,
                cwd = identity.working_directory,
            )
        }
    }
}

/// Render the full description into the `*pmacs-instance*` buffer
/// (creating it if absent), replacing its full contents. Returns
/// the buffer id and the Edits produced by the replacement.
///
/// # Post-audit-round-6 F31 — broadcast queueing
///
/// Returning the Edits lets the caller queue any `crdt_op` they
/// carry via `EditorCore::queue_daemon_origin_crdt_op`. See the
/// equivalent doc on `workers_buffer::render`.
///
/// The buffer is marked clean — the modeline shouldn't claim unsaved
/// changes for a generated buffer.
pub fn render(
    registry: &mut BufferRegistry,
    identity: &InstanceIdentity,
    attachment: Option<&AttachmentHandle>,
) -> (BufferId, Vec<crate::rope::Edit>) {
    let text = format_full_text(identity, attachment);
    let id = registry
        .find_by_name(INSTANCE_BUFFER_NAME)
        .unwrap_or_else(|| registry.create(INSTANCE_BUFFER_NAME));
    let buf = registry.get_mut(id).expect("just resolved");
    let mut edits = Vec::new();
    if buffer_contents_equal(buf, &text) {
        buf.mark_clean();
        return (id, edits);
    }
    if !buf.is_empty() {
        let len = buf.len();
        if let Ok(edit) = buf.apply_edit(EditOp::Delete {
            range: crate::rope::Range::new(0, len),
        }) {
            edits.push(edit);
        }
    }
    if !text.is_empty()
        && let Ok(edit) = buf.apply_edit(EditOp::Insert {
            pos: 0,
            bytes: text.as_bytes(),
        })
    {
        edits.push(edit);
    }
    buf.mark_clean();
    (id, edits)
}

fn buffer_contents_equal(buf: &Buffer, text: &str) -> bool {
    if buf.len() != text.len() as u64 {
        return false;
    }
    let mut bytes = vec![0u8; text.len()];
    if !bytes.is_empty() {
        buf.snapshot_rope().slice(0, buf.len(), &mut bytes);
    }
    bytes == text.as_bytes()
}

/// Multi-section text payload for the buffer view. Sections:
///
/// 1. **This instance** — version, build hash, name, cwd, uptime.
/// 2. **Attachment** — only when `attachment` is `Some`. Shows the
///    target string and the remote identity.
/// 3. **Notes** — short hints about how to dismiss the buffer
///    (`q` keybinding) and about the v0.1 single-instance mode.
#[must_use]
pub fn format_full_text(
    identity: &InstanceIdentity,
    attachment: Option<&AttachmentHandle>,
) -> String {
    let mut text = String::new();

    let _ = writeln!(text, "This instance");
    let _ = writeln!(text, "  version:           {}", identity.pmacs_version);
    let _ = writeln!(
        text,
        "  build hash:        {}",
        identity.build_hash.as_deref().unwrap_or("(unknown)")
    );
    let _ = writeln!(
        text,
        "  instance name:     {}",
        identity.instance_name.as_deref().unwrap_or("(unnamed)")
    );
    let _ = writeln!(text, "  working directory: {}", identity.working_directory);
    let _ = writeln!(
        text,
        "  uptime:            {} ({} seconds)",
        format_uptime(identity.uptime_secs),
        identity.uptime_secs
    );

    let _ = writeln!(text);

    match attachment {
        None => {
            let _ = writeln!(text, "Attachment");
            let _ = writeln!(
                text,
                "  (no outbound attachment — this process is its own instance)"
            );
        }
        Some(h) => {
            let _ = writeln!(text, "Attachment");
            let _ = writeln!(text, "  frontend id:       {}", h.frontend_id.0);
            let _ = writeln!(text, "  target:            {}", h.target);
            let _ = writeln!(text, "  kind:              {}", h.target.kind_name());
            let _ = writeln!(text, "  remote version:    {}", h.identity.pmacs_version);
            let _ = writeln!(
                text,
                "  remote name:       {}",
                h.identity.instance_name.as_deref().unwrap_or("(unnamed)")
            );
            let _ = writeln!(
                text,
                "  remote cwd:        {}",
                h.identity.working_directory
            );
            let _ = writeln!(
                text,
                "  remote uptime:     {} ({} seconds)",
                format_uptime(h.identity.uptime_secs),
                h.identity.uptime_secs
            );
            if let Some(hash) = h.identity.build_hash.as_deref() {
                let _ = writeln!(text, "  remote build hash: {hash}");
            }
        }
    }

    let _ = writeln!(text);
    let _ = writeln!(text, "Notes");
    let _ = writeln!(text, "  press `q` to kill this buffer.");
    let _ = writeln!(
        text,
        "  the buffer is regenerated each time you run `editor.describe-instance-buffer`."
    );

    text
}

/// Render `seconds` as a short human-readable duration. Mirrors the
/// scale buckets in `crate::workers_buffer::format_duration_ms` but in
/// seconds (uptimes are rarely sub-second-interesting).
fn format_uptime(seconds: u64) -> String {
    if seconds < 60 {
        format!("{seconds}s")
    } else if seconds < 3_600 {
        format!("{}m{:02}s", seconds / 60, seconds % 60)
    } else if seconds < 86_400 {
        let hours = seconds / 3_600;
        let minutes = (seconds % 3_600) / 60;
        format!("{hours}h{minutes:02}m")
    } else {
        let days = seconds / 86_400;
        let hours = (seconds % 86_400) / 3_600;
        format!("{days}d{hours:02}h")
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::protocol::{AttachTarget, FrontendId, InstanceIdentity};
    use std::path::PathBuf;

    fn local_identity() -> InstanceIdentity {
        InstanceIdentity {
            pmacs_version: "0.1.0".into(),
            build_hash: Some("abc123".into()),
            instance_name: None,
            uptime_secs: 47,
            working_directory: "/tmp/proj".into(),
        }
    }

    fn named_identity() -> InstanceIdentity {
        InstanceIdentity {
            pmacs_version: "0.1.0".into(),
            build_hash: None,
            instance_name: Some("work".into()),
            uptime_secs: 4_500,
            working_directory: "/srv/www".into(),
        }
    }

    fn sample_attachment() -> AttachmentHandle {
        AttachmentHandle::new(
            FrontendId(2),
            named_identity(),
            AttachTarget::LocalSocket(PathBuf::from("/run/pmacs/work.sock")),
        )
    }

    // ---- format_echo_line ---------------------------------------------------

    #[test]
    fn echo_unattached_shows_local_name_when_no_instance_name() {
        let line = format_echo_line(&local_identity(), None);
        assert!(line.starts_with("pmacs 0.1.0 [local]"), "{line}");
        assert!(line.contains("/tmp/proj"));
        assert!(line.contains("uptime 47s"));
    }

    #[test]
    fn echo_unattached_uses_instance_name_when_present() {
        let line = format_echo_line(&named_identity(), None);
        assert!(line.starts_with("pmacs 0.1.0 [work]"), "{line}");
        assert!(line.contains("/srv/www"));
        // 4500 seconds → 1h15m.
        assert!(line.contains("uptime 1h15m"), "{line}");
    }

    #[test]
    fn echo_attached_shows_remote_name_and_target() {
        let h = sample_attachment();
        let line = format_echo_line(&local_identity(), Some(&h));
        assert!(line.contains("attached to work"), "{line}");
        assert!(line.contains("local:/run/pmacs/work.sock"), "{line}");
        assert!(line.contains("from /tmp/proj"));
    }

    #[test]
    fn echo_attached_falls_back_to_anonymous_when_remote_name_missing() {
        let mut id = named_identity();
        id.instance_name = None;
        let h = AttachmentHandle::new(
            FrontendId(2),
            id,
            AttachTarget::LocalSocket(PathBuf::from("/x")),
        );
        let line = format_echo_line(&local_identity(), Some(&h));
        assert!(line.contains("attached to anonymous"), "{line}");
    }

    #[test]
    fn echo_line_is_single_line() {
        let line = format_echo_line(&local_identity(), None);
        assert!(!line.contains('\n'), "echo line must not contain newlines");
        let h = sample_attachment();
        let line = format_echo_line(&local_identity(), Some(&h));
        assert!(!line.contains('\n'), "echo line must not contain newlines");
    }

    // ---- format_full_text ---------------------------------------------------

    #[test]
    fn full_text_unattached_includes_self_section_and_no_attachment_marker() {
        let text = format_full_text(&local_identity(), None);
        assert!(text.contains("This instance"));
        assert!(text.contains("version:           0.1.0"));
        assert!(text.contains("build hash:        abc123"));
        assert!(text.contains("instance name:     (unnamed)"));
        assert!(text.contains("working directory: /tmp/proj"));
        assert!(text.contains("uptime:"));
        assert!(text.contains("(no outbound attachment"));
        assert!(text.contains("Notes"));
        assert!(text.contains("press `q` to kill"));
    }

    #[test]
    fn full_text_unnamed_build_hash_renders_as_unknown() {
        let text = format_full_text(&named_identity(), None);
        assert!(text.contains("build hash:        (unknown)"));
        assert!(text.contains("instance name:     work"));
    }

    #[test]
    fn full_text_attached_renders_remote_section() {
        let h = sample_attachment();
        let text = format_full_text(&local_identity(), Some(&h));
        assert!(text.contains("Attachment"));
        assert!(text.contains("frontend id:       2"));
        assert!(text.contains("target:            local:/run/pmacs/work.sock"));
        assert!(text.contains("kind:              local"));
        assert!(text.contains("remote version:    0.1.0"));
        assert!(text.contains("remote name:       work"));
        assert!(text.contains("remote cwd:        /srv/www"));
        assert!(text.contains("remote uptime:     1h15m (4500 seconds)"));
        // build_hash on the remote is None, so the line should be omitted.
        assert!(
            !text.contains("remote build hash:"),
            "expected no build-hash line for None, got: {text}"
        );
    }

    #[test]
    fn full_text_attached_includes_remote_build_hash_when_present() {
        let mut id = named_identity();
        id.build_hash = Some("def456".into());
        let h = AttachmentHandle::new(
            FrontendId(3),
            id,
            AttachTarget::LocalSocket(PathBuf::from("/x")),
        );
        let text = format_full_text(&local_identity(), Some(&h));
        assert!(text.contains("remote build hash: def456"));
    }

    // ---- format_uptime ------------------------------------------------------

    #[test]
    fn format_uptime_buckets() {
        assert_eq!(format_uptime(0), "0s");
        assert_eq!(format_uptime(47), "47s");
        assert_eq!(format_uptime(60), "1m00s");
        assert_eq!(format_uptime(125), "2m05s");
        assert_eq!(format_uptime(3_600), "1h00m");
        assert_eq!(format_uptime(4_500), "1h15m");
        assert_eq!(format_uptime(86_400), "1d00h");
        assert_eq!(format_uptime(90_000), "1d01h");
    }

    // ---- render -------------------------------------------------------------

    #[test]
    fn render_creates_named_buffer_and_writes_text() {
        let mut reg = BufferRegistry::new();
        let (id, _edits) = render(&mut reg, &local_identity(), None);
        let buf = reg.get(id).expect("just rendered");
        assert_eq!(buf.name(), INSTANCE_BUFFER_NAME);
        let len = buf.len();
        let mut bytes = vec![0u8; usize::try_from(len).unwrap()];
        if len > 0 {
            buf.snapshot_rope().slice(0, len, &mut bytes);
        }
        let body = String::from_utf8(bytes).unwrap();
        assert!(body.contains("This instance"));
        assert!(body.contains("version:           0.1.0"));
        assert!(body.contains("(no outbound attachment"));
        assert!(!buf.is_modified(), "rendered buffer must be marked clean");
    }

    #[test]
    fn render_replaces_existing_contents_on_second_call() {
        let mut reg = BufferRegistry::new();
        let (id1, _) = render(&mut reg, &local_identity(), None);
        let (id2, _) = render(&mut reg, &named_identity(), None);
        assert_eq!(id1, id2, "render must reuse the named buffer");
        let buf = reg.get(id2).expect("rendered");
        let len = buf.len();
        let mut bytes = vec![0u8; usize::try_from(len).unwrap()];
        if len > 0 {
            buf.snapshot_rope().slice(0, len, &mut bytes);
        }
        let body = String::from_utf8(bytes).unwrap();
        assert!(body.contains("instance name:     work"));
        assert!(
            !body.contains("instance name:     (unnamed)"),
            "stale text from first render leaked: {body}"
        );
    }

    #[test]
    fn render_same_identity_is_no_op() {
        let mut reg = BufferRegistry::new();
        let (_id, first_edits) = render(&mut reg, &local_identity(), None);
        assert!(
            !first_edits.is_empty(),
            "initial render should create buffer contents"
        );
        let (_id, second_edits) = render(&mut reg, &local_identity(), None);
        assert!(
            second_edits.is_empty(),
            "unchanged instance render must not emit delete/insert edits"
        );
    }

    #[test]
    fn render_with_attachment_includes_remote_section() {
        let mut reg = BufferRegistry::new();
        let h = sample_attachment();
        let (id, _) = render(&mut reg, &local_identity(), Some(&h));
        let buf = reg.get(id).expect("rendered");
        let len = buf.len();
        let mut bytes = vec![0u8; usize::try_from(len).unwrap()];
        if len > 0 {
            buf.snapshot_rope().slice(0, len, &mut bytes);
        }
        let body = String::from_utf8(bytes).unwrap();
        assert!(body.contains("frontend id:       2"));
        assert!(body.contains("target:            local:/run/pmacs/work.sock"));
    }
}
