// r8r: rust port of n8n.io
// Initial author: Michael Cochran <mcochran@cochranblock.org>
// Built for: Tanner Woodrum, intended cofounder
// License: Unlicense (public domain dedication, see UNLICENSE)
//
// `anthropic_tmux_tui` — drive a Claude CLI/TUI session via tmux
// send-keys + capture-pane. Bypasses the Anthropic API entirely
// (no API key needed) — piggybacks on the user's logged-in `claude` CLI.
//
// Required parameter:
//   prompt       text to send to the pane
//
// Optional parameters (sensible defaults — you usually don't set these):
//   target       full tmux target string. Wins if set.
//   session      tmux session name (default "claude")
//   window       optional window name/index — combined into the target
//   pane         optional pane index — combined into the target
//   command      CLI to launch if the session is created here
//                (default "claude")
//   auto_start   create the session running `command` if it doesn't
//                already exist (default true)
//   timeout_ms   max wait for response (default 30000)
//   stable_ms    "no output change for this long" → done (default 1500)
//   poll_ms      capture-pane poll interval (default 250)
//   strip_ansi   strip ANSI escapes (default true)
//
// Returns: { response, raw, target, prompt }
//
// Typical use: just set `prompt`. The node creates a tmux session
// named "claude" running the `claude` CLI on first call (you'll need
// to authenticate the CLI once interactively if it isn't already), and
// every subsequent call sends to the same session.

use async_trait::async_trait;
use crate::core::error::NodeError;
use crate::core::types::*;
use crate::engine::{ExecuteCtx, Node, NodeFactory};
use serde_json::json;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::process::Command;

pub struct AnthropicTmuxTuiNode;

static META: NodeMetadata = NodeMetadata {
    name: "anthropic_tmux_tui",
    display_name: "Anthropic Tmux TUI",
    version: &[1],
    group: &["ai"],
    description: "Drive Claude (or any CLI) via tmux send-keys + capture-pane — no API token required",
    inputs: 1,
    outputs: 1,
    properties: &[],
    credentials: &[],
};

#[async_trait]
impl Node for AnthropicTmuxTuiNode {
    fn metadata(&self) -> &'static NodeMetadata { &META }

    async fn execute(&self, ctx: &mut ExecuteCtx<'_>) -> Result<NodeOutput, NodeError> {
        let prompt = ctx.param_str("prompt")?;
        let target = build_target(ctx);
        let command = ctx.param_opt_str("command").unwrap_or_else(|| "claude".to_string());
        let auto_start = ctx.node_def.parameters.get("auto_start")
            .and_then(|v| v.as_bool()).unwrap_or(true);
        let timeout_ms = ctx.node_def.parameters.get("timeout_ms")
            .and_then(|v| v.as_u64()).unwrap_or(30_000);
        let stable_ms = ctx.node_def.parameters.get("stable_ms")
            .and_then(|v| v.as_u64()).unwrap_or(1500);
        let poll_ms = ctx.node_def.parameters.get("poll_ms")
            .and_then(|v| v.as_u64()).unwrap_or(250);
        let strip = ctx.node_def.parameters.get("strip_ansi")
            .and_then(|v| v.as_bool()).unwrap_or(true);

        if auto_start && !session_exists(&target).await {
            ensure_session(&target, &command).await?;
            // Give the CLI a moment to draw its initial frame so the
            // baseline doesn't capture a half-rendered prompt.
            tokio::time::sleep(std::time::Duration::from_millis(800)).await;
        }

        // Snapshot before sending — diff baseline.
        let baseline = capture_pane(&target).await?;

        // Send the prompt as a literal string, then submit with Enter.
        send_keys(&target, &prompt, false).await?;
        send_keys(&target, "Enter", true).await?;

        // Poll until output stabilizes or timeout hits.
        let started = Instant::now();
        let stable = Duration::from_millis(stable_ms);
        let total = Duration::from_millis(timeout_ms);
        let interval = Duration::from_millis(poll_ms);
        let mut last_seen = capture_pane(&target).await?;
        let mut last_change = Instant::now();
        loop {
            tokio::time::sleep(interval).await;
            if started.elapsed() > total { break; }
            let current = capture_pane(&target).await?;
            if current != last_seen {
                last_seen = current;
                last_change = Instant::now();
            } else if last_change.elapsed() >= stable {
                break;
            }
        }

        // Compute the diff: lines in last_seen that weren't in baseline.
        let response = diff_after(&baseline, &last_seen);
        let response = if strip { strip_ansi(&response) } else { response };
        let raw = if strip { strip_ansi(&last_seen) } else { last_seen };

        Ok(vec![vec![NodeItem {
            json: json!({
                "target": target,
                "prompt": prompt,
                "response": response,
                "raw": raw,
            }),
            ..Default::default()
        }]])
    }
}

/// Same target-builder as tmux_pane, but defaults the session name to
/// "claude" so the bare-minimum surface is just `prompt = "..."`.
fn build_target(ctx: &ExecuteCtx<'_>) -> String {
    if let Ok(t) = ctx.param_str("target") {
        if !t.is_empty() { return t; }
    }
    let session = ctx.param_opt_str("session").unwrap_or_else(|| "claude".to_string());
    let window = ctx.node_def.parameters.get("window")
        .and_then(|v| match v {
            serde_json::Value::String(s) => Some(s.clone()),
            serde_json::Value::Number(n) => Some(n.to_string()),
            _ => None,
        });
    let pane = ctx.node_def.parameters.get("pane")
        .and_then(|v| match v {
            serde_json::Value::String(s) => Some(s.clone()),
            serde_json::Value::Number(n) => Some(n.to_string()),
            _ => None,
        });
    match (window, pane) {
        (Some(w), Some(p)) => format!("{}:{}.{}", session, w, p),
        (Some(w), None)    => format!("{}:{}", session, w),
        (None, Some(p))    => format!("{}:0.{}", session, p),
        (None, None)       => session,
    }
}

async fn session_exists(target: &str) -> bool {
    // The session name is everything before the first ':' (window/pane suffix).
    let session = target.split(':').next().unwrap_or(target);
    Command::new("tmux").args(["has-session", "-t", session])
        .output().await
        .map(|o| o.status.success()).unwrap_or(false)
}

async fn ensure_session(target: &str, command: &str) -> Result<(), NodeError> {
    let session = target.split(':').next().unwrap_or(target);
    let out = Command::new("tmux")
        .args(["new-session", "-d", "-s", session, command])
        .output().await
        .map_err(|e| NodeError::Other(format!("tmux new-session: {}", e)))?;
    if !out.status.success() {
        return Err(NodeError::Other(format!(
            "tmux new-session failed: {}",
            String::from_utf8_lossy(&out.stderr).trim()
        )));
    }
    Ok(())
}

async fn capture_pane(target: &str) -> Result<String, NodeError> {
    let out = Command::new("tmux")
        .args(["capture-pane", "-p", "-t", target, "-S", "-1000"])
        .output().await
        .map_err(|e| NodeError::Other(format!("tmux capture-pane: {}", e)))?;
    if !out.status.success() {
        return Err(NodeError::Other(format!(
            "tmux capture-pane failed: {}",
            String::from_utf8_lossy(&out.stderr).trim()
        )));
    }
    Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}

async fn send_keys(target: &str, value: &str, is_key_name: bool) -> Result<(), NodeError> {
    let mut cmd = Command::new("tmux");
    if is_key_name {
        cmd.args(["send-keys", "-t", target, value]);
    } else {
        // -l = literal: don't interpret as key name, send as text
        cmd.args(["send-keys", "-t", target, "-l", value]);
    }
    let out = cmd.output().await
        .map_err(|e| NodeError::Other(format!("tmux send-keys: {}", e)))?;
    if !out.status.success() {
        return Err(NodeError::Other(format!(
            "tmux send-keys failed: {}",
            String::from_utf8_lossy(&out.stderr).trim()
        )));
    }
    Ok(())
}

fn diff_after(baseline: &str, current: &str) -> String {
    // Common-prefix trim: walk both line-by-line, return tail of `current`
    // after the longest matching prefix.
    let base_lines: Vec<&str> = baseline.lines().collect();
    let cur_lines: Vec<&str> = current.lines().collect();
    let mut shared = 0;
    while shared < base_lines.len()
        && shared < cur_lines.len()
        && base_lines[shared] == cur_lines[shared]
    {
        shared += 1;
    }
    cur_lines[shared..].join("\n")
}

fn strip_ansi(s: &str) -> String {
    // Tiny ANSI scrubber — handles CSI (ESC[...letter) and OSC (ESC]...BEL).
    let bytes = s.as_bytes();
    let mut out = String::with_capacity(s.len());
    let mut i = 0;
    while i < bytes.len() {
        if bytes[i] == 0x1B && i + 1 < bytes.len() {
            let next = bytes[i + 1];
            if next == b'[' {
                let mut j = i + 2;
                while j < bytes.len() && !(bytes[j] as char).is_ascii_alphabetic() { j += 1; }
                i = j.saturating_add(1).min(bytes.len());
                continue;
            } else if next == b']' {
                let mut j = i + 2;
                while j < bytes.len() && bytes[j] != 0x07 { j += 1; }
                i = j.saturating_add(1).min(bytes.len());
                continue;
            }
        }
        out.push(bytes[i] as char);
        i += 1;
    }
    out
}

inventory::submit! { NodeFactory::new("anthropic_tmux_tui", || Arc::new(AnthropicTmuxTuiNode)) }
