//! Split raw search-box input into (cascade term, structured filters).
//!
//! The ranked search cascade has no boolean logic by design: everything
//! that isn't a recognized `key:value` filter joins the *term* — the single
//! phrase the cascade matches through its filename/full-text/fuzzy stages.
//! Recognized filters (`type:`, `modified:`, `path:`, `mime:`, `name:`)
//! become parameterized SQL fragments ANDed onto every cascade stage.
//!
//! Robustness rules for search-as-you-type:
//! - A lex error (e.g. a half-typed quote) degrades to "whole input is the
//!   term" — incremental typing must never surface an error.
//! - An *unrecognized* `key:value` (like `12:30`) is reassembled verbatim
//!   into the term.
//! - A recognized key whose value doesn't translate (bad date, unknown type
//!   name) is a real [`TranslateError`] — the caller shows it inline.
//! - `AND`/`OR`/parens are not operators here; the words pass through into
//!   the term, parens are dropped.

use super::ast::Op;
use super::lexer::{tokenize, Token};
use super::pattern::{RegexQuery, TermPart, TermPattern};
use super::translator::{build_filter, is_filter_key, TranslateError};

/// The cascade's parsed input: one term string plus composable filter SQL.
#[derive(Debug, Clone, Default)]
pub struct CascadeQuery {
    /// The ranked search phrase; may be empty when the input was
    /// filter-only or regex-only.
    pub term: String,
    /// `term` compiled for matching: literal, wildcard, or empty.
    pub pattern: TermPattern,
    /// A `regex:` filter, matched in Rust against name, path and content —
    /// never part of the SQL.
    pub regex: Option<RegexQuery>,
    /// Zero or more ` AND (...)` fragments with anonymous `?` placeholders
    /// over alias `f`; appended verbatim to every stage's WHERE clause.
    pub filter_sql: String,
    pub filter_params: Vec<rusqlite::types::Value>,
}

impl CascadeQuery {
    /// Nothing to rank on: no term pattern and no regex. (Filters alone
    /// don't drive a search.)
    pub fn is_empty(&self) -> bool {
        self.pattern.is_empty() && self.regex.is_none()
    }

    fn term_only(term: &str) -> CascadeQuery {
        let term = term.trim().to_string();
        // Un-lexable input is searched verbatim — stars are not wildcards
        // here, mirroring the "whole input is the term" degrade rule.
        let pattern = TermPattern::build(&[TermPart {
            text: term.clone(),
            glob: false,
        }])
        .expect("literal patterns always compile");
        CascadeQuery {
            term,
            pattern,
            ..CascadeQuery::default()
        }
    }
}

fn op_str(op: Op) -> &'static str {
    match op {
        Op::Contains => ":",
        Op::Eq => "=",
        Op::Lt => "<",
        Op::Le => "<=",
        Op::Gt => ">",
        Op::Ge => ">=",
    }
}

pub fn split_for_cascade(input: &str) -> Result<CascadeQuery, TranslateError> {
    // NUL bytes can't occur in filenames or extracted text, but they do
    // break SQLite text binding and the FTS5 query parser — strip them.
    let input = input.replace('\0', "");
    let input = input.as_str();
    let tokens = match tokenize(input) {
        Ok(t) => t,
        // Half-typed input (unterminated quote, invalid word): the whole
        // raw string is the term. Never an error mid-keystroke.
        Err(_) => return Ok(CascadeQuery::term_only(input)),
    };

    let mut out = CascadeQuery::default();
    let mut term_parts: Vec<TermPart> = Vec::new();
    let mut i = 0usize;
    // Only plain unquoted words are wildcard-eligible; everything else
    // (quoted phrases, demoted AND/OR, reassembled key:value glue) is
    // searched verbatim.
    let literal = |text: &str| TermPart {
        text: text.to_string(),
        glob: false,
    };

    while i < tokens.len() {
        match &tokens[i] {
            Token::Word(word) => {
                // Candidate filter: Word(key) Op [Op] (Word|Quoted).
                // The lexer emits `modified:>=x` as Word Op(:) Op(>=) Word.
                if let Some(Token::Op(op1)) = tokens.get(i + 1) {
                    let (op, value_idx) = match tokens.get(i + 2) {
                        Some(Token::Op(op2)) => (*op2, i + 3),
                        _ => (*op1, i + 2),
                    };
                    let value = match tokens.get(value_idx) {
                        Some(Token::Word(v)) | Some(Token::Quoted(v)) => Some(v.clone()),
                        _ => None,
                    };
                    if let Some(value) = value {
                        // Quoted values keep `*` literal; only a bare word's
                        // stars act as wildcards (`name:` honors this too).
                        let value_is_word = matches!(tokens.get(value_idx), Some(Token::Word(_)));
                        if word.eq_ignore_ascii_case("regex") {
                            // Not a SQL filter: compiled here, matched in
                            // Rust against name, path and content.
                            if op != Op::Contains {
                                return Err(TranslateError::UnsupportedOp {
                                    key: word.clone(),
                                    op,
                                });
                            }
                            if out.regex.is_some() {
                                return Err(TranslateError::BadRegex(
                                    "only one regex: per query".into(),
                                ));
                            }
                            out.regex = Some(RegexQuery::new(&value)?);
                            i = value_idx + 1;
                            continue;
                        }
                        if is_filter_key(word) {
                            let frag = build_filter(word, op, &value, value_is_word)?;
                            out.filter_sql.push_str(" AND (");
                            out.filter_sql.push_str(&frag.sql);
                            out.filter_sql.push(')');
                            out.filter_params.extend(frag.params);
                            i = value_idx + 1;
                            continue;
                        }
                        // Unrecognized key — reassemble verbatim (`12:30`,
                        // `foo:bar`), gluing any further `:value` chains
                        // (`foo:bar:baz`).
                        let mut glued = format!("{}{}{}", word, op_str(op), value);
                        i = value_idx + 1;
                        while let Some(Token::Op(next_op)) = tokens.get(i) {
                            glued.push_str(op_str(*next_op));
                            i += 1;
                            if let Some(Token::Word(v)) | Some(Token::Quoted(v)) = tokens.get(i) {
                                glued.push_str(v);
                                i += 1;
                            }
                        }
                        term_parts.push(literal(&glued));
                        continue;
                    }
                    // Key + op with no value yet (mid-typing "type:"):
                    // pass through as literal text.
                    term_parts.push(literal(&format!("{}{}", word, op_str(*op1))));
                    i += 2;
                    continue;
                }
                term_parts.push(TermPart {
                    text: word.clone(),
                    glob: word.contains('*'),
                });
            }
            Token::Quoted(q) => term_parts.push(literal(q)),
            // Not operators in the cascade grammar — plain words.
            Token::And => term_parts.push(literal("AND")),
            Token::Or => term_parts.push(literal("OR")),
            // Grouping has no meaning without boolean logic.
            Token::LParen | Token::RParen => {}
            // Dangling operator (e.g. "a > b" typed literally).
            Token::Op(op) => term_parts.push(literal(op_str(*op))),
        }
        i += 1;
    }

    out.term = term_parts
        .iter()
        .map(|p| p.text.as_str())
        .collect::<Vec<_>>()
        .join(" ");
    out.pattern = TermPattern::build(&term_parts)?;
    Ok(out)
}

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

    #[test]
    fn plain_words_join_in_order() {
        let q = split_for_cascade("hello brave world").unwrap();
        assert_eq!(q.term, "hello brave world");
        assert!(q.filter_sql.is_empty());
        assert!(q.filter_params.is_empty());
    }

    #[test]
    fn empty_input() {
        let q = split_for_cascade("").unwrap();
        assert_eq!(q.term, "");
        assert!(q.filter_sql.is_empty());
    }

    #[test]
    fn each_recognized_filter_key_extracts() {
        for input in [
            "type:Audio",
            "modified:>=2024-01-01",
            "mtime:<2023-12-01",
            "path:/home/me",
            "folder:/home/me",
            "includefolder:/home/me",
            "name:report",
            "filename:report",
            "mime:application/pdf",
        ] {
            let q = split_for_cascade(input).unwrap();
            assert_eq!(q.term, "", "input {:?} should be pure filter", input);
            assert!(
                q.filter_sql.starts_with(" AND ("),
                "input {:?} → {:?}",
                input,
                q.filter_sql
            );
            assert!(!q.filter_params.is_empty(), "input {:?}", input);
        }
    }

    #[test]
    fn filters_and_term_mix() {
        let q = split_for_cascade("type:Document budget report modified:>=2024-01-01").unwrap();
        assert_eq!(q.term, "budget report");
        assert_eq!(q.filter_sql.matches(" AND (").count(), 2);
        assert_eq!(q.filter_params.len(), 2); // type bits + mtime bound
    }

    #[test]
    fn unknown_key_stays_literal() {
        let q = split_for_cascade("meeting 12:30 notes").unwrap();
        assert_eq!(q.term, "meeting 12:30 notes");
        assert!(q.filter_sql.is_empty());
    }

    #[test]
    fn unknown_key_chain_reassembles() {
        let q = split_for_cascade("foo:bar:baz").unwrap();
        assert_eq!(q.term, "foo:bar:baz");
    }

    #[test]
    fn half_typed_quote_is_whole_term() {
        let q = split_for_cascade("\"unclosed phrase").unwrap();
        assert_eq!(q.term, "\"unclosed phrase");
        assert!(q.filter_sql.is_empty());
    }

    #[test]
    fn half_typed_filter_key_is_literal() {
        let q = split_for_cascade("type:").unwrap();
        assert_eq!(q.term, "type:");
        assert!(q.filter_sql.is_empty());
    }

    #[test]
    fn recognized_key_bad_value_errors() {
        assert!(matches!(
            split_for_cascade("modified:>=not-a-date"),
            Err(TranslateError::BadDate(_))
        ));
        assert!(matches!(
            split_for_cascade("type:NotAThing"),
            Err(TranslateError::UnknownProperty(_))
        ));
    }

    #[test]
    fn and_or_parens_are_plain_text() {
        let q = split_for_cascade("(alpha AND beta) OR gamma").unwrap();
        assert_eq!(q.term, "alpha AND beta OR gamma");
    }

    #[test]
    fn a_windows_drive_path_reaches_the_filter_intact() {
        let q = split_for_cascade(r"path:C:\Users\me\docs").unwrap();
        assert_eq!(q.term, "", "the whole input is a filter");
        // The folder filter binds one value: the subtree LIKE pattern built
        // from the spelling the user typed. Asserted through the builder so
        // this stays about the plumbing rather than about escaping rules.
        assert_eq!(
            q.filter_params[0],
            Value::Text(crate::query::translator::like_subtree_pattern(
                r"C:\Users\me\docs"
            )),
            "{:?}",
            q.filter_params
        );
    }

    #[test]
    fn quoted_value_for_filter() {
        let q = split_for_cascade("path:\"/home/me/My Documents\"").unwrap();
        assert_eq!(q.term, "");
        assert_eq!(
            q.filter_params[0],
            Value::Text(crate::query::translator::like_subtree_pattern(
                "/home/me/My Documents"
            ))
        );
    }

    #[test]
    fn quoted_phrase_joins_term() {
        let q = split_for_cascade("\"exact phrase\" extra").unwrap();
        assert_eq!(q.term, "exact phrase extra");
    }

    #[test]
    fn injection_shapes_stay_bound() {
        // Everything lands either in the term (never interpolated into
        // SQL by the cascade — bound as parameters there too) or in
        // filter_params. filter_sql must never contain user text.
        let q = split_for_cascade("mime:application/x-foo'; DROP TABLE files; --").unwrap();
        assert!(!q.filter_sql.contains("DROP"), "{}", q.filter_sql);
        // The value went into params (term got the trailing junk words).
        assert!(matches!(&q.filter_params[0], Value::Text(t) if t.contains("x-foo'")));

        let q = split_for_cascade("name:%_\\").unwrap();
        // LIKE-escaped inside the bound param, not the SQL.
        assert!(matches!(&q.filter_params[0], Value::Text(t) if t == "%\\%\\_\\\\%"));
    }

    #[test]
    fn unquoted_star_builds_a_wildcard_pattern() {
        let q = split_for_cascade("foo*").unwrap();
        assert_eq!(q.term, "foo*");
        assert!(q.pattern.is_wildcard());
        assert!(q.pattern.whole_match("foobar", false));
    }

    #[test]
    fn quoted_star_stays_literal() {
        let q = split_for_cascade("\"foo*\"").unwrap();
        assert_eq!(q.pattern.literal(), Some("foo*"));
    }

    #[test]
    fn bare_star_matches_nothing() {
        for input in ["*", "**", "* *"] {
            let q = split_for_cascade(input).unwrap();
            assert!(
                q.pattern.find_first("anything", true).is_none(),
                "{:?}",
                input
            );
        }
        // "* *" has an interior literal space; plain stars are Empty.
        assert!(split_for_cascade("*").unwrap().pattern.is_empty());
        assert!(split_for_cascade("*").unwrap().is_empty());
    }

    #[test]
    fn glued_unknown_keys_keep_stars_literal() {
        let q = split_for_cascade("foo:ba*r").unwrap();
        assert_eq!(q.pattern.literal(), Some("foo:ba*r"));
    }

    #[test]
    fn lex_error_degrade_keeps_stars_literal() {
        let q = split_for_cascade("re*port \"unclosed").unwrap();
        assert_eq!(q.pattern.literal(), Some("re*port \"unclosed"));
    }

    #[test]
    fn regex_keyword_compiles_out_of_band() {
        let q = split_for_cascade("regex:foo\\d+").unwrap();
        assert_eq!(q.term, "");
        assert!(q.pattern.is_empty());
        assert!(!q.is_empty(), "a regex-only query still searches");
        let re = q.regex.unwrap();
        assert!(re.is_match("FOO12"));
        assert!(q.filter_sql.is_empty(), "regex is not a SQL filter");
    }

    #[test]
    fn regex_value_may_be_quoted_and_key_is_case_insensitive() {
        let q = split_for_cascade("REGEX:\"foo (bar|baz)\"").unwrap();
        assert!(q.regex.unwrap().is_match("foo bar"));
    }

    #[test]
    fn regex_mixes_with_filters_and_term() {
        let q = split_for_cascade("regex:\\d+ type:Text budget").unwrap();
        assert_eq!(q.term, "budget");
        assert!(q.regex.is_some());
        assert_eq!(q.filter_sql.matches(" AND (").count(), 1);
    }

    #[test]
    fn regex_error_shapes() {
        assert!(matches!(
            split_for_cascade("regex:["),
            Err(TranslateError::BadRegex(_))
        ));
        assert!(matches!(
            split_for_cascade("regex=x"),
            Err(TranslateError::UnsupportedOp { .. })
        ));
        assert!(matches!(
            split_for_cascade("regex:a regex:b"),
            Err(TranslateError::BadRegex(_))
        ));
        // Empty-matchable patterns are rejected loudly.
        assert!(matches!(
            split_for_cascade("regex:.*"),
            Err(TranslateError::BadRegex(_))
        ));
    }

    #[test]
    fn dangling_regex_key_is_literal_text() {
        let q = split_for_cascade("regex:").unwrap();
        assert_eq!(q.term, "regex:");
        assert!(q.regex.is_none());
    }

    #[test]
    fn name_filter_star_becomes_like_wildcard() {
        let q = split_for_cascade("name:foo*bar").unwrap();
        assert!(matches!(&q.filter_params[0], Value::Text(t) if t == "%foo%bar%"));

        // Quoted value: star stays a literal character.
        let q = split_for_cascade("name:\"fo*o\"").unwrap();
        assert!(matches!(&q.filter_params[0], Value::Text(t) if t == "%fo*o%"));

        // User LIKE metacharacters stay escaped even in glob values.
        let q = split_for_cascade("name:%*_").unwrap();
        assert!(matches!(&q.filter_params[0], Value::Text(t) if t == "%\\%%\\_%"));

        // path: values never glob — the star reaches the pattern as a literal.
        let q = split_for_cascade("path:/da*ta").unwrap();
        assert_eq!(
            q.filter_params[0],
            Value::Text(crate::query::translator::like_subtree_pattern("/da*ta"))
        );
    }

    #[test]
    fn nul_bytes_stripped_and_long_terms_pass_through() {
        // NULs would break SQLite binding / FTS5 parsing downstream.
        let q = split_for_cascade("abc\0def").unwrap();
        assert_eq!(q.term, "abcdef");

        let long = "x".repeat(10_240);
        let q = split_for_cascade(&long).unwrap();
        assert_eq!(q.term.len(), 10_240);
    }
}
