//! Prompt rendering for IDE context injected into TUI user turns.

use codex_app_server_protocol::ByteRange;
use codex_app_server_protocol::TextElement;
use codex_app_server_protocol::UserInput;

use super::IdeContext;

const MAX_ACTIVE_SELECTION_CHARS: usize = 40_000;
const MAX_OPEN_TABS: usize = 100;
const MAX_OPEN_TABS_CHARS: usize = 20_000;
// Match the desktop app and IDE extension delimiter exactly. IDE context is serialized into the
// raw prompt before this marker, then transcript rendering strips back to the request after the last
// marker. Keeping the same marker and stripping semantics lets threads created with IDE context in
// one surface replay cleanly in the others.
const PROMPT_REQUEST_BEGIN: &str = "## My request for Codex:";

pub(crate) fn apply_ide_context_to_user_input(
    context: &IdeContext,
    items: &mut Vec<UserInput>,
) -> bool {
    let Some(context_text) = render_prompt_context(context) else {
        return false;
    };

    let prefix = format!("{context_text}\n{PROMPT_REQUEST_BEGIN}\n");
    if let Some(text_index) = items
        .iter()
        .position(|item| matches!(item, UserInput::Text { .. }))
    {
        // Prefix the existing text item in place so image and text items keep
        // the same relative order they had in the user's original submission.
        let item = std::mem::replace(
            &mut items[text_index],
            UserInput::Text {
                text: String::new(),
                text_elements: Vec::new(),
            },
        );
        let UserInput::Text {
            text,
            text_elements,
        } = item
        else {
            unreachable!("position matched a text item");
        };
        items[text_index] = prefixed_text_input(prefix, text, text_elements);
    } else {
        items.insert(
            0,
            UserInput::Text {
                text: prefix,
                text_elements: Vec::new(),
            },
        );
    }

    true
}

pub(crate) fn has_prompt_context(context: &IdeContext) -> bool {
    render_prompt_context(context).is_some()
}

pub(crate) fn extract_prompt_request_with_offset(message: &str) -> (&str, usize) {
    let Some((before_request, request)) = message.rsplit_once(PROMPT_REQUEST_BEGIN) else {
        return (message, 0);
    };

    let request_start = before_request.len() + PROMPT_REQUEST_BEGIN.len();
    let trimmed_request = request.trim();
    let leading_trimmed_len = request.len() - request.trim_start().len();
    (trimmed_request, request_start + leading_trimmed_len)
}

fn prefixed_text_input(prefix: String, text: String, text_elements: Vec<TextElement>) -> UserInput {
    let prefix_len = prefix.len();
    UserInput::Text {
        text: format!("{prefix}{text}"),
        text_elements: text_elements
            .into_iter()
            .map(|element| {
                let range = element.byte_range.clone();
                TextElement::new(
                    ByteRange {
                        start: range.start + prefix_len,
                        end: range.end + prefix_len,
                    },
                    element.placeholder().map(str::to_string),
                )
            })
            .collect(),
    }
}

fn render_prompt_context(context: &IdeContext) -> Option<String> {
    let mut ide_context_section = String::new();

    if let Some(active_file) = &context.active_file {
        ide_context_section.push_str(&format!(
            "\n## Active file: {}\n",
            active_file.descriptor.path
        ));
    }

    if let Some(active_file) = &context.active_file {
        let selected_ranges = if active_file.selections.is_empty() {
            std::slice::from_ref(&active_file.selection)
        } else {
            active_file.selections.as_slice()
        }
        .iter()
        .filter(|range| range.start != range.end)
        .collect::<Vec<_>>();

        if !selected_ranges.is_empty()
            && (active_file.active_selection_content.is_empty() || selected_ranges.len() > 1)
        {
            if selected_ranges.len() == 1 {
                ide_context_section.push_str("\n## Active selection range:\n");
            } else {
                ide_context_section.push_str("\n## Active selection ranges:\n");
            }
            for range in selected_ranges {
                // Render ranges as 1-based positions for the prompt.
                let start_line = range.start.line + 1;
                let start_column = range.start.character + 1;
                let end_line = range.end.line + 1;
                let end_column = range.end.character + 1;
                ide_context_section.push_str(&format!(
                    "- {}: line {start_line}, column {start_column} to line {end_line}, column {end_column}\n",
                    active_file.descriptor.path
                ));
            }
        }
    }

    if let Some(active_file) = &context.active_file
        && !active_file.active_selection_content.is_empty()
    {
        ide_context_section.push_str("\n## Active selection of the file:\n");
        let selection = active_file.active_selection_content.as_str();
        if let Some((truncate_at, _)) = selection.char_indices().nth(MAX_ACTIVE_SELECTION_CHARS) {
            ide_context_section.push_str(&selection[..truncate_at]);
            ide_context_section.push_str(&format!(
                "\n[Selection truncated to {MAX_ACTIVE_SELECTION_CHARS} characters.]\n"
            ));
        } else {
            ide_context_section.push_str(selection);
        }
    }

    if !context.open_tabs.is_empty() {
        ide_context_section.push_str("\n## Open tabs:\n");
        let mut rendered_tabs = 0;
        let mut rendered_tab_chars = 0;
        for tab in &context.open_tabs {
            if rendered_tabs >= MAX_OPEN_TABS {
                break;
            }

            let tab_line = format!("- {}: {}\n", tab.label, tab.path);
            if rendered_tab_chars + tab_line.len() > MAX_OPEN_TABS_CHARS {
                break;
            }

            ide_context_section.push_str(&tab_line);
            rendered_tabs += 1;
            rendered_tab_chars += tab_line.len();
        }

        let omitted_tabs = context.open_tabs.len() - rendered_tabs;
        if omitted_tabs > 0 {
            ide_context_section.push_str(&format!("[{omitted_tabs} open tabs omitted.]\n"));
        }
    }

    if ide_context_section.is_empty() {
        None
    } else {
        Some(format!(
            "# Context from my IDE setup:\n{ide_context_section}"
        ))
    }
}

#[cfg(test)]
mod tests {
    use super::super::ActiveFile;
    use super::super::FileDescriptor;
    use super::super::IdeContext;
    use super::super::Position;
    use super::super::Range;
    use super::*;
    use pretty_assertions::assert_eq;
    use std::path::PathBuf;

    fn descriptor(label: &str, path: &str) -> FileDescriptor {
        FileDescriptor {
            label: label.to_string(),
            path: path.to_string(),
        }
    }

    #[test]
    fn render_prompt_context_matches_app_format() {
        let context = IdeContext {
            active_file: Some(ActiveFile {
                descriptor: descriptor("lib.rs", "src/lib.rs"),
                selection: Range {
                    start: Position {
                        line: 4,
                        character: 0,
                    },
                    end: Position {
                        line: 6,
                        character: 1,
                    },
                },
                active_selection_content: "fn selected() {}".to_string(),
                selections: Vec::new(),
            }),
            open_tabs: vec![
                descriptor("lib.rs", "src/lib.rs"),
                descriptor("main.rs", "src/main.rs"),
            ],
        };

        assert_eq!(
            render_prompt_context(&context),
            Some(
                "# Context from my IDE setup:\n\n## Active file: src/lib.rs\n\n## Active selection of the file:\nfn selected() {}\n## Open tabs:\n- lib.rs: src/lib.rs\n- main.rs: src/main.rs\n"
                    .to_string()
            )
        );
    }

    #[test]
    fn render_prompt_context_omits_empty_context() {
        let context = IdeContext {
            active_file: None,
            open_tabs: Vec::new(),
        };

        assert_eq!(render_prompt_context(&context), None);
    }

    #[test]
    fn apply_ide_context_uses_desktop_prompt_request_delimiter() {
        let context = IdeContext {
            active_file: Some(ActiveFile {
                descriptor: descriptor("lib.rs", "src/lib.rs"),
                selection: Range {
                    start: Position {
                        line: 0,
                        character: 0,
                    },
                    end: Position {
                        line: 0,
                        character: 0,
                    },
                },
                active_selection_content: String::new(),
                selections: Vec::new(),
            }),
            open_tabs: Vec::new(),
        };
        let text = "Ask $figma".to_string();
        let mut items = vec![
            UserInput::LocalImage {
                path: PathBuf::from("/tmp/screenshot.png"),
                detail: None,
            },
            UserInput::Text {
                text,
                text_elements: vec![TextElement::new(
                    ByteRange { start: 4, end: 10 },
                    Some("$figma".to_string()),
                )],
            },
        ];

        assert!(apply_ide_context_to_user_input(&context, &mut items));

        let expected_prefix = "# Context from my IDE setup:\n\n## Active file: src/lib.rs\n\n## My request for Codex:\n";
        let prefix_len = expected_prefix.len();
        assert_eq!(
            items,
            vec![
                UserInput::LocalImage {
                    path: PathBuf::from("/tmp/screenshot.png"),
                    detail: None,
                },
                UserInput::Text {
                    text: format!("{expected_prefix}Ask $figma"),
                    text_elements: vec![TextElement::new(
                        ByteRange {
                            start: prefix_len + 4,
                            end: prefix_len + 10,
                        },
                        Some("$figma".to_string()),
                    )],
                },
            ]
        );
    }

    #[test]
    fn extract_prompt_request_returns_text_after_last_delimiter() {
        let message =
            "# Context\n## My request for Codex:\nFirst\n## My request for Codex:\n  Second\n";

        assert_eq!(
            extract_prompt_request_with_offset(message),
            ("Second", message.find("Second").expect("request offset"))
        );
    }

    #[test]
    fn render_prompt_context_includes_selection_ranges_without_content() {
        let first_range = Range {
            start: Position {
                line: 1,
                character: 2,
            },
            end: Position {
                line: 1,
                character: 5,
            },
        };
        let second_range = Range {
            start: Position {
                line: 3,
                character: 0,
            },
            end: Position {
                line: 4,
                character: 1,
            },
        };
        let context = IdeContext {
            active_file: Some(ActiveFile {
                descriptor: descriptor("lib.rs", "src/lib.rs"),
                selection: first_range.clone(),
                active_selection_content: String::new(),
                selections: vec![first_range, second_range],
            }),
            open_tabs: Vec::new(),
        };

        assert_eq!(
            render_prompt_context(&context),
            Some(
                "# Context from my IDE setup:\n\n## Active file: src/lib.rs\n\n## Active selection ranges:\n- src/lib.rs: line 2, column 3 to line 2, column 6\n- src/lib.rs: line 4, column 1 to line 5, column 2\n"
                    .to_string()
            )
        );
    }

    #[test]
    fn render_prompt_context_truncates_large_selection() {
        let context = IdeContext {
            active_file: Some(ActiveFile {
                descriptor: descriptor("large.txt", "large.txt"),
                selection: Range {
                    start: Position {
                        line: 0,
                        character: 0,
                    },
                    end: Position {
                        line: 0,
                        character: 1,
                    },
                },
                active_selection_content: format!("{}tail", "a".repeat(MAX_ACTIVE_SELECTION_CHARS)),
                selections: Vec::new(),
            }),
            open_tabs: Vec::new(),
        };

        let rendered = render_prompt_context(&context).expect("rendered IDE context");
        assert!(rendered.contains(&format!(
            "[Selection truncated to {MAX_ACTIVE_SELECTION_CHARS} characters.]"
        )));
        assert!(!rendered.contains("tail"));
    }

    #[test]
    fn render_prompt_context_omits_excess_open_tabs() {
        let open_tabs = (0..MAX_OPEN_TABS + 2)
            .map(|index| descriptor(&format!("file-{index}.rs"), &format!("src/file-{index}.rs")))
            .collect::<Vec<_>>();
        let context = IdeContext {
            active_file: None,
            open_tabs,
        };

        let rendered = render_prompt_context(&context).expect("rendered IDE context");
        assert!(rendered.contains("- file-99.rs: src/file-99.rs\n"));
        assert!(!rendered.contains("- file-100.rs: src/file-100.rs\n"));
        assert!(rendered.contains("[2 open tabs omitted.]\n"));
    }
}
