#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))]

//! Composer submit parser — owns `/image` slash grammar and the
//! leading/trailing path-token auto-detect rule for drag-pasted
//! filenames. See spec §5.1 / §5.2 in
//! `docs/superpowers/specs/2026-05-20-capo-v0.6-design.md`.

use std::path::{Path, PathBuf};

/// Outcome of parsing the composer buffer at submit time (Enter pressed).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParsedSubmit {
    /// `/image <path> [<path>...] [<text...>]` parsed successfully.
    /// `attachments` has at least one element.
    SlashImage {
        attachments: Vec<PathBuf>,
        text: String,
    },

    /// `/image ...` with zero path-shaped tokens consumed. Composer must
    /// surface a "must include at least one path" error and retain the
    /// buffer.
    SlashImageMalformed,

    /// Buffer starts with `/` but not `/image ` (e.g. `/help`, `/compact`).
    /// Caller treats this as the existing slash-command path — no
    /// auto-detect, no attachments extracted.
    OtherSlashCommand,

    /// Plain text. `attachments` may be empty (pure text) or non-empty if
    /// leading and/or trailing path-shaped tokens that point to existing
    /// files were found.
    Plain {
        attachments: Vec<PathBuf>,
        text: String,
    },
}

/// Return `true` if `token` matches the path-shape regex:
///   `^[\w./~\-]+\.(?i)(png|jpe?g|gif|webp)$`
///
/// Implemented as a hand-rolled check (no `regex` dep): split on the
/// last `.`, verify the suffix is in the supported set (case-insensitive),
/// then verify every byte in the prefix is `[A-Za-z0-9_./~\-]`.
pub fn is_path_shaped(token: &str) -> bool {
    let (stem, ext) = match token.rsplit_once('.') {
        Some(pair) => pair,
        None => return false,
    };
    if stem.is_empty() {
        return false;
    }
    let ext_lower = ext.to_ascii_lowercase();
    let ext_ok = matches!(ext_lower.as_str(), "png" | "jpg" | "jpeg" | "gif" | "webp");
    if !ext_ok {
        return false;
    }
    stem.bytes()
        .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'/' | b'~' | b'-'))
}

/// Pure version of `parse_composer_submit` — caller provides the
/// existence-check closure so tests can stub the filesystem.
pub fn tokenize_submit_with(buf: &str, exists: impl Fn(&Path) -> bool) -> ParsedSubmit {
    let trimmed = buf.trim_start();
    if trimmed.is_empty() {
        return ParsedSubmit::Plain {
            attachments: Vec::new(),
            text: String::new(),
        };
    }

    // `/image ` (note the trailing space) is the slash trigger. Bare `/image`
    // with no space falls through to OtherSlashCommand.
    if let Some(rest) = trimmed.strip_prefix("/image ") {
        return parse_slash_image(rest);
    }

    if trimmed.starts_with('/') {
        let first_token = trimmed.split_whitespace().next().unwrap_or_default();
        let first_path = PathBuf::from(first_token);
        if !(is_path_shaped(first_token) && exists(&first_path)) {
            return ParsedSubmit::OtherSlashCommand;
        }
    }

    parse_plain(trimmed, exists)
}

/// Convenience: live filesystem.
pub fn parse_composer_submit(buf: &str) -> ParsedSubmit {
    tokenize_submit_with(buf, |p| p.is_file())
}

fn parse_slash_image(rest: &str) -> ParsedSubmit {
    let tokens: Vec<&str> = rest.split_whitespace().collect();
    let mut attachments: Vec<PathBuf> = Vec::new();
    let mut idx = 0;
    while idx < tokens.len() && is_path_shaped(tokens[idx]) {
        attachments.push(PathBuf::from(tokens[idx]));
        idx += 1;
    }
    if attachments.is_empty() {
        return ParsedSubmit::SlashImageMalformed;
    }
    let text = tokens[idx..].join(" ");
    ParsedSubmit::SlashImage { attachments, text }
}

fn parse_plain(buf: &str, exists: impl Fn(&Path) -> bool) -> ParsedSubmit {
    let tokens: Vec<&str> = buf.split_whitespace().collect();
    if tokens.is_empty() {
        return ParsedSubmit::Plain {
            attachments: Vec::new(),
            text: String::new(),
        };
    }

    let mut consumed_leading = 0usize;
    let mut leading: Vec<PathBuf> = Vec::new();
    while consumed_leading < tokens.len() {
        let tok = tokens[consumed_leading];
        let p = PathBuf::from(tok);
        if is_path_shaped(tok) && exists(&p) {
            leading.push(p);
            consumed_leading += 1;
        } else {
            break;
        }
    }

    let mut consumed_trailing = 0usize;
    let mut trailing: Vec<PathBuf> = Vec::new();
    // Trailing scan backward, but never overlap the leading region.
    while consumed_leading + consumed_trailing < tokens.len() {
        let i = tokens.len() - 1 - consumed_trailing;
        let tok = tokens[i];
        let p = PathBuf::from(tok);
        if is_path_shaped(tok) && exists(&p) {
            trailing.push(p);
            consumed_trailing += 1;
        } else {
            break;
        }
    }
    trailing.reverse();

    let mut attachments = leading;
    attachments.extend(trailing);

    let middle = &tokens[consumed_leading..tokens.len() - consumed_trailing];
    let text = middle.join(" ");

    ParsedSubmit::Plain { attachments, text }
}

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

    use std::io::Write;

    fn tempfile_with_extension(ext: &str) -> PathBuf {
        let mut path = std::env::temp_dir();
        path.push(format!(
            "capo-v06-phase-c-{}-{}.{ext}",
            std::process::id(),
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .expect("unix epoch")
                .as_nanos(),
        ));
        std::fs::File::create(&path)
            .expect("create tempfile")
            .write_all(&[0x89, 0x50, 0x4E, 0x47])
            .expect("write tempfile");
        path
    }

    #[test]
    fn parse_composer_submit_extracts_real_leading_path() {
        let path = tempfile_with_extension("png");
        let buf = format!("{} describe this", path.to_string_lossy());
        let out = parse_composer_submit(&buf);
        let ParsedSubmit::Plain { attachments, text } = out else {
            panic!("expected Plain; got {out:?}");
        };
        assert_eq!(attachments, vec![path]);
        assert_eq!(text, "describe this");
    }

    /// Stub: all named files exist. Use for /image tests and the
    /// "everything exists" auto-detect tests.
    fn fs_all_exist(_: &Path) -> bool {
        true
    }

    /// Stub: no files exist. Use for auto-detect tests where existence
    /// must demote tokens to plain text.
    fn fs_none_exist(_: &Path) -> bool {
        false
    }

    #[test]
    fn slash_image_one_path() {
        let out = tokenize_submit_with("/image foo.png", fs_all_exist);
        assert_eq!(
            out,
            ParsedSubmit::SlashImage {
                attachments: vec![PathBuf::from("foo.png")],
                text: String::new(),
            }
        );
    }

    #[test]
    fn slash_image_multiple_paths_no_text() {
        let out = tokenize_submit_with("/image foo.png bar.jpg", fs_all_exist);
        assert_eq!(
            out,
            ParsedSubmit::SlashImage {
                attachments: vec![PathBuf::from("foo.png"), PathBuf::from("bar.jpg")],
                text: String::new(),
            }
        );
    }

    #[test]
    fn slash_image_one_path_then_text() {
        let out = tokenize_submit_with("/image foo.png check this", fs_all_exist);
        assert_eq!(
            out,
            ParsedSubmit::SlashImage {
                attachments: vec![PathBuf::from("foo.png")],
                text: "check this".to_string(),
            }
        );
    }

    #[test]
    fn slash_image_multiple_paths_then_text() {
        let out = tokenize_submit_with("/image foo.png bar.jpg what do you see", fs_all_exist);
        assert_eq!(
            out,
            ParsedSubmit::SlashImage {
                attachments: vec![PathBuf::from("foo.png"), PathBuf::from("bar.jpg")],
                text: "what do you see".to_string(),
            }
        );
    }

    #[test]
    fn slash_image_zero_paths_is_malformed() {
        let out = tokenize_submit_with("/image hello world", fs_all_exist);
        assert_eq!(out, ParsedSubmit::SlashImageMalformed);
    }

    #[test]
    fn slash_image_with_only_unshaped_first_token_is_malformed() {
        let out = tokenize_submit_with("/image not-a-path describe it", fs_all_exist);
        assert_eq!(out, ParsedSubmit::SlashImageMalformed);
    }

    #[test]
    fn other_slash_command_is_not_image() {
        let out = tokenize_submit_with("/compact some.png", fs_all_exist);
        assert_eq!(out, ParsedSubmit::OtherSlashCommand);
        let out = tokenize_submit_with("/help", fs_all_exist);
        assert_eq!(out, ParsedSubmit::OtherSlashCommand);
    }

    /// Stub: only specific files exist. Use for the mixed cases.
    fn fs_only<'a>(allowed: &'a [&'static str]) -> impl Fn(&Path) -> bool + 'a {
        move |p: &Path| {
            let s = p.to_string_lossy();
            allowed.iter().any(|a| *a == s)
        }
    }

    #[test]
    fn plain_text_no_paths() {
        let out = tokenize_submit_with("hello world", fs_all_exist);
        assert_eq!(
            out,
            ParsedSubmit::Plain {
                attachments: Vec::new(),
                text: "hello world".to_string(),
            }
        );
    }

    #[test]
    fn plain_leading_path_then_text() {
        let out = tokenize_submit_with("foo.png hello world", fs_only(&["foo.png"]));
        assert_eq!(
            out,
            ParsedSubmit::Plain {
                attachments: vec![PathBuf::from("foo.png")],
                text: "hello world".to_string(),
            }
        );
    }

    #[test]
    fn plain_trailing_path_with_text() {
        let out = tokenize_submit_with("hello world foo.png", fs_only(&["foo.png"]));
        assert_eq!(
            out,
            ParsedSubmit::Plain {
                attachments: vec![PathBuf::from("foo.png")],
                text: "hello world".to_string(),
            }
        );
    }

    #[test]
    fn plain_leading_and_trailing_paths_with_middle_text() {
        let out = tokenize_submit_with("a.png hello b.png", fs_only(&["a.png", "b.png"]));
        assert_eq!(
            out,
            ParsedSubmit::Plain {
                attachments: vec![PathBuf::from("a.png"), PathBuf::from("b.png")],
                text: "hello".to_string(),
            }
        );
    }

    #[test]
    fn plain_middle_path_token_stays_text() {
        // The token "foo.png" is in the middle; auto-detect does NOT
        // pull it out as an attachment.
        let out = tokenize_submit_with("look at foo.png closely", fs_only(&["foo.png"]));
        assert_eq!(
            out,
            ParsedSubmit::Plain {
                attachments: Vec::new(),
                text: "look at foo.png closely".to_string(),
            }
        );
    }

    #[test]
    fn plain_path_shaped_but_file_missing_becomes_text() {
        let out = tokenize_submit_with("missing.png hello", fs_none_exist);
        assert_eq!(
            out,
            ParsedSubmit::Plain {
                attachments: Vec::new(),
                text: "missing.png hello".to_string(),
            }
        );
    }

    #[test]
    fn plain_only_a_single_path_token_yields_empty_text() {
        let out = tokenize_submit_with("foo.png", fs_only(&["foo.png"]));
        assert_eq!(
            out,
            ParsedSubmit::Plain {
                attachments: vec![PathBuf::from("foo.png")],
                text: String::new(),
            }
        );
    }

    #[test]
    fn plain_two_path_tokens_back_to_back_both_extracted() {
        // Both leading; trailing scan doesn't re-consume.
        let out = tokenize_submit_with("a.png b.png hello", fs_only(&["a.png", "b.png"]));
        assert_eq!(
            out,
            ParsedSubmit::Plain {
                attachments: vec![PathBuf::from("a.png"), PathBuf::from("b.png")],
                text: "hello".to_string(),
            }
        );
    }

    #[test]
    fn path_shape_regex_accepts_lowercase_and_mixed_case_extensions() {
        assert!(is_path_shaped("foo.PNG"));
        assert!(is_path_shaped("foo.Jpeg"));
        assert!(is_path_shaped("foo.webp"));
        assert!(is_path_shaped("foo.gif"));
        assert!(!is_path_shaped("foo.txt"));
        assert!(!is_path_shaped("foo"));
        assert!(!is_path_shaped(".png")); // empty stem
    }

    #[test]
    fn bare_slash_image_with_no_space_is_other_slash() {
        // `/image` with no trailing space falls to OtherSlashCommand —
        // the user might be typing `/imageviewer` or similar; we only
        // hijack on the precise `/image ` prefix.
        let out = tokenize_submit_with("/image", fs_all_exist);
        assert_eq!(out, ParsedSubmit::OtherSlashCommand);
    }
}
