//! Tickets: the rules a ticket and its labels live by, and the store that
//! keeps their rows.
//!
//! The rules sit here rather than in the handlers because everything that
//! speaks about a ticket has to agree on them: what a summary must be, what a
//! label may be called, who may change a ticket, and what a search box means
//! once it reaches FTS5.

pub mod store;

pub use crate::entities::tickets::State;

/// The labels every repository starts with.
///
/// These are the kinds of work a ticket is usually about; a repository whose
/// owner wants more makes them from the settings page.
pub const DEFAULT_LABELS: [&str; 2] = ["bug", "improvement"];

/// The state a ticket starts in.
///
/// A ticket with no state at all would be missing from the default list.
pub const DEFAULT_STATE: &str = "open";

/// The state the query string asks for, or [`State::Open`] when it
/// holds nothing. Returns `None` when the parameter is explicitly empty,
/// meaning "show all states".
pub fn parse_state(requested: Option<&str>) -> Option<State> {
    match requested {
        None => Some(State::Open), // Default to open when parameter is absent
        Some(s) => match s.trim() {
            "" => None, // Empty parameter means "show all"
            "closed" => Some(State::Closed),
            _ => Some(State::Open),
        },
    }
}

/// The query-string value for a state: what the address holds.
pub fn state_string(state: State) -> String {
    match state {
        State::Open => "open".to_string(),
        State::Closed => "closed".to_string(),
    }
}

/// How long a summary may be, which is the width of the column it is kept
/// in: a longer one is refused here rather than by SQLite in words of its
/// own.
pub const MAX_SUMMARY_LEN: usize = 255;

/// How long a label's name may be, for the same reason in a narrower column.
pub const MAX_LABEL_NAME_LEN: usize = 64;

/// The default maximum size of an attachment in bytes (2 MB).
pub const DEFAULT_MAX_ATTACHMENT_SIZE: usize = 2 * 1024 * 1024;

/// The default maximum number of attachments per ticket.
pub const DEFAULT_MAX_ATTACHMENTS_PER_TICKET: usize = 4;

/// Environment variable for the maximum attachment size in bytes.
const MAX_ATTACHMENT_SIZE_VAR: &str = "FROGUE_MAX_ATTACHMENT_SIZE";

/// Environment variable for the maximum number of attachments per ticket.
const MAX_ATTACHMENTS_PER_TICKET_VAR: &str = "FROGUE_MAX_ATTACHMENTS_PER_TICKET";

/// Returns the maximum attachment size in bytes, read from the environment or
/// the default.
pub fn max_attachment_size() -> usize {
    std::env::var(MAX_ATTACHMENT_SIZE_VAR)
        .ok()
        .and_then(|v| v.parse().ok())
        .unwrap_or(DEFAULT_MAX_ATTACHMENT_SIZE)
}

/// Returns the maximum number of attachments per ticket, read from the
/// environment or the default.
pub fn max_attachments_per_ticket() -> usize {
    std::env::var(MAX_ATTACHMENTS_PER_TICKET_VAR)
        .ok()
        .and_then(|v| v.parse().ok())
        .unwrap_or(DEFAULT_MAX_ATTACHMENTS_PER_TICKET)
}

/// The summary a ticket may be saved with: the text with the space around it
/// taken off, or the reason it cannot be one.
///
/// The summary is the one thing a ticket cannot do without - it is what a
/// list is scanned for - so it is asked for in words of ours rather than
/// left to a `NOT NULL` to say no to.
pub fn checked_summary(summary: &str) -> Result<&str, String> {
    let summary = summary.trim();

    if summary.is_empty() {
        return Err("a ticket needs a summary".to_string());
    }
    if summary.chars().count() > MAX_SUMMARY_LEN {
        return Err(format!(
            "a summary may be at most {MAX_SUMMARY_LEN} characters long"
        ));
    }

    Ok(summary)
}

/// The name a label may be made with: the text with the space around it
/// taken off, or the reason it cannot be one.
///
/// A comma is refused rather than escaped: it is what the labels of one
/// filter are joined by in the address, so a label holding one could not be
/// told apart from two labels.
pub fn checked_label_name(name: &str) -> Result<&str, String> {
    let name = name.trim();

    if name.is_empty() {
        return Err("a label needs a name".to_string());
    }
    if name.contains(',') {
        return Err(
            "a label's name may not hold a comma: that is what the labels of \
             one filter are joined by"
                .to_string(),
        );
    }
    if name.chars().count() > MAX_LABEL_NAME_LEN {
        return Err(format!(
            "a label's name may be at most {MAX_LABEL_NAME_LEN} characters long"
        ));
    }

    Ok(name)
}

/// Whether `viewer` may edit or delete a ticket opened by `creator`.
///
/// Anybody who may read a repository may open a ticket on it - complaints
/// are usually somebody else's - but changing one is another matter: the
/// person who wrote it, and the person whose repository it is, and nobody
/// else. If the ticket was created by a remote actor (creator is None), only
/// the repository owner may change it via the WebUI.
pub fn may_change(repository_owner: i64, creator: Option<i64>, viewer: i64) -> bool {
    creator.map_or(false, |c| viewer == c) || viewer == repository_owner
}

/// The labels the list shows: the ones `requested` names - which is what the
/// address holds - or nothing when the address holds no filter at all.
///
/// An empty list shows everything.
pub fn selected_labels(requested: Option<&str>) -> Vec<String> {
    match requested {
        Some(raw) => filter(raw),
        None => Vec::new(),
    }
}

/// The label names a filter parameter holds, joined by commas.
///
/// Space around each name is dropped, because that is what writing a label's
/// name in an address tends to leave behind, and the empty names a comma
/// with nothing after it leaves are not labels at all.
pub fn filter(raw: &str) -> Vec<String> {
    raw.split(',')
        .map(str::trim)
        .filter(|name| !name.is_empty())
        .map(str::to_string)
        .collect()
}

/// The FTS5 expression for `query`, or `None` when it holds nothing to
/// search for.
///
/// A search box is somebody's typing rather than a piece of FTS5 syntax, so
/// only its runs of letters and digits are taken as words - the rest of what
/// was typed may be a stray quote, a `*`, or `NEAR/(` for all it matters.
/// Each word is quoted, which is what stops the engine from reading it as
/// its own syntax whether it spells `AND` or `NEAR`, and followed by `*` so
/// that `sauce` also finds `sauces` and `cran` also finds `cranberry`.
///
/// Nothing worth searching for searches for nothing: an empty list, not a
/// fault - the engine has opinions about an empty expression, and they are
/// all errors.
pub fn search_expression(query: &str) -> Option<String> {
    let mut words: Vec<String> = Vec::new();
    let mut word = String::new();
    for character in query.chars() {
        if character.is_alphanumeric() {
            word.push(character);
        } else if !word.is_empty() {
            words.push(std::mem::take(&mut word));
        }
    }
    if !word.is_empty() {
        words.push(word);
    }

    if words.is_empty() {
        return None;
    }

    Some(
        words
            .iter()
            .map(|word| format!("\"{word}\"*"))
            .collect::<Vec<_>>()
            .join(" AND "),
    )
}

/// Percent-encodes `value` for a query string, keeping the characters that
/// may stand for themselves.
///
/// A label's name is written into the address of its filter, and a name with
/// a space or an `&` in it would otherwise be two things by the time it came
/// back.
pub fn encode(value: &str) -> String {
    let mut encoded = String::with_capacity(value.len());
    for byte in value.bytes() {
        match byte {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
                encoded.push(byte as char);
            }
            b' ' => encoded.push_str("%20"),
            _ => encoded.push_str(&format!("%{byte:02X}")),
        }
    }
    encoded
}

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

    #[test]
    fn a_summary_is_the_text_with_the_space_around_it_gone() {
        assert_eq!(
            checked_summary("  Cranberry sauce \n"),
            Ok("Cranberry sauce")
        );
    }

    #[test]
    fn a_summary_of_nothing_is_refused_in_words_of_ours() {
        assert_eq!(
            checked_summary(""),
            Err("a ticket needs a summary".to_string())
        );
        assert_eq!(
            checked_summary("   "),
            Err("a ticket needs a summary".to_string())
        );
        assert_eq!(
            checked_summary("\n\t"),
            Err("a ticket needs a summary".to_string())
        );
    }

    #[test]
    fn a_summary_longer_than_the_column_holds_says_how_long_is_too_long() {
        let long = "x".repeat(MAX_SUMMARY_LEN + 1);

        assert_eq!(
            checked_summary(&long).unwrap_err(),
            "a summary may be at most 255 characters long"
        );
        // The width is counted in characters rather than bytes, so a summary
        // of accents fills the column the same way the column counts it.
        assert!(checked_summary(&"é".repeat(MAX_SUMMARY_LEN)).is_ok());
    }

    #[test]
    fn a_label_name_is_refused_for_being_empty_or_holding_a_comma() {
        assert_eq!(
            checked_label_name("  "),
            Err("a label needs a name".to_string())
        );
        assert!(checked_label_name("one,two").unwrap_err().contains("comma"));
        // A comma anywhere is one comma too many, not only at the ends.
        assert!(checked_label_name(",urgent").unwrap_err().contains("comma"));
        assert_eq!(checked_label_name(" help wanted "), Ok("help wanted"));
    }

    #[test]
    fn a_ticket_is_changed_by_the_one_who_opened_it_or_the_owner_of_the_repository() {
        assert!(may_change(7, Some(3), 3), "the creator may change it");
        assert!(may_change(7, Some(3), 7), "the owner may change it");
        assert!(!may_change(7, Some(3), 9), "somebody else may not");
        // Remote-created tickets (creator is None) can only be changed by the
        // owner
        assert!(
            may_change(7, None, 7),
            "the owner may change remote-created tickets"
        );
        assert!(
            !may_change(7, None, 3),
            "non-owners may not change remote-created tickets"
        );
    }

    #[test]
    fn the_list_opens_with_no_label_filter_and_reads_a_filter_it_is_given() {
        // The list as it opens shows every ticket; state filtering is separate.
        assert_eq!(selected_labels(None), Vec::<String>::new());
        // A filter that was cleared shows every ticket rather than falling
        // back to the default one.
        assert_eq!(selected_labels(Some("")), Vec::<String>::new());
        assert_eq!(
            selected_labels(Some("bug,improvement")),
            vec!["bug", "improvement"]
        );
        // Space around the names is what writing a label's name by hand leaves
        // behind.
        assert_eq!(
            selected_labels(Some(" bug , improvement ")),
            vec!["bug", "improvement"]
        );
    }

    #[test]
    fn a_search_is_words_and_never_the_engines_own_syntax() {
        assert_eq!(
            search_expression("sauce"),
            Some("\"sauce\"*".to_string()),
            "one word is searched for as a prefix of itself"
        );
        assert_eq!(
            search_expression("fruity rich"),
            Some("\"fruity\"* AND \"rich\"*".to_string())
        );
        // Words the engine has opinions of its own about are quoted into
        // being the words they spell.
        assert_eq!(
            search_expression("AND OR NEAR"),
            Some("\"AND\"* AND \"OR\"* AND \"NEAR\"*".to_string())
        );
        // A stray quote, a star and a slash are somebody's typing, not a
        // query, and searching for nothing is an empty result rather than a
        // fault the request is answered with.
        assert_eq!(search_expression("\""), None);
        assert_eq!(search_expression("*"), None);
        assert_eq!(search_expression("NEAR/("), Some("\"NEAR\"*".to_string()));
        assert_eq!(search_expression("   "), None);
        assert_eq!(search_expression(""), None);
    }

    #[test]
    fn a_label_name_survives_a_query_string() {
        assert_eq!(encode("help wanted"), "help%20wanted");
        assert_eq!(encode("urgent"), "urgent");
        assert_eq!(encode("a&b=c"), "a%26b%3Dc");
        // Letters outside ASCII are bytes here, and each one is escaped as
        // the byte it is, which is what a query string asks for.
        assert_eq!(encode("é"), "%C3%A9");
    }
}
