mod history;

use std::sync::{Arc, RwLock};

#[cfg(test)]
use lash_core::llm::types::LlmContentBlock;
use lash_core::llm::types::{LlmMessage, LlmRequestScope, LlmRole, LlmToolChoice};
use lash_core::sansio::ContextProjector;
use lash_core::{
    LlmRequest, ProjectorContext, PromptContribution, PromptUsage, ProtocolBuildInput,
    TurnDriverConfig, TurnDriverPreamble,
};
use lash_lashlang_runtime::LashlangSurface;
use lash_rlm_types::{RlmCreateExtras, RlmFinalAnswerFormat, RlmTermination};

#[cfg(test)]
use crate::projection::rlm_protocol_event;
use crate::rlm_support::decode_rlm_options;

#[cfg(test)]
use history::render_history_messages;
use history::{RlmHistoryRenderInput, build_rlm_history_messages_from_turn};

/// Cell shared between the RLM protocol plugin's turn-prepare hook (writer)
/// and the projector (reader). The plugin's hook captures `prompt_usage`
/// from `TurnTransformContext` each turn and stores it here so the
/// projector can render the budget suffix into the volatile turn-tail
/// message — keeping the cached system prefix byte-stable.
pub type SharedPromptUsage = Arc<RwLock<Option<PromptUsage>>>;

#[derive(Clone)]
pub struct RlmProjectorConfig {
    pub max_output_chars: usize,
    pub max_budget_tokens: Option<usize>,
    pub last_prompt_usage: SharedPromptUsage,
    pub prompt_features: crate::protocol::RlmPromptFeatures,
    pub lashlang_surface: LashlangSurface,
}

impl Default for RlmProjectorConfig {
    fn default() -> Self {
        Self {
            max_output_chars: 10_000,
            max_budget_tokens: None,
            last_prompt_usage: Arc::new(RwLock::new(None)),
            prompt_features: crate::protocol::RlmPromptFeatures::default(),
            lashlang_surface: LashlangSurface::default(),
        }
    }
}

pub fn build_rlm_preamble(
    input: ProtocolBuildInput,
    config: RlmProjectorConfig,
) -> TurnDriverPreamble {
    let tool_catalog = input.tool_catalog.as_ref();
    let tool_names = tool_catalog.tool_names();
    let tool_names_fingerprint = tool_catalog.tool_names_fingerprint();
    let mut prompt_contributions = Vec::new();

    let tool_docs = crate::tool_catalog::rlm_prompt_tool_docs(tool_catalog);
    if !tool_docs.trim().is_empty() {
        prompt_contributions.push(PromptContribution::execution("Tools", tool_docs));
    }
    prompt_contributions.extend(input.extra_prompt_contributions);
    let lashlang_host_environment = config
        .lashlang_surface
        .host_environment(tool_catalog)
        .expect("RLM tool catalog registration must validate explicit Lashlang bindings");

    TurnDriverPreamble {
        config: TurnDriverConfig {
            protocol: Arc::new(crate::protocol::RlmDriver),
            projector: Arc::new(RlmContextProjector {
                max_output_chars: config.max_output_chars,
                max_budget_tokens: config.max_budget_tokens,
                last_prompt_usage: config.last_prompt_usage,
            }),
            sync_execution_environment: true,
            turn_limit_final_message: Arc::new(crate::protocol::turn_limit_final_message),
        },
        tool_specs: Arc::new(Vec::new()),
        tool_names,
        tool_names_fingerprint,
        execution_prompt: Arc::from(crate::protocol::rlm_execution_section_for_host_environment(
            config.prompt_features,
            &lashlang_host_environment,
        )),
        prompt_contributions,
    }
}

#[cfg(test)]
mod catalogue_tests {
    use super::*;
    use lash_core::{ToolActivation, ToolScheduling};
    use lash_lashlang_runtime::{LashlangToolBinding, ToolDefinitionLashlangExt};

    fn tool(
        name: &str,
        module: &'static str,
        operation: &'static str,
    ) -> lash_core::ToolDefinition {
        lash_core::ToolDefinition::raw(
            format!("tool:{name}"),
            name,
            format!("Tool {name}"),
            serde_json::json!({
                "type": "object",
                "properties": { "query": { "type": "string" } },
                "required": ["query"]
            }),
            serde_json::json!({ "type": "string" }),
        )
        .with_activation(ToolActivation::Always)
        .with_scheduling(ToolScheduling::Parallel)
        .with_lashlang_binding(LashlangToolBinding::new([module], operation))
    }

    #[test]
    fn rlm_preamble_uses_resolved_tool_catalog_without_search_tool_special_cases() {
        let definitions = vec![
            tool("search_tools", "tools", "search"),
            tool("grep", "files", "grep"),
        ];
        let contracts = definitions
            .iter()
            .map(|tool| (tool.name().to_string(), Arc::new(tool.contract())))
            .collect();
        let surface = lash_core::ToolCatalog::from_tools(
            definitions
                .into_iter()
                .map(|tool| tool.manifest())
                .collect(),
            contracts,
        );

        let preamble = build_rlm_preamble(
            lash_core::ProtocolBuildInput {
                tool_catalog: Arc::new(surface),
                plugin_extensions: Default::default(),
                trigger_events: Default::default(),
                extra_prompt_contributions: Vec::new(),
            },
            RlmProjectorConfig {
                lashlang_surface: LashlangSurface::new(
                    lashlang::LashlangAbilities::all(),
                    lashlang::LashlangLanguageFeatures::default(),
                    lashlang::LashlangHostCatalog::tool_default(["search_tools", "grep"]),
                ),
                ..RlmProjectorConfig::default()
            },
        );

        assert_eq!(preamble.tool_names.as_ref(), &vec!["search_tools", "grep"]);
        let prompt = preamble
            .prompt_contributions
            .iter()
            .map(|contribution| contribution.content.as_ref())
            .collect::<Vec<_>>()
            .join("\n");
        assert!(prompt.contains("tools.search"));
        assert!(prompt.contains("files.grep"));
        assert!(!prompt.contains("search_tools("));
    }

    #[test]
    fn rlm_preamble_uses_lashlang_host_environment_abilities() {
        let definitions = vec![tool("grep", "files", "grep")];
        let contracts = definitions
            .iter()
            .map(|tool| (tool.name().to_string(), Arc::new(tool.contract())))
            .collect();
        let surface = lash_core::ToolCatalog::from_tools(
            definitions
                .into_iter()
                .map(|tool| tool.manifest())
                .collect(),
            contracts,
        );

        let preamble = build_rlm_preamble(
            lash_core::ProtocolBuildInput {
                tool_catalog: Arc::new(surface),
                plugin_extensions: Default::default(),
                trigger_events: Default::default(),
                extra_prompt_contributions: Vec::new(),
            },
            RlmProjectorConfig {
                lashlang_surface: LashlangSurface::new(
                    lashlang::LashlangAbilities::default(),
                    lashlang::LashlangLanguageFeatures::default(),
                    lashlang::LashlangHostCatalog::tool_default(["grep"]),
                ),
                ..RlmProjectorConfig::default()
            },
        );

        assert!(!preamble.execution_prompt.contains("process name"));
        assert!(!preamble.execution_prompt.contains("sleep for"));
        assert!(preamble.execution_prompt.contains("Module operations"));
    }

    #[test]
    fn finish_finalization_prompt_defaults_to_natural_guidance() {
        let prompt = rlm_finalization_prompt(&RlmTermination::default());

        assert!(prompt.contains("prose-only response immediately ends the turn"));
        assert!(prompt.contains("If any work remains, do not write prose-only"));
        assert!(prompt.contains("without `finish` is progress"));
    }

    #[test]
    fn finish_required_schema_finalization_prompt_requires_value() {
        let prompt = rlm_finalization_prompt(&RlmTermination::FinishRequired {
            schema: Some(serde_json::json!({ "type": "object" })),
        });

        assert!(prompt.contains("finish <value>"));
        assert!(prompt.contains("REQUIRED OUTPUT"));
        assert!(prompt.contains("Every non-terminal response must contain"));
        assert!(prompt.contains("Prose-only does not end the turn"));
        assert!(prompt.contains("commentary/status only"));
    }

    #[test]
    fn natural_finalization_prompt_allows_direct_prose() {
        let prompt = rlm_finalization_prompt(&RlmTermination::Natural);

        assert!(prompt.contains("Finish with prose"));
        assert!(prompt.contains("prose-only response immediately ends the turn"));
        assert!(prompt.contains("finish <value>"));
        assert!(prompt.contains("Every message before the final answer"));
        assert!(prompt.contains("Unaccompanied prose is final-answer-only"));
        assert!(prompt.contains("Example multi-step natural turn"));
    }
}

struct RlmContextProjector {
    max_output_chars: usize,
    max_budget_tokens: Option<usize>,
    last_prompt_usage: SharedPromptUsage,
}

impl ContextProjector<lash_core::HostTurnProtocol> for RlmContextProjector {
    fn project(&self, ctx: ProjectorContext<'_>) -> Arc<LlmRequest> {
        let options = decode_rlm_options(&ctx.config.termination)
            .expect("RLM turn options are validated before prompt projection");
        let finalization = rlm_finalization_prompt(&options.termination);
        let required_output = required_output_block(&options.termination);
        let final_answer_format = final_answer_format_prompt(&options);
        let budget_suffix = self.last_prompt_usage.read().ok().and_then(|guard| {
            crate::rlm_support::format_budget_suffix(
                ctx.protocol_iteration + 1,
                guard.as_ref(),
                self.max_budget_tokens,
            )
        });

        let mut messages = Vec::new();
        if !ctx.config.system_prompt.trim().is_empty() {
            messages.push(LlmMessage::text(
                LlmRole::System,
                Arc::clone(&ctx.config.system_prompt),
            ));
        }
        let mut attachments = Vec::new();
        messages.extend(build_rlm_history_messages_from_turn(
            RlmHistoryRenderInput {
                events: ctx.events,
                turn_messages: ctx.messages,
                turn_causes: ctx.turn_causes,
                max_output_chars: self.max_output_chars,
                protocol_iteration: ctx.protocol_iteration + 1,
                finalization,
                required_output: required_output.as_deref(),
                final_answer_format: final_answer_format.as_deref(),
                budget_suffix: budget_suffix.as_deref(),
            },
            &mut attachments,
        ));

        Arc::new(LlmRequest {
            model: ctx.config.model.clone(),
            messages,
            attachments,
            tools: Arc::new(Vec::new()),
            tool_choice: LlmToolChoice::None,
            model_variant: ctx.config.model_variant.clone(),
            model_capability: ctx.config.model_capability.clone(),
            scope: LlmRequestScope::new(
                ctx.config.session_id.clone(),
                format!("{}:frame:sansio", ctx.config.session_id),
                format!(
                    "{}:sansio:rlm:{}",
                    ctx.config.session_id, ctx.protocol_iteration
                ),
            ),
            output_spec: None,
            stream_events: None,
            generation: ctx.config.generation.clone(),
            provider_trace: None,
        })
    }
}

fn required_output_block(termination: &RlmTermination) -> Option<String> {
    match termination {
        RlmTermination::FinishRequired {
            schema: Some(schema),
        } => Some(render_value_schema_contract(schema)),
        _ => None,
    }
}

fn final_answer_format_prompt(options: &RlmCreateExtras) -> Option<String> {
    if matches!(
        options.termination,
        RlmTermination::FinishRequired { schema: Some(_) }
    ) {
        return None;
    }
    match options.final_answer_format.as_ref()? {
        RlmFinalAnswerFormat::Markdown => Some(
            match options.termination {
                RlmTermination::FinishRequired { schema: None } => {
                    "When finishing, call `finish <value>` with a nicely formatted Markdown string, not a raw record/list/tool-result value."
                }
                RlmTermination::Natural => {
                    "Write prose-only final answers as nicely formatted Markdown. If you intentionally use `finish <value>`, use a Markdown string for user-facing answers, not a raw record/list/tool-result value."
                }
                RlmTermination::FinishRequired { schema: Some(_) } => unreachable!(),
            }
            .to_string(),
        ),
        RlmFinalAnswerFormat::Custom { guidance } => {
            let guidance = guidance.trim();
            (!guidance.is_empty()).then(|| guidance.to_string())
        }
        RlmFinalAnswerFormat::RawFinalValue => None,
    }
}

fn render_value_schema_contract(schema: &serde_json::Value) -> String {
    let input_contract = lash_core::ToolDefinition::raw(
        "tool:finish",
        "finish",
        "",
        schema.clone(),
        serde_json::json!({}),
    )
    .compact_contract();

    if input_contract.parameters.is_empty() {
        return lash_core::ToolDefinition::raw(
            "tool:finish",
            "finish",
            "",
            lash_core::ToolDefinition::default_input_schema(),
            schema.clone(),
        )
        .compact_contract()
        .returns;
    }

    let head = format!(
        "{{ {} }}",
        input_contract
            .parameters
            .iter()
            .filter_map(|value| value.get("signature").and_then(serde_json::Value::as_str))
            .collect::<Vec<_>>()
            .join(", ")
    );
    let lines = input_contract
        .parameters
        .iter()
        .filter_map(compact_doc_line)
        .collect::<Vec<_>>();

    if lines.is_empty() {
        head
    } else {
        format!("{head}\nFields:\n{}", lines.join("\n"))
    }
}

fn compact_doc_line(value: &serde_json::Value) -> Option<String> {
    let signature = value.get("signature")?.as_str()?.trim();
    if signature.is_empty() {
        return None;
    }
    let description = value
        .get("description")
        .and_then(serde_json::Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty());
    Some(match description {
        Some(description) => format!("- `{signature}` — {description}"),
        None => format!("- `{signature}`"),
    })
}

fn rlm_finalization_prompt(termination: &RlmTermination) -> &'static str {
    match termination {
        RlmTermination::FinishRequired { schema: Some(_) } => {
            "This turn uses finish-required termination. Prose-only does not end the turn. Every non-terminal response must contain a paired `<lashlang>...</lashlang>` block that performs the next step; prose before the block is commentary/status only. Never say you will continue, inspect, patch, wait, monitor, validate, or retry unless the same response also contains the block that does it. The terminal response must be a paired `<lashlang>...</lashlang>` block that calls `finish <value>`, and `<value>` must match the REQUIRED OUTPUT contract."
        }
        RlmTermination::FinishRequired { schema: None } => {
            "This turn uses finish-required termination. Prose-only does not end the turn. Every non-terminal response must contain a paired `<lashlang>...</lashlang>` block that performs the next step; prose before the block is commentary/status only. Never say you will continue, inspect, patch, wait, monitor, validate, or retry unless the same response also contains the block that does it. The terminal response must be a paired `<lashlang>...</lashlang>` block that calls `finish <value>`. Use `finish null` only when null is intentional."
        }
        RlmTermination::Natural => {
            r#"This turn uses natural termination. Each assistant response must choose exactly one of these shapes:

1. Continue working: include a paired `<lashlang>...</lashlang>` block. Brief prose may appear before the block; that prose is commentary/status for the action that follows. A block without `finish` is progress and continues the loop.
2. Finish with prose: write prose with no `<lashlang>` block. A prose-only response immediately ends the turn as the final answer. Use this only when the task is complete and no work remains.
3. Finish with a computed/raw value: call `finish <value>` inside a paired `<lashlang>...</lashlang>` block. This ends the turn with that value.

Every message before the final answer must contain a paired `<lashlang>...</lashlang>` block. Any message may also contain prose; when prose accompanies a Lashlang block, it is commentary/status for the action that follows. Unaccompanied prose is final-answer-only. If any work remains, do not write prose-only. Never say you will continue, inspect, patch, wait, monitor, validate, or retry unless the same response also contains the `<lashlang>` block that does it.

Example multi-step natural turn:

I’ll inspect the current value first.
<lashlang>
preview = slice(to_string(value), 0, 400)
print(preview)
</lashlang>

<lashlang>
result = format("Checked: {}", preview)
print(result)
</lashlang>

Done. I inspected the value and summarized the result."#
        }
    }
}

impl RlmContextProjector {
    /// Test helper: the history-only messages (no current-iteration tail)
    /// flattened to their text for substring assertions on the rendered format.
    #[cfg(test)]
    fn format_history(&self, events: &[lash_core::SessionEventRecord]) -> String {
        let mut attachments = Vec::new();
        let messages = render_history_messages(
            &RlmHistoryRenderInput {
                events,
                turn_messages: &lash_core::MessageSequence::default(),
                turn_causes: &[],
                max_output_chars: self.max_output_chars,
                protocol_iteration: 0,
                finalization: "",
                required_output: None,
                final_answer_format: None,
                budget_suffix: None,
            },
            &mut attachments,
        );
        messages
            .iter()
            .flat_map(|message| message.blocks.iter())
            .filter_map(|block| match block {
                LlmContentBlock::Text { text, .. } => Some(text.as_ref()),
                _ => None,
            })
            .collect::<Vec<_>>()
            .join("\n\n")
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use lash_core::session_model::{
        ConversationRecord, MessageRole, Part, PartKind, PruneState, SessionEventRecord,
    };
    use lash_rlm_types::{RlmProtocolEvent, RlmTrajectoryEntry};

    fn user_event(id: &str, text: &str) -> SessionEventRecord {
        SessionEventRecord::Conversation(ConversationRecord {
            id: id.to_string(),
            role: MessageRole::User,
            parts: vec![Part {
                id: format!("{id}.p0"),
                kind: PartKind::Text,
                content: text.to_string(),
                attachment: None,
                tool_call_id: None,
                tool_name: None,
                tool_replay: None,
                prune_state: PruneState::Intact,
                reasoning_meta: None,
                response_meta: None,
            }]
            .into(),
            origin: None,
        })
    }

    fn step_event(protocol_iteration: usize, code: &str, output: &str) -> SessionEventRecord {
        SessionEventRecord::Protocol(rlm_protocol_event(RlmProtocolEvent::RlmTrajectoryEntry(
            RlmTrajectoryEntry {
                id: format!("lashlang_step_{protocol_iteration}"),
                protocol_iteration,
                code: code.to_string(),
                output: if output.is_empty() {
                    Vec::new()
                } else {
                    vec![output.to_string()]
                },
                images: Vec::new(),
                error: None,
                final_output: None,
            },
        )))
    }

    fn assistant_prose_event(id: &str, text: &str) -> SessionEventRecord {
        SessionEventRecord::Conversation(ConversationRecord {
            id: id.to_string(),
            role: MessageRole::Assistant,
            parts: vec![Part {
                id: format!("{id}.p0"),
                kind: PartKind::Text,
                content: text.to_string(),
                attachment: None,
                tool_call_id: None,
                tool_name: None,
                tool_replay: None,
                prune_state: PruneState::Intact,
                reasoning_meta: None,
                response_meta: None,
            }]
            .into(),
            origin: None,
        })
    }

    fn projector(max_output_chars: usize) -> RlmContextProjector {
        RlmContextProjector {
            max_output_chars,
            max_budget_tokens: None,
            last_prompt_usage: Arc::new(RwLock::new(None)),
        }
    }

    #[test]
    fn chronological_history_renders_messages_and_steps_in_order() {
        let projector = projector(100);
        let events = [
            user_event("u1", "first"),
            step_event(0, "print 1", "1"),
            user_event("u2", "second"),
            step_event(1, "print 2", "2"),
        ];
        let history = projector.format_history(&events);

        // History renders in the emission grammar: prior steps are the literal
        // `<lashlang>` cell the model must emit, outputs as separate blocks.
        assert!(history.contains("first"));
        assert!(history.contains("<lashlang>\nprint 1\n</lashlang>"));
        assert!(history.contains("history[1].output[0] (1 chars):\n1"));
        assert!(history.contains("second"));
        assert!(history.contains("<lashlang>\nprint 2\n</lashlang>"));
        assert!(history.contains("history[3].output[0] (1 chars):\n2"));
        // The `--- history[N] ---` meta-format is gone entirely.
        assert!(!history.contains("--- history["));
        assert!(!history.contains("Code:"));
        assert!(!history.contains("protocol_iteration"));
        assert!(!history.contains("Task"));
        assert!(!history.contains("user_input_"));
    }

    #[test]
    fn folded_step_renders_as_emission_cell_not_history_echo() {
        // Regression for the observed glm-5.2 echo: a step preceded by assistant
        // prose folds into ONE assistant message that is the literal `<lashlang>`
        // cell — byte-identical to what the model emits — never a
        // `--- history[...] ---` meta-format the model could imitate.
        let events = [
            user_event("u1", "find it"),
            assistant_prose_event("a1", "Found it. Running it now."),
            step_event(0, "loc = run()", "ok"),
        ];
        let mut attachments = Vec::new();
        let messages = build_rlm_history_messages_from_turn(
            RlmHistoryRenderInput {
                events: &events,
                turn_messages: &lash_core::MessageSequence::default(),
                turn_causes: &[],
                max_output_chars: 1000,
                protocol_iteration: 1,
                finalization: rlm_finalization_prompt(&RlmTermination::default()),
                required_output: None,
                final_answer_format: None,
                budget_suffix: None,
            },
            &mut attachments,
        );

        let assistant_texts = messages
            .iter()
            .filter(|message| matches!(message.role, LlmRole::Assistant))
            .flat_map(|message| message.blocks.iter())
            .filter_map(|block| match block {
                LlmContentBlock::Text { text, .. } => Some(text.as_ref()),
                _ => None,
            })
            .collect::<Vec<_>>();

        // The prose folded into the cell: exactly one assistant message, no echo.
        assert_eq!(assistant_texts.len(), 1);
        assert!(!assistant_texts[0].contains("--- history["));
        assert!(!assistant_texts[0].contains("Code:\n"));
        assert_eq!(
            assistant_texts[0],
            crate::cell_scan::render_lashlang_cell_text("Found it. Running it now.", "loc = run()")
        );
    }

    #[test]
    fn chronological_history_excludes_hidden_tool_events() {
        let projector = projector(1000);
        let events = [user_event("u1", "first"), step_event(0, "x = 1", "1")];
        let history = projector.format_history(&events);

        assert!(history.contains("first"));
        assert!(history.contains("<lashlang>\nx = 1\n</lashlang>"));
        assert!(!history.contains("tool_call"));
        assert!(!history.contains("--- history["));
    }

    #[test]
    fn long_user_message_gets_full_history_reference() {
        let projector = projector(10);
        let history = projector.format_history(&[user_event("u1", "abcdefghijklmnopqrstuvwxyz")]);

        assert!(history.contains("re-print history[0].content"));
        assert!(history.contains("... (16 characters omitted) ..."));
        assert!(!history.contains("user_input_"));
    }

    #[test]
    fn truncated_lashlang_step_output_emits_full_reference() {
        // The render half of the re-fetch contract: a truncated step output
        // shows a preview plus a `full: history[0].output[0]` handle. The
        // resolve half — that the handle returns the full untruncated value —
        // is covered by `history_step_output_resolves_full_untruncated_value`
        // in projection::context.
        let projector = projector(10);
        let output = "x".repeat(60 * 1024);
        let history = projector.format_history(&[step_event(0, "print big", &output)]);

        assert!(history.contains("re-print history[0].output[0]"));
        assert!(history.contains("full value retained"));
        assert!(history.contains("...truncated..."));
    }

    #[test]
    fn truncated_step_output_states_value_is_retained_not_lost() {
        // A truncated preview must read as display-only, not lost state — the
        // inference gpt-5.5 got wrong when it stopped a /spring-cleaning
        // mid-task ("I can't continue from the previous tool state"). The note
        // names the re-print handle and states the value is retained.
        let projector = projector(10);
        let output = "x".repeat(60 * 1024);
        let history = projector.format_history(&[step_event(0, "print big", &output)]);

        assert!(history.contains("full value retained"), "{history}");
        assert!(
            history.contains("re-print history[0].output[0]"),
            "{history}"
        );
        // The bare, easily-misread "chars, full: <ref>" framing is gone.
        assert!(!history.contains("chars, full: history"), "{history}");
    }

    #[test]
    fn structured_lashlang_step_output_keeps_diagnostic_fields_in_projected_history() {
        let projector = projector(10);
        let raw = serde_json::json!({
            "output": "x".repeat(60 * 1024),
            "status": "failed",
            "error": "boom",
            "exit_code": 2,
            "stderr": "short stderr"
        })
        .to_string();
        let history = projector.format_history(&[step_event(0, "print result", &raw)]);

        assert!(
            history.contains("re-print history[0].output[0]"),
            "{history}"
        );
        let status = history.find(r#""status":"failed""#).expect("status field");
        let error = history.find(r#""error":"boom""#).expect("error field");
        let exit = history.find(r#""exit_code":2"#).expect("exit field");
        let stderr = history
            .find(r#""stderr":"short stderr""#)
            .expect("stderr field");
        assert!(status < error && error < exit && exit < stderr, "{history}");
        assert!(history.contains("truncated"), "{history}");
    }

    #[test]
    fn plugin_origin_is_not_rendered_in_history() {
        let projector = projector(100);
        let event = SessionEventRecord::Conversation(ConversationRecord {
            id: "plugin".to_string(),
            role: MessageRole::User,
            parts: vec![Part {
                id: "plugin.p0".to_string(),
                kind: PartKind::Text,
                content: "synthetic plugin message".to_string(),
                attachment: None,
                tool_call_id: None,
                tool_name: None,
                tool_replay: None,
                prune_state: PruneState::Intact,
                reasoning_meta: None,
                response_meta: None,
            }]
            .into(),
            origin: Some(lash_core::MessageOrigin::Plugin {
                plugin_id: "test".to_string(),
                transient: false,
            }),
        });

        let history = projector.format_history(&[event]);
        assert!(history.contains("synthetic plugin message"));
        assert!(!history.contains("from plugin"));
        assert!(!history.contains("test"));
        assert!(!history.contains("--- history["));
    }

    #[test]
    fn process_wake_history_renders_as_chronological_event_context() {
        let projector = projector(1000);
        let event = SessionEventRecord::Conversation(ConversationRecord {
            id: "wake:abc".to_string(),
            role: MessageRole::Event,
            parts: vec![Part {
                id: "wake:abc.p0".to_string(),
                kind: PartKind::Text,
                content: "Background process wake\nProcess: process-1\nEvent: process.wake #7\nWake input:\nblue button pressed".to_string(),
                attachment: None,
                tool_call_id: None,
                tool_name: None,
                tool_replay: None,
                prune_state: PruneState::Intact,
                reasoning_meta: None,
                response_meta: None,
            }]
            .into(),
            origin: Some(lash_core::MessageOrigin::Process {
                process_id: "process-1".to_string(),
                event_type: "process.wake".to_string(),
                sequence: 7,
                wake_id: Some("wake:abc".to_string()),
                caused_by: None,
            }),
        });
        let events = [event];
        let mut attachments = Vec::new();

        let messages = build_rlm_history_messages_from_turn(
            RlmHistoryRenderInput {
                events: &events,
                turn_messages: &lash_core::MessageSequence::default(),
                turn_causes: &[],
                max_output_chars: 1000,
                protocol_iteration: 1,
                finalization: rlm_finalization_prompt(&RlmTermination::default()),
                required_output: None,
                final_answer_format: None,
                budget_suffix: None,
            },
            &mut attachments,
        );
        let history = projector.format_history(&events);

        assert!(history.contains("Background process wake"));
        assert!(history.contains("blue button pressed"));
        assert!(!history.contains("system message"));
        assert!(!history.contains("--- history["));
        assert!(matches!(messages[0].role, LlmRole::User));
    }

    #[test]
    fn active_turn_causes_render_in_current_turn_events_without_history_duplication() {
        let cause = lash_core::TurnCause {
            id: "wake:abc".to_string(),
            event_type: "process.wake".to_string(),
            origin: lash_core::MessageOrigin::Process {
                process_id: "process-1".to_string(),
                event_type: "process.wake".to_string(),
                sequence: 7,
                wake_id: Some("wake:abc".to_string()),
                caused_by: None,
            },
            text: "Background process wake\nProcess: process-1\nEvent: process.wake #7\nWake input:\nblue button pressed".to_string(),
        };
        let event_message = cause.to_event_message();
        let messages = lash_core::MessageSequence::from(vec![event_message]);
        let mut attachments = Vec::new();

        let rendered = build_rlm_history_messages_from_turn(
            RlmHistoryRenderInput {
                events: &[],
                turn_messages: &messages,
                turn_causes: std::slice::from_ref(&cause),
                max_output_chars: 1000,
                protocol_iteration: 0,
                finalization: rlm_finalization_prompt(&RlmTermination::default()),
                required_output: None,
                final_answer_format: None,
                budget_suffix: None,
            },
            &mut attachments,
        );

        let combined = rendered
            .iter()
            .flat_map(|message| message.blocks.iter())
            .filter_map(|block| match block {
                LlmContentBlock::Text { text, .. } => Some(text.as_ref()),
                _ => None,
            })
            .collect::<Vec<_>>()
            .join("\n");
        assert!(combined.contains("=== TURN EVENTS ==="));
        assert!(combined.contains("blue button pressed"));
        assert!(!combined.contains("--- history[0] · event message"));
        assert!(rendered.iter().any(|message| {
            message.role == LlmRole::User
                && message.blocks.iter().any(|block| {
                    matches!(
                        block,
                        LlmContentBlock::Text { text, .. }
                            if text.contains("=== TURN EVENTS ===")
                    )
                })
        }));
    }

    #[test]
    fn printed_images_render_as_llm_image_blocks() {
        let event = SessionEventRecord::Protocol(rlm_protocol_event(
            RlmProtocolEvent::RlmTrajectoryEntry(RlmTrajectoryEntry {
                id: "lashlang_step_1".to_string(),
                protocol_iteration: 1,
                code: "print img".to_string(),
                output: vec![r#"{"type":"image","id":"img"}"#.to_string()],
                images: vec![lash_core::AttachmentRef {
                    id: lash_core::AttachmentId::new("img-ref"),
                    media_type: lash_core::MediaType::Image(lash_core::ImageMediaType::Png),
                    byte_len: 3,
                    width: Some(1),
                    height: Some(1),
                    label: Some("img.png".to_string()),
                }],
                error: None,
                final_output: None,
            }),
        ));
        let events = [event];
        let mut attachments = Vec::new();

        let messages = build_rlm_history_messages_from_turn(
            RlmHistoryRenderInput {
                events: &events,
                turn_messages: &lash_core::MessageSequence::default(),
                turn_causes: &[],
                max_output_chars: 1000,
                protocol_iteration: 1,
                finalization: rlm_finalization_prompt(&RlmTermination::default()),
                required_output: None,
                final_answer_format: None,
                budget_suffix: None,
            },
            &mut attachments,
        );

        assert_eq!(attachments.len(), 1);
        assert_eq!(attachments[0].mime, "image/png");
        assert!(attachments[0].data.is_empty());
        assert_eq!(
            attachments[0]
                .reference
                .as_ref()
                .map(|reference| reference.id.as_str()),
            Some("img-ref")
        );
        // The printed image rides the user observation message for the step.
        assert!(messages.iter().any(|message| {
            matches!(message.role, LlmRole::User)
                && message
                    .blocks
                    .iter()
                    .any(|block| matches!(block, LlmContentBlock::Image { attachment_idx: 0 }))
        }));
    }

    #[test]
    fn rlm_prompt_projects_history_as_chat_messages_with_rolling_cache_breakpoint() {
        let events = [user_event("u1", "first"), step_event(0, "print 1", "1")];
        let mut attachments = Vec::new();

        let messages = build_rlm_history_messages_from_turn(
            RlmHistoryRenderInput {
                events: &events,
                turn_messages: &lash_core::MessageSequence::default(),
                turn_causes: &[],
                max_output_chars: 1000,
                protocol_iteration: 2,
                finalization: rlm_finalization_prompt(&RlmTermination::default()),
                required_output: None,
                final_answer_format: None,
                budget_suffix: None,
            },
            &mut attachments,
        );

        // user turn, assistant cell, user observation, volatile current-iteration tail.
        assert_eq!(messages.len(), 4);
        assert!(matches!(messages[0].role, LlmRole::User));
        assert!(matches!(messages[1].role, LlmRole::Assistant));
        assert!(matches!(messages[2].role, LlmRole::User));
        assert!(matches!(messages[3].role, LlmRole::User));
        assert!(matches!(
            messages[0].blocks.first(),
            Some(LlmContentBlock::Text {
                text,
                cache_breakpoint: false,
                ..
            }) if text.as_ref() == "first"
        ));
        assert!(matches!(
            messages[1].blocks.first(),
            Some(LlmContentBlock::Text {
                text,
                cache_breakpoint: false,
                ..
            }) if text.starts_with("<lashlang>") && text.contains("print 1")
        ));
        // The last history message (the observation) carries the rolling fence.
        assert!(matches!(
            messages[2].blocks.first(),
            Some(LlmContentBlock::Text {
                text,
                cache_breakpoint: true,
                ..
            }) if text.starts_with("history[1].output[0]")
        ));
        assert!(matches!(
            messages[3].blocks.first(),
            Some(LlmContentBlock::Text {
                text,
                cache_breakpoint: false,
                ..
            }) if text.contains("=== CURRENT ITERATION: 2 ===")
        ));
    }

    #[test]
    fn rlm_prompt_renders_required_output_block_when_schema_present() {
        let events = [user_event("u1", "first")];
        let mut attachments = Vec::new();
        let schema = serde_json::json!({
            "type": "object",
            "properties": {
                "action": { "type": "string", "enum": ["call", "fold"] },
                "amount": { "type": "integer", "minimum": 0 }
            },
            "required": ["action"]
        });

        let schema_contract = render_value_schema_contract(&schema);
        let messages = build_rlm_history_messages_from_turn(
            RlmHistoryRenderInput {
                events: &events,
                turn_messages: &lash_core::MessageSequence::default(),
                turn_causes: &[],
                max_output_chars: 1000,
                protocol_iteration: 1,
                finalization: "Call finish",
                required_output: Some(&schema_contract),
                final_answer_format: None,
                budget_suffix: None,
            },
            &mut attachments,
        );

        let tail = messages
            .last()
            .and_then(|message| message.blocks.first())
            .and_then(|block| match block {
                LlmContentBlock::Text { text, .. } => Some(text.as_ref()),
                _ => None,
            })
            .expect("tail block");
        assert!(tail.contains("=== REQUIRED OUTPUT ==="));
        assert!(tail.contains("{ action: enum[\"call\", \"fold\"], amount?: int >= 0 }"));
        assert!(tail.contains("Fields:"));
    }

    #[test]
    fn final_answer_format_guidance_renders_markdown_for_unstructured_turns() {
        let guidance = final_answer_format_prompt(&RlmCreateExtras {
            termination: RlmTermination::FinishRequired { schema: None },
            final_answer_format: Some(RlmFinalAnswerFormat::Markdown),
        })
        .expect("markdown guidance");

        assert!(guidance.contains("call `finish <value>` with a nicely formatted Markdown string"));
        assert!(guidance.contains("not a raw record/list/tool-result value"));
    }

    #[test]
    fn final_answer_format_guidance_honors_custom_text_and_raw_suppression() {
        let custom = final_answer_format_prompt(&RlmCreateExtras {
            termination: RlmTermination::Natural,
            final_answer_format: Some(RlmFinalAnswerFormat::Custom {
                guidance: "  Finish concise release-note Markdown.  ".to_string(),
            }),
        })
        .expect("custom guidance");
        assert_eq!(custom, "Finish concise release-note Markdown.");

        assert!(
            final_answer_format_prompt(&RlmCreateExtras {
                termination: RlmTermination::FinishRequired { schema: None },
                final_answer_format: Some(RlmFinalAnswerFormat::RawFinalValue),
            })
            .is_none()
        );
    }

    #[test]
    fn required_output_schema_suppresses_final_answer_format_guidance() {
        let guidance = final_answer_format_prompt(&RlmCreateExtras {
            termination: RlmTermination::FinishRequired {
                schema: Some(serde_json::json!({ "type": "object" })),
            },
            final_answer_format: Some(RlmFinalAnswerFormat::Markdown),
        });

        assert!(guidance.is_none());
    }

    #[test]
    fn render_value_schema_contract_renders_object_shape_with_field_table() {
        let schema = serde_json::json!({
            "type": "object",
            "properties": {
                "action": { "type": "string", "enum": ["call", "fold"] },
                "confidence": { "type": "number", "minimum": 0, "maximum": 1 }
            },
            "required": ["action"]
        });

        let rendered = render_value_schema_contract(&schema);
        let head = rendered.lines().next().expect("at least one line");
        assert_eq!(
            head,
            "{ action: enum[\"call\", \"fold\"], confidence?: float >= 0 <= 1 }"
        );
        assert!(rendered.contains("Fields:"));
        assert!(rendered.contains("- `action: enum[\"call\", \"fold\"]`"));
        assert!(rendered.contains("- `confidence?: float >= 0 <= 1`"));
    }

    #[test]
    fn render_value_schema_contract_falls_back_to_compact_label_for_scalars() {
        let scalar = serde_json::json!({ "type": "string" });
        assert_eq!(render_value_schema_contract(&scalar), "str");

        let array = serde_json::json!({ "type": "array", "items": { "type": "integer" } });
        assert_eq!(render_value_schema_contract(&array), "list[int]");

        let nullable_string = serde_json::json!({ "type": ["string", "null"] });
        assert_eq!(render_value_schema_contract(&nullable_string), "str | null");
    }

    #[test]
    fn incremental_render_extends_cached_prefix_on_subsequent_calls() {
        let projector = projector(100);
        let initial =
            projector.format_history(&[user_event("u1", "first"), step_event(0, "print 1", "1")]);
        assert!(initial.contains("first"));
        assert!(initial.contains("<lashlang>\nprint 1\n</lashlang>"));

        let extended = projector.format_history(&[
            user_event("u1", "first"),
            step_event(0, "print 1", "1"),
            user_event("u2", "second"),
            step_event(1, "print 2", "2"),
        ]);
        // The stable prefix is byte-identical, so the cached prefix extends.
        assert!(extended.starts_with(&initial));
        assert!(extended.contains("second"));
        assert!(extended.contains("<lashlang>\nprint 2\n</lashlang>"));
    }
}
