//! `hjkl --embed` — JSON-RPC 2.0 server over stdin/stdout.
//!
//! Newline-delimited. One request per line, one response per line.
//! Errors follow JSON-RPC 2.0: `{"jsonrpc":"2.0","error":{"code":-32601,"message":"..."},"id":...}`.
//! See `docs/embed-rpc.md` for the method catalogue.

use std::io::{BufRead, Read, Write};
use std::path::{Path, PathBuf};

use anyhow::Result;
use hjkl_buffer::View;
use hjkl_editor::buffer::Position;
use hjkl_engine::{BufferEdit, DefaultHost, Editor, Options, VimMode};
use hjkl_ex::ExEffect;
use hjkl_vim::VimEditorExt;
use serde_json::{Value, json};

// ---------------------------------------------------------------------------
// RPC read cap
// ---------------------------------------------------------------------------

/// Cap for file reads whose path comes from an untrusted RPC client
/// (`--embed` / `--nvim-api` take file paths off the wire, so the file at that
/// path is untrusted input: a client that can write a multi-GB file into the
/// working directory — or rename a buffer to point at one — must not force a
/// matching allocation in the editor). 256 MiB matches the two largest caps
/// already enforced elsewhere (`hjkl_fs::caps::UNDO` / `STDIN`), so any file a
/// legitimate session would already hold in memory is unaffected; anything
/// larger is refused outright, never silently truncated.
const RPC_READ_CAP: u64 = 256 * 1024 * 1024;

/// The effective RPC read cap. `HJKL_RPC_READ_CAP` (bytes) overrides the
/// default so tests can exercise the refusal path with a tiny cap; an
/// unparsable value falls back to the default.
pub fn rpc_read_cap() -> u64 {
    std::env::var("HJKL_RPC_READ_CAP")
        .ok()
        .and_then(|v| v.parse().ok())
        .unwrap_or(RPC_READ_CAP)
}

// ---------------------------------------------------------------------------
// JSON-RPC 2.0 error codes
// ---------------------------------------------------------------------------

const ERR_PARSE: i64 = -32700;
const ERR_INVALID_REQUEST: i64 = -32600;
const ERR_METHOD_NOT_FOUND: i64 = -32601;
const ERR_INVALID_PARAMS: i64 = -32602;
/// Server-defined: ex-command failure.
const ERR_EX_COMMAND: i64 = -32000;

// ---------------------------------------------------------------------------
// Response helpers
// ---------------------------------------------------------------------------

fn success(id: &Value, result: Value) -> Value {
    json!({
        "jsonrpc": "2.0",
        "result": result,
        "id": id,
    })
}

fn error_resp(id: &Value, code: i64, message: &str) -> Value {
    json!({
        "jsonrpc": "2.0",
        "error": {
            "code": code,
            "message": message,
        },
        "id": id,
    })
}

fn write_response(stdout: &mut impl Write, v: &Value) -> Result<()> {
    let s = serde_json::to_string(v)?;
    stdout.write_all(s.as_bytes())?;
    stdout.write_all(b"\n")?;
    stdout.flush()?;
    Ok(())
}

/// Read and discard bytes through the next `\n` (or EOF) without buffering the
/// whole line — used to resync after rejecting an oversized request. Bounded
/// memory (one fill_buf chunk at a time).
fn drain_to_newline<R: BufRead>(r: &mut R) -> std::io::Result<()> {
    loop {
        let avail = r.fill_buf()?;
        if avail.is_empty() {
            return Ok(()); // EOF
        }
        if let Some(i) = avail.iter().position(|&b| b == b'\n') {
            r.consume(i + 1);
            return Ok(());
        }
        let n = avail.len();
        r.consume(n);
    }
}

// ---------------------------------------------------------------------------
// Editor construction (mirrors headless.rs)
// ---------------------------------------------------------------------------

fn build_editor(
    maybe_path: Option<&Path>,
) -> Result<(
    Editor<View, DefaultHost>,
    Option<PathBuf>,
    crate::save::EolState,
)> {
    let mut buffer = View::new();
    // Vim `'endofline'` — derived from the bytes read; see `save::EolState`.
    let mut eol = crate::save::EolState::default();

    if let Some(path) = maybe_path {
        match hjkl_fs::read_to_string_unbounded(path) {
            Ok(content) => {
                eol = crate::save::EolState::from_loaded(&content);
                let content = content.strip_suffix('\n').unwrap_or(&content);
                BufferEdit::replace_all(&mut buffer, content);
            }
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
                // New file — start empty.
            }
            Err(e) => {
                return Err(anyhow::anyhow!("hjkl: {}: {e}", path.display()));
            }
        }
    }

    let host = DefaultHost::new();
    let editor = hjkl_vim::vim_editor(buffer, host, Options::default());
    Ok((editor, maybe_path.map(Path::to_path_buf), eol))
}

// ---------------------------------------------------------------------------
// Method dispatch
// ---------------------------------------------------------------------------

fn dispatch(
    editor: &mut Editor<View, DefaultHost>,
    current_filename: &mut Option<PathBuf>,
    eol: &mut crate::save::EolState,
    should_quit: &mut bool,
    method: &str,
    params: &Value,
    id: &Value,
) -> Value {
    match method {
        "hjkl_input" => {
            let keys = match params_positional_str(params, 0) {
                Ok(k) => k,
                Err(msg) => return error_resp(id, ERR_INVALID_PARAMS, &msg),
            };
            let inputs = hjkl_engine::decode_macro(&keys);
            for input in inputs {
                hjkl_vim::dispatch_input(editor, input);
            }
            success(id, Value::Null)
        }

        "hjkl_command" => {
            let cmd = match params_positional_str(params, 0) {
                Ok(c) => c,
                Err(msg) => return error_resp(id, ERR_INVALID_PARAMS, &msg),
            };
            let cmd = cmd.strip_prefix(':').unwrap_or(&cmd).to_string();
            // `:set mouse` / `:set explorer.open` / `:set endofline` — host
            // state, not engine options (hjkl's option registry rejects
            // them), so pull those tokens out before hjkl-ex sees the line
            // (same interception the TUI does). Embed has no terminal / no
            // explorer / no config file: mouse and explorer.open tokens are
            // consumed as no-ops and query replies are dropped.
            let intercepted: Option<crate::set_tokens::SetLine> =
                cmd.strip_prefix("set ").map(|body| {
                    let body = body.trim();
                    if body.is_empty() {
                        crate::set_tokens::SetLine::PassThrough
                    } else {
                        let mut host = crate::set_tokens::NonTuiSetHost { eol: &mut *eol };
                        crate::set_tokens::intercept_set_tokens(body, &mut host)
                    }
                });
            let cmd = match intercepted {
                None | Some(crate::set_tokens::SetLine::PassThrough) => cmd,
                Some(crate::set_tokens::SetLine::Swallow) => return success(id, Value::Null),
                Some(crate::set_tokens::SetLine::Rebuild(line)) => line,
            };
            let reg = hjkl_ex::default_registry::<hjkl_engine::DefaultHost>();
            let effect = hjkl_ex::try_dispatch(&reg, editor, &cmd)
                .unwrap_or_else(|| ExEffect::Unknown(cmd.clone()));
            match effect {
                ExEffect::None
                | ExEffect::Ok
                | ExEffect::Info(_)
                | ExEffect::InfoTitled { .. }
                | ExEffect::Substituted { .. }
                | ExEffect::Quickfix(_)
                | ExEffect::Location(_) => success(id, Value::Null),
                ExEffect::Error(msg) | ExEffect::Unknown(msg) => {
                    error_resp(id, ERR_EX_COMMAND, &msg)
                }
                ExEffect::Save => {
                    if let Err(e) = write_buffer(editor, current_filename, *eol) {
                        error_resp(id, ERR_EX_COMMAND, &e)
                    } else {
                        success(id, Value::Null)
                    }
                }
                ExEffect::SaveAs(path_str) => {
                    let new_path = PathBuf::from(&path_str);
                    if let Err(e) = write_buffer(editor, &Some(new_path.clone()), *eol) {
                        error_resp(id, ERR_EX_COMMAND, &e)
                    } else {
                        *current_filename = Some(new_path);
                        success(id, Value::Null)
                    }
                }
                ExEffect::Quit { save, force: _ } => {
                    if save && let Err(e) = write_buffer(editor, current_filename, *eol) {
                        return error_resp(id, ERR_EX_COMMAND, &e);
                    }
                    *should_quit = true;
                    success(id, Value::Null)
                }
                ExEffect::EditFile { path, .. } => {
                    // Single-buffer embed mode: treat :e as a reload/open.
                    // When FS policy is active, resolve through the
                    // symlink-safe `resolve_under` instead of reading the
                    // path directly.
                    if let Err(e) = hjkl_engine::policy::check_fs_path(std::path::Path::new(&path))
                    {
                        return error_resp(id, ERR_EX_COMMAND, &format!("{path}: {e}"));
                    }
                    let cwd =
                        std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
                    let resolved = if hjkl_engine::policy::fs_restricted() {
                        match hjkl_fs::resolve_under(&cwd, std::path::Path::new(&path)) {
                            Ok(p) => p,
                            Err(e) => {
                                return error_resp(id, ERR_EX_COMMAND, &format!("{path}: {e}"));
                            }
                        }
                    } else {
                        std::path::PathBuf::from(&path)
                    };
                    let read = if hjkl_engine::policy::fs_restricted() {
                        hjkl_fs::read_to_string_capped(&resolved, rpc_read_cap())
                    } else {
                        hjkl_fs::read_to_string_unbounded(&resolved)
                    };
                    match read {
                        Ok(content) => {
                            *eol = crate::save::EolState::from_loaded(&content);
                            let content = content.strip_suffix('\n').unwrap_or(&content);
                            hjkl_engine::BufferEdit::replace_all(editor.buffer_mut(), content);
                            *current_filename = Some(PathBuf::from(&path));
                            success(id, Value::Null)
                        }
                        Err(e) => error_resp(id, ERR_EX_COMMAND, &format!("{path}: {e}")),
                    }
                }
                ExEffect::BufferDelete { .. } => {
                    // No multi-buffer support in embed mode — treat as a no-op quit.
                    *should_quit = true;
                    success(id, Value::Null)
                }
                ExEffect::PutRegister { .. } => {
                    // Multi-buffer operation not supported in embed mode — no-op.
                    success(id, Value::Null)
                }
                ExEffect::SaveAndRename { path } => {
                    let new_path = PathBuf::from(&path);
                    if let Err(e) = write_buffer(editor, &Some(new_path.clone()), *eol) {
                        error_resp(id, ERR_EX_COMMAND, &e)
                    } else {
                        *current_filename = Some(new_path);
                        success(id, Value::Null)
                    }
                }
                ExEffect::RenameBuffer { .. } => {
                    // In-memory rename only — no-op in embed mode.
                    success(id, Value::Null)
                }
                ExEffect::Cwd(_) => {
                    // Directory already changed by the handler — no-op.
                    success(id, Value::Null)
                }
                ExEffect::Redraw { .. } => {
                    // No terminal to clear in embed mode — treat as no-op.
                    success(id, Value::Null)
                }
                ExEffect::Preserve => {
                    // No swap files in embed mode — no-op.
                    success(id, Value::Null)
                }
                ExEffect::Recover(_) => {
                    // No swap recovery in embed mode — no-op.
                    success(id, Value::Null)
                }
                ExEffect::SubstituteConfirm { matches } => {
                    // Embed mode has no interactive TUI; apply all matches immediately.
                    let count = matches.len();
                    if count > 0 {
                        let accepted = vec![true; count];
                        hjkl_engine::apply_collected_matches(editor, &matches, &accepted);
                    }
                    success(id, Value::Null)
                }
            }
        }

        "hjkl_get_buffer" => {
            let rope = editor.buffer().rope();
            let lines: Vec<Value> = (0..rope.len_lines())
                .map(|i| Value::String(hjkl_buffer::rope_line_str(&rope, i)))
                .collect();
            success(id, Value::Array(lines))
        }

        "hjkl_set_buffer" => {
            let lines = match params_array(params, 0) {
                Ok(arr) => arr,
                Err(msg) => return error_resp(id, ERR_INVALID_PARAMS, &msg),
            };
            let mut strings: Vec<String> = Vec::with_capacity(lines.len());
            for v in &lines {
                match v.as_str() {
                    Some(s) => strings.push(s.to_string()),
                    None => {
                        return error_resp(
                            id,
                            ERR_INVALID_PARAMS,
                            "hjkl_set_buffer: each element must be a string",
                        );
                    }
                }
            }
            let content = if strings.is_empty() {
                String::new()
            } else {
                let mut s = strings.join("\n");
                s.push('\n');
                s
            };
            editor.set_content(&content);
            success(id, Value::Null)
        }

        "hjkl_get_cursor" => {
            let (row, col) = editor.cursor();
            success(id, json!([row, col]))
        }

        "hjkl_set_cursor" => {
            let row = match params_positional_u64(params, 0) {
                Ok(v) => v as usize,
                Err(msg) => return error_resp(id, ERR_INVALID_PARAMS, &msg),
            };
            let col = match params_positional_u64(params, 1) {
                Ok(v) => v as usize,
                Err(msg) => return error_resp(id, ERR_INVALID_PARAMS, &msg),
            };
            let pos = Position::new(row, col);
            editor.buffer_mut().set_cursor(pos);
            success(id, Value::Null)
        }

        "hjkl_get_mode" => {
            let mode_str = match editor.vim_mode() {
                VimMode::Normal => "normal",
                VimMode::Insert => "insert",
                VimMode::Visual => "visual",
                VimMode::VisualLine => "visual_line",
                VimMode::VisualBlock => "visual_block",
            };
            success(id, Value::String(mode_str.to_string()))
        }

        "hjkl_get_register" => {
            let reg_str = match params_positional_str(params, 0) {
                Ok(s) => s,
                Err(msg) => return error_resp(id, ERR_INVALID_PARAMS, &msg),
            };
            let mut chars = reg_str.chars();
            let Some(c) = chars.next() else {
                return error_resp(
                    id,
                    ERR_INVALID_PARAMS,
                    "hjkl_get_register: reg must be a single character",
                );
            };
            if chars.next().is_some() {
                return error_resp(
                    id,
                    ERR_INVALID_PARAMS,
                    "hjkl_get_register: reg must be a single character",
                );
            }
            match editor
                .with_registers(|r| r.read(c).map(|slot| (slot.text.clone(), slot.linewise)))
            {
                None => success(id, Value::Null),
                Some((text, linewise)) => success(
                    id,
                    json!({
                        "text": text,
                        "linewise": linewise,
                    }),
                ),
            }
        }

        _ => error_resp(
            id,
            ERR_METHOD_NOT_FOUND,
            &format!("method not found: {method}"),
        ),
    }
}

// ---------------------------------------------------------------------------
// Param extractors
// ---------------------------------------------------------------------------

fn params_positional_str(params: &Value, idx: usize) -> std::result::Result<String, String> {
    match params {
        Value::Array(arr) => match arr.get(idx) {
            Some(Value::String(s)) => Ok(s.clone()),
            Some(other) => Err(format!("params[{idx}] must be a string, got {other}")),
            None => Err(format!("params[{idx}] missing")),
        },
        _ => Err("params must be an array".to_string()),
    }
}

fn params_positional_u64(params: &Value, idx: usize) -> std::result::Result<u64, String> {
    match params {
        Value::Array(arr) => match arr.get(idx) {
            Some(Value::Number(n)) => n
                .as_u64()
                .ok_or_else(|| format!("params[{idx}] must be a non-negative integer")),
            Some(other) => Err(format!("params[{idx}] must be a number, got {other}")),
            None => Err(format!("params[{idx}] missing")),
        },
        _ => Err("params must be an array".to_string()),
    }
}

fn params_array(params: &Value, idx: usize) -> std::result::Result<Vec<Value>, String> {
    match params {
        Value::Array(arr) => match arr.get(idx) {
            Some(Value::Array(inner)) => Ok(inner.clone()),
            Some(other) => Err(format!("params[{idx}] must be an array, got {other}")),
            None => Err(format!("params[{idx}] missing")),
        },
        _ => Err("params must be an array".to_string()),
    }
}

// ---------------------------------------------------------------------------
// View write helper (mirrors headless.rs)
// ---------------------------------------------------------------------------

fn write_buffer(
    editor: &Editor<View, DefaultHost>,
    path: &Option<PathBuf>,
    eol: crate::save::EolState,
) -> std::result::Result<(), String> {
    match path {
        None => Err("E32: No file name".to_string()),
        Some(p) => {
            let joined = editor.buffer().content_joined();
            // vim `'endofline'`/`'fixendofline'` — see
            // `save::EolState::trailing_newline`.
            let trailing_nl = eol.trailing_newline(&joined, editor.settings().fixendofline);
            let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
            crate::save::save_file_durable(p, joined.as_bytes(), trailing_nl, &cwd)
                .map_err(|e| format!("hjkl: {}: {e}", p.display()))
        }
    }
}

// ---------------------------------------------------------------------------
// Public entry point
// ---------------------------------------------------------------------------

/// Run in embed mode: JSON-RPC 2.0 server over stdin/stdout.
///
/// `files` — files to open. Only the first is loaded; the rest are ignored
/// for now (Phase 2.5 will add multi-buffer support).
pub fn run(files: Vec<PathBuf>) -> Result<i32> {
    // Stdout is the rpc channel here — keep the clipboard off so an OSC 52
    // fallback can't write escapes into the protocol stream (#264).
    crate::host::disable_clipboard_for_rpc();
    let first_file = files.into_iter().next();
    let (mut editor, mut current_filename, mut eol) = build_editor(first_file.as_deref())?;
    let mut should_quit = false;

    let stdin = std::io::stdin();
    let stdout = std::io::stdout();
    let mut stdin_lock = stdin.lock();
    let mut stdout_lock = stdout.lock();

    // Cap a single JSON-RPC line so a peer that never sends a newline can't
    // grow the buffer without bound (memory DoS). 64 MiB is far larger than any
    // real request; an oversized line is rejected and its remainder drained so
    // the stream resyncs on the next newline.
    const MAX_RPC_LINE: u64 = 64 * 1024 * 1024;
    let mut line = String::new();

    loop {
        line.clear();
        let n = (&mut stdin_lock).take(MAX_RPC_LINE).read_line(&mut line)?;
        if n == 0 {
            // EOF — clean exit.
            break;
        }
        if n as u64 == MAX_RPC_LINE && !line.ends_with('\n') {
            // Hit the cap without a newline → oversized request. Reject and
            // discard the rest of the line so the next read starts fresh.
            let resp = error_resp(&Value::Null, ERR_PARSE, "Parse error: request too large");
            write_response(&mut stdout_lock, &resp)?;
            drain_to_newline(&mut stdin_lock)?;
            continue;
        }

        let trimmed = line.trim();
        if trimmed.is_empty() {
            continue;
        }

        // Parse as JSON.
        let req: Value = match serde_json::from_str(trimmed) {
            Ok(v) => v,
            Err(_) => {
                let resp = error_resp(&Value::Null, ERR_PARSE, "Parse error");
                write_response(&mut stdout_lock, &resp)?;
                continue;
            }
        };

        // Validate JSON-RPC 2.0 structure.
        if req.get("jsonrpc").and_then(Value::as_str) != Some("2.0") {
            let id = req.get("id").cloned().unwrap_or(Value::Null);
            let resp = error_resp(
                &id,
                ERR_INVALID_REQUEST,
                "Invalid Request: missing jsonrpc:2.0",
            );
            write_response(&mut stdout_lock, &resp)?;
            continue;
        }

        let id = req.get("id").cloned().unwrap_or(Value::Null);
        let is_notification = req.get("id").is_none();

        let method = match req.get("method").and_then(Value::as_str) {
            Some(m) => m.to_string(),
            None => {
                if !is_notification {
                    let resp =
                        error_resp(&id, ERR_INVALID_REQUEST, "Invalid Request: missing method");
                    write_response(&mut stdout_lock, &resp)?;
                }
                continue;
            }
        };

        let params = req.get("params").cloned().unwrap_or(Value::Array(vec![]));

        // Notifications (no id) — dispatch but no response.
        if is_notification {
            dispatch(
                &mut editor,
                &mut current_filename,
                &mut eol,
                &mut should_quit,
                &method,
                &params,
                &Value::Null,
            );
            if should_quit {
                break;
            }
            continue;
        }

        let resp = dispatch(
            &mut editor,
            &mut current_filename,
            &mut eol,
            &mut should_quit,
            &method,
            &params,
            &id,
        );
        write_response(&mut stdout_lock, &resp)?;

        if should_quit {
            break;
        }
    }

    Ok(0)
}
