//! Pure response text policy for turn closeout and write guards.

/// Strip leading `## Assistant` and trailing `## User` headings from append-mode
/// response text.
///
/// The append writer adds its own `## Assistant` prefix and `## User` suffix, so
/// echoed transcript headings are removed before the response is persisted.
pub fn strip_assistant_heading(response: &str) -> String {
    let mut result = response.to_string();

    let trimmed = result.trim_start();
    if let Some(rest) = trimmed.strip_prefix("## Assistant") {
        let rest = rest.strip_prefix('\n').unwrap_or(rest);
        let rest = rest.trim_start_matches('\n');
        result = rest.to_string();
    }

    let trimmed_end = result.trim_end();
    if let Some(before) = trimmed_end.strip_suffix("## User") {
        result = before.trim_end_matches('\n').to_string();
        if !result.ends_with('\n') {
            result.push('\n');
        }
    }

    result
}

/// Extract the session prompt target from the first `### Re:`-style response
/// heading, dropping the model suffix after a dash separator.
pub fn response_prompt_target_from_re_heading(response_body: &str) -> Option<String> {
    for line in response_body.lines() {
        let without_hashes = line.trim().trim_start_matches('#').trim_start();
        let Some(rest) = without_hashes.strip_prefix("Re:") else {
            continue;
        };
        let target = rest
            .split_once(" \u{2014} ")
            .map(|(target, _)| target)
            .or_else(|| rest.split_once(" - ").map(|(target, _)| target))
            .unwrap_or(rest)
            .trim();
        if !target.is_empty() {
            return Some(target.to_string());
        }
    }
    None
}

pub fn is_committed_prompt_diff_interruption(reason: &str) -> bool {
    reason.contains("is `committed`")
        && reason.contains("prompt_target:")
        && (reason.contains("unresolved prompt-bearing user changes")
            || reason.contains(
                "active harness session changed this document after the last committed closeout",
            ))
        && (reason.contains("no new agent-doc cycle started")
            || reason.contains("without reopening the binary-owned write/commit path"))
}

pub fn prompt_target_from_interruption_reason(reason: &str) -> Option<String> {
    let marker = "prompt_target:";
    let tail = reason.split_once(marker)?.1.trim();
    (!tail.is_empty()).then(|| tail.to_string())
}

pub fn first_nonempty_prompt_line(prompt: &str) -> String {
    prompt
        .lines()
        .find(|line| !line.trim().is_empty())
        .unwrap_or(prompt)
        .trim()
        .to_string()
}

/// Summarize captured response text for external hook consumers. Agent-doc patch
/// comments are metadata, not useful response content, so they are removed
/// before the summary is bounded.
pub fn summarize_response_for_hook(response_body: &str) -> String {
    let lines = response_body
        .lines()
        .filter(|line| {
            let trimmed = line.trim();
            trimmed != "<!-- patch:exchange -->"
                && trimmed != "<!-- /patch:exchange -->"
                && !(trimmed.starts_with("<!--") && trimmed.ends_with("-->"))
        })
        .collect::<Vec<_>>()
        .join("\n");
    truncate_response_summary(lines.trim(), 4000)
}

pub fn render_interleaved_thinking_response(thinking: &str, response: &str) -> String {
    if thinking.is_empty() {
        return response.to_string();
    }

    format!(
        "<details>\n<summary>Thinking</summary>\n\n{}\n</details>\n\n{}",
        thinking, response
    )
}

fn truncate_response_summary(input: &str, max_chars: usize) -> String {
    let mut chars = input.chars();
    let mut output = String::new();
    for _ in 0..max_chars {
        let Some(ch) = chars.next() else {
            return input.to_string();
        };
        output.push(ch);
    }
    if chars.next().is_some() {
        output.push_str("\n[truncated]");
    }
    output
}

const IMPERATIVE_STATUS_ONLY_SIGNALS: &[&str] = &[
    "in progress",
    "continuing",
    "starting",
    "working on it",
    "still working",
    "next i'll",
    "next i will",
    "i'll update",
    "i will update",
    "i'm going to",
    "i am going to",
    "let me do that",
];

const IMPERATIVE_META_REFUSAL_SIGNALS: &[&str] = &[
    "because you asked me to run agent-doc",
    "treated that text as document content",
    "not to execute",
    "say do #",
    "repeat the instruction in chat",
    "i stayed on the first layer",
    "operate on the session document",
];

const IMPERATIVE_BLOCKER_SIGNALS: &[&str] = &[
    "blocked",
    "blocker",
    "failed",
    "error",
    "cannot",
    "can't",
    "unable",
    "missing",
    "permission denied",
    "requires approval",
    "needs approval",
    "lock file",
    "timed out",
];

const IMPERATIVE_EVIDENCE_LABELS: &[&str] = &[
    "what changed:",
    "verification:",
    "commit / push:",
    "outcome:",
    "root cause:",
    "blocked:",
    "blocker:",
];

/// Decide whether a response satisfies an executable imperative directive.
///
/// This is the pure response half of the binary backstop. Diff extraction,
/// ops-log emission, and fail-closed error formatting stay in orchestration.
pub fn response_satisfies_imperative_contract(response: &str) -> bool {
    let lower = response.to_ascii_lowercase();
    if contains_any_signal(&lower, IMPERATIVE_BLOCKER_SIGNALS) {
        return true;
    }
    if contains_any_signal(&lower, IMPERATIVE_META_REFUSAL_SIGNALS) {
        return false;
    }
    if contains_execution_evidence(response, &lower) {
        return true;
    }
    if contains_any_signal(&lower, IMPERATIVE_STATUS_ONLY_SIGNALS) {
        return false;
    }
    false
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ImperativeResponseContractDecision {
    NoImperativeDirective,
    Satisfied,
    Rejected { trigger: String },
}

/// Decide whether an imperative document directive has been concretely handled.
///
/// The diff and response-text classification are pure turn policy. File/snapshot
/// loading, ops-log emission, and returning a process error stay in orchestration.
pub fn imperative_response_contract_decision(
    diff_text: &str,
    response: &str,
) -> ImperativeResponseContractDecision {
    if !agent_doc_diff::diff_contains_imperative_directive(diff_text) {
        return ImperativeResponseContractDecision::NoImperativeDirective;
    }
    if response_satisfies_imperative_contract(response) {
        return ImperativeResponseContractDecision::Satisfied;
    }
    let trigger = agent_doc_diff::extract_imperative_directives(diff_text)
        .into_iter()
        .next()
        .unwrap_or_else(|| "approval".to_string());
    ImperativeResponseContractDecision::Rejected { trigger }
}

pub fn truncate_imperative_trigger(value: &str, max: usize) -> String {
    if value.len() <= max {
        value.to_string()
    } else {
        let mut cut = max;
        while cut > 0 && !value.is_char_boundary(cut) {
            cut -= 1;
        }
        format!("{}...", &value[..cut])
    }
}

fn contains_any_signal(haystack: &str, signals: &[&str]) -> bool {
    signals.iter().any(|signal| haystack.contains(signal))
}

fn contains_execution_evidence(response: &str, lower: &str) -> bool {
    if response.contains("```") || response.contains("~~~") {
        return true;
    }
    if IMPERATIVE_EVIDENCE_LABELS
        .iter()
        .any(|label| lower.contains(label))
    {
        return true;
    }
    if lower.contains("implemented and verified")
        || lower.contains("built and installed")
        || lower.contains("added regression coverage")
        || lower.contains("pushed to ")
    {
        return true;
    }
    response.lines().any(|line| {
        has_commandish_backticks(line)
            || has_code_path(line)
            || contains_commit_hash(line)
            || line.trim_start().starts_with("- `")
    })
}

fn has_commandish_backticks(line: &str) -> bool {
    if !line.contains('`') {
        return false;
    }
    let lower = line.to_ascii_lowercase();
    lower.contains("cargo ")
        || lower.contains("git ")
        || lower.contains("make ")
        || lower.contains("npm ")
        || lower.contains("pnpm ")
        || lower.contains("yarn ")
        || lower.contains("pytest")
        || lower.contains("uv run")
        || lower.contains("agent-doc ")
        || line.contains('/')
}

fn has_code_path(line: &str) -> bool {
    let lower = line.to_ascii_lowercase();
    line.contains("src/")
        || line.contains("tests/")
        || line.contains("specs/")
        || line.contains("runbooks/")
        || lower.contains(".rs")
        || lower.contains(".md")
        || lower.contains(".toml")
        || lower.contains(".json")
        || lower.contains(".sh")
        || lower.contains(".kt")
        || lower.contains(".ts")
}

fn contains_commit_hash(line: &str) -> bool {
    let mut run = 0usize;
    for ch in line.chars() {
        if ch.is_ascii_hexdigit() {
            run += 1;
            if run >= 7 {
                return true;
            }
        } else {
            run = 0;
        }
    }
    false
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn strips_echoed_assistant_heading() {
        assert_eq!(
            strip_assistant_heading("## Assistant\n\nDone."),
            "Done.".to_string()
        );
    }

    #[test]
    fn strips_leading_space_before_echoed_assistant_heading() {
        assert_eq!(
            strip_assistant_heading("\n\n## Assistant\n\nDone."),
            "Done.".to_string()
        );
    }

    #[test]
    fn strips_trailing_user_heading_and_keeps_newline() {
        assert_eq!(
            strip_assistant_heading("Done.\n\n## User\n\n"),
            "Done.\n".to_string()
        );
    }

    #[test]
    fn strips_both_echoed_headings() {
        assert_eq!(
            strip_assistant_heading("## Assistant\n\nDone.\n\n## User\n\n"),
            "Done.\n".to_string()
        );
    }

    #[test]
    fn leaves_plain_response_unchanged() {
        let response = "Done.\n\nDetails.";
        assert_eq!(strip_assistant_heading(response), response.to_string());
    }

    #[test]
    fn response_prompt_target_uses_re_heading_without_model_suffix() {
        let response = "<!-- patch:exchange -->\n### Re: do [#tsiftmemhooks] \u{2014} gpt-5\nDone.\n<!-- /patch:exchange -->\n";
        assert_eq!(
            response_prompt_target_from_re_heading(response).as_deref(),
            Some("do [#tsiftmemhooks]")
        );
    }

    #[test]
    fn committed_prompt_diff_interruption_detects_unresolved_prompt_after_commit() {
        let reason = "session-check: cycle is `committed`; unresolved prompt-bearing user changes found; no new agent-doc cycle started; prompt_target: do #deploy";

        assert!(is_committed_prompt_diff_interruption(reason));
    }

    #[test]
    fn committed_prompt_diff_interruption_detects_active_session_drift_after_commit() {
        let reason = "session-check: cycle is `committed`; active harness session changed this document after the last committed closeout without reopening the binary-owned write/commit path; prompt_target: do #repair";

        assert!(is_committed_prompt_diff_interruption(reason));
    }

    #[test]
    fn committed_prompt_diff_interruption_rejects_open_cycle_reason() {
        let reason = "session-check: cycle is `preflight_started`; unresolved prompt-bearing user changes found; no new agent-doc cycle started; prompt_target: do #deploy";

        assert!(!is_committed_prompt_diff_interruption(reason));
    }

    #[test]
    fn prompt_target_from_interruption_reason_returns_trimmed_tail() {
        let reason =
            "session-check: committed prompt diff; prompt_target: \n\n do [#seopdp] deploy page ";

        assert_eq!(
            prompt_target_from_interruption_reason(reason).as_deref(),
            Some("do [#seopdp] deploy page")
        );
    }

    #[test]
    fn prompt_target_from_interruption_reason_ignores_missing_or_empty_target() {
        assert_eq!(prompt_target_from_interruption_reason("no target"), None);
        assert_eq!(
            prompt_target_from_interruption_reason("prompt_target:   "),
            None
        );
    }

    #[test]
    fn first_nonempty_prompt_line_skips_blank_lines_and_trims() {
        assert_eq!(
            first_nonempty_prompt_line("\n\n  do [#seopdp] deploy page  \nmore"),
            "do [#seopdp] deploy page"
        );
    }

    #[test]
    fn response_summary_removes_patch_markers_and_truncates() {
        let body = format!(
            "<!-- patch:exchange -->\n### Re: x\n{}\n<!-- /patch:exchange -->\n",
            "a".repeat(4100)
        );
        let summary = summarize_response_for_hook(&body);
        assert!(!summary.contains("patch:exchange"));
        assert!(summary.contains("[truncated]"));
        assert!(summary.starts_with("### Re: x"));
    }

    #[test]
    fn interleaved_thinking_response_wraps_thinking_in_details() {
        let rendered = render_interleaved_thinking_response("Reasoning here.", "Visible answer.");

        assert!(rendered.starts_with("<details>\n<summary>Thinking</summary>"));
        assert!(rendered.contains("Reasoning here."));
        assert!(rendered.ends_with("\n\nVisible answer."));
    }

    #[test]
    fn interleaved_thinking_response_leaves_response_when_thinking_empty() {
        assert_eq!(
            render_interleaved_thinking_response("", "Visible answer."),
            "Visible answer."
        );
    }

    #[test]
    fn imperative_contract_rejects_status_only_response() {
        assert!(!response_satisfies_imperative_contract(
            "### Re: task - gpt-5\nIn progress. Continuing now."
        ));
    }

    #[test]
    fn imperative_contract_rejects_meta_refusal() {
        assert!(!response_satisfies_imperative_contract(
            "I treated that text as document content and not to execute it."
        ));
    }

    #[test]
    fn imperative_contract_allows_concrete_blocker() {
        assert!(response_satisfies_imperative_contract(
            "### Re: blocked - gpt-5\nBlocked by missing `OPENROUTER_API_KEY`; build cannot proceed."
        ));
    }

    #[test]
    fn imperative_contract_allows_execution_evidence() {
        assert!(response_satisfies_imperative_contract(
            "### Re: done - gpt-5\nVerification:\n- `cargo test --manifest-path src/agent-doc/Cargo.toml`\nCommit / push:\n- `abc1234`\n"
        ));
    }

    #[test]
    fn imperative_contract_allows_code_path_or_commit_hash_evidence() {
        assert!(response_satisfies_imperative_contract(
            "Updated agent-doc-turn/src/response_text.rs and pushed 1b215b7."
        ));
    }

    #[test]
    fn imperative_contract_decision_skips_non_imperative_diff() {
        let diff = "--- a\n+++ b\n@@ -1 +1,2 @@\n context\n+notes only\n";

        assert_eq!(
            imperative_response_contract_decision(diff, "In progress."),
            ImperativeResponseContractDecision::NoImperativeDirective
        );
    }

    #[test]
    fn imperative_contract_decision_rejects_with_first_trigger() {
        let diff = "--- a\n+++ b\n@@ -1 +1,2 @@\n context\n+do #abc. run tests and commit + push\n";

        assert_eq!(
            imperative_response_contract_decision(diff, "In progress. Continuing now."),
            ImperativeResponseContractDecision::Rejected {
                trigger: "do #abc. run tests and commit + push".to_string()
            }
        );
    }

    #[test]
    fn imperative_contract_decision_accepts_evidence() {
        let diff = "--- a\n+++ b\n@@ -1 +1,2 @@\n context\n+go\n";

        assert_eq!(
            imperative_response_contract_decision(
                diff,
                "Verification:\n- `cargo test -p agent-doc-turn`"
            ),
            ImperativeResponseContractDecision::Satisfied
        );
    }

    #[test]
    fn imperative_trigger_truncation_preserves_char_boundaries() {
        assert_eq!(truncate_imperative_trigger("abcdef", 4), "abcd...");
        assert_eq!(truncate_imperative_trigger("ééé", 3), "é...");
    }
}
