use serde::Deserialize;

use super::BugzillaClient;
use crate::error::{BzrError, Result, BUGZILLA_INTERNAL_ERROR};
use crate::types::{
    partition_filters, ApiMode, Bug, CreateBugParams, HistoryEntry, SearchParams, UpdateBugParams,
    FIELD_MAPPINGS,
};

/// Default fields requested for Bug queries. Matches the fields in [`Bug`] and
/// avoids requesting server-side fields we don't use — some Bugzilla extensions
/// crash when serializing certain fields (e.g. group visibility) via the REST API.
const BUG_DEFAULT_FIELDS: &str = "id,summary,status,resolution,product,component,version,\
    assigned_to,priority,severity,creation_time,last_change_time,creator,\
    url,whiteboard,keywords,blocks,depends_on,cc,op_sys,rep_platform";

#[derive(Deserialize)]
struct BugListResponse {
    bugs: Vec<Bug>,
}

#[derive(Deserialize)]
struct HistoryResponse {
    bugs: Vec<HistoryBugEntry>,
}

#[derive(Deserialize)]
struct HistoryBugEntry {
    history: Vec<HistoryEntry>,
}

/// Appends positive (non-negated) values from multi-value `SearchParams`
/// fields as repeated query params (e.g. `&status=NEW&status=ASSIGNED`).
fn append_multi_value_params(
    mut builder: reqwest::RequestBuilder,
    params: &SearchParams,
) -> reqwest::RequestBuilder {
    for mapping in FIELD_MAPPINGS {
        let (positive, _) = partition_filters(params.get_field(mapping.struct_field));
        for v in positive {
            builder = builder.query(&[(mapping.struct_field, v)]);
        }
    }
    builder
}

/// Appends negated values (prefixed with `!`) as Bugzilla boolean chart
/// parameters (`fN`, `oN`, `vN` triples with `notequals` operator).
///
/// Multiple negated values on the same field each get their own triple and
/// are combined with AND (Bugzilla default when no `j_top` join is set).
/// E.g. `--status '!CLOSED' --status '!VERIFIED'` produces
/// `f1=bug_status&o1=notequals&v1=CLOSED&f2=bug_status&o2=notequals&v2=VERIFIED`,
/// meaning "status != CLOSED AND status != VERIFIED" — the desired behavior.
fn append_negated_params(
    mut builder: reqwest::RequestBuilder,
    params: &SearchParams,
) -> reqwest::RequestBuilder {
    let mut idx = 1u32;
    for mapping in FIELD_MAPPINGS {
        let (_, negated) = partition_filters(params.get_field(mapping.struct_field));
        for v in negated {
            let f_key = format!("f{idx}");
            let o_key = format!("o{idx}");
            let v_key = format!("v{idx}");
            builder = builder.query(&[
                (&f_key, mapping.internal_name),
                (&o_key, "notequals"),
                (&v_key, v),
            ]);
            idx += 1;
        }
    }
    builder
}

/// Appends the remaining single-value `Option` and scalar fields from
/// `SearchParams` as query parameters. These were previously handled by
/// serde `Serialize` on the struct; now all query encoding is explicit.
fn append_option_params(
    mut builder: reqwest::RequestBuilder,
    params: &SearchParams,
) -> reqwest::RequestBuilder {
    let option_fields: &[(&str, &Option<String>)] = &[
        ("cc", &params.cc),
        ("alias", &params.alias),
        ("summary", &params.summary),
        ("quicksearch", &params.quicksearch),
        ("include_fields", &params.include_fields),
        ("exclude_fields", &params.exclude_fields),
    ];
    for &(key, value) in option_fields {
        if let Some(v) = value {
            builder = builder.query(&[(key, v.as_str())]);
        }
    }
    if let Some(limit) = params.limit {
        builder = builder.query(&[("limit", limit)]);
    }
    builder
}

/// Returns true if any multi-value filter field contains negated values (prefixed with `!`).
fn has_negated_filters(params: &SearchParams) -> bool {
    FIELD_MAPPINGS.iter().any(|m| {
        params
            .get_field(m.struct_field)
            .iter()
            .any(|v| v.starts_with('!'))
    })
}

/// Returns true if `raw_params` contains boolean chart parameters (`fN`, `oN`, `vN`
/// where N is a positive integer).
fn has_raw_boolean_chart_params(params: &SearchParams) -> bool {
    params.raw_params.iter().any(|(k, _)| {
        k.len() >= 2
            && matches!(k.as_bytes()[0], b'f' | b'o' | b'v')
            && k[1..].parse::<u32>().is_ok_and(|n| n >= 1)
    })
}

/// Appends raw key-value parameters to the request builder verbatim.
/// Used for URL-imported queries with boolean chart params that
/// `bzr` does not natively model.
fn append_raw_params(
    builder: reqwest::RequestBuilder,
    raw_params: &[(String, String)],
) -> reqwest::RequestBuilder {
    builder.query(raw_params)
}

impl BugzillaClient {
    pub async fn get_bug_history_since(
        &self,
        bug_id: u64,
        since: Option<&str>,
    ) -> Result<Vec<HistoryEntry>> {
        let data: HistoryResponse = if let Some(since) = since {
            self.get_json_query(&format!("bug/{bug_id}/history"), &[("new_since", since)])
                .await?
        } else {
            self.get_json(&format!("bug/{bug_id}/history")).await?
        };
        let history = data
            .bugs
            .into_iter()
            .next()
            .map_or_else(Vec::new, |b| b.history);
        Ok(history)
    }

    pub async fn search_bugs(&self, params: &SearchParams) -> Result<Vec<Bug>> {
        tracing::debug!(?params, %self.api_mode, "search parameters");
        // Raw params (boolean charts from URLs) only work with REST.
        if !params.raw_params.is_empty() && self.api_mode != ApiMode::Rest {
            tracing::warn!(
                "query contains raw URL parameters that require REST API; \
                 ignoring configured {} mode",
                self.api_mode
            );
            return self.search_bugs_rest(params).await;
        }
        match self.api_mode {
            ApiMode::Rest => self.search_bugs_rest(params).await,
            ApiMode::XmlRpc => self.xmlrpc_client()?.search_bugs(params).await,
            ApiMode::Hybrid => {
                // Hybrid search only retries on empty results with active filters,
                // not on REST errors. Unlike get_bug (which retries on HTTP/parse
                // errors), search results are less critical and REST errors likely
                // indicate a server issue that XML-RPC won't solve either.
                let rest_result = self.search_bugs_rest(params).await;
                match rest_result {
                    Ok(ref bugs) if !bugs.is_empty() => rest_result,
                    Ok(_) if params.has_filters() => {
                        tracing::info!(
                            "REST search returned empty with active filters, \
                             retrying via XML-RPC"
                        );
                        self.xmlrpc_client()?.search_bugs(params).await
                    }
                    other => other,
                }
            }
        }
    }

    async fn search_bugs_rest(&self, params: &SearchParams) -> Result<Vec<Bug>> {
        if has_negated_filters(params) && has_raw_boolean_chart_params(params) {
            return Err(crate::error::BzrError::InputValidation(
                "cannot combine negated filters (e.g. --status '!CLOSED') with a \
                 URL-imported query containing boolean chart parameters; the chart \
                 indices would collide"
                    .into(),
            ));
        }

        let mut req_builder = self.http.get(self.url("bug"));
        req_builder = append_multi_value_params(req_builder, params);
        req_builder = append_negated_params(req_builder, params);
        req_builder = append_option_params(req_builder, params);

        for id in &params.id {
            req_builder = req_builder.query(&[("id", id)]);
        }

        req_builder = append_raw_params(req_builder, &params.raw_params);

        if params.include_fields.is_none() {
            req_builder = req_builder.query(&[("include_fields", BUG_DEFAULT_FIELDS)]);
        }
        let req = self.apply_auth(req_builder);
        let resp = self.send(req).await?;
        let data: BugListResponse = self.parse_json(resp).await?;
        Ok(data.bugs)
    }

    /// Fetch a single bug by numeric ID or alias string.
    ///
    /// Unlike `get_bug_history_since`, `get_comments_since`, and `get_attachments`,
    /// this method accepts `&str` because Bugzilla supports alias lookup here.
    /// The returned `Bug.id` (u64) can be passed to those numeric-only methods.
    ///
    /// In Hybrid mode, the retry chain is: REST direct → REST search (on 100500)
    /// → XML-RPC. The first two steps happen inside `get_bug_rest`; the XML-RPC
    /// fallback here catches transport failures and residual 100500 errors.
    pub async fn get_bug(
        &self,
        id: &str,
        include_fields: Option<&str>,
        exclude_fields: Option<&str>,
    ) -> Result<Bug> {
        match self.api_mode {
            ApiMode::XmlRpc => self.xmlrpc_client()?.get_bug(id).await,
            ApiMode::Hybrid => {
                let rest_result = self.get_bug_rest(id, include_fields, exclude_fields).await;
                match &rest_result {
                    Err(e) if e.is_transport_failure() => {
                        tracing::info!("REST bug lookup failed, retrying via XML-RPC");
                        self.xmlrpc_client()?.get_bug(id).await
                    }
                    Err(BzrError::Api {
                        code: BUGZILLA_INTERNAL_ERROR,
                        ..
                    }) => {
                        // get_bug_rest() already retries 100500 via the search
                        // endpoint; this arm catches the case where the search
                        // endpoint also fails with 100500.
                        tracing::info!(
                            "REST bug lookup returned 100500, \
                             retrying via XML-RPC"
                        );
                        self.xmlrpc_client()?.get_bug(id).await
                    }
                    _ => rest_result,
                }
            }
            ApiMode::Rest => self.get_bug_rest(id, include_fields, exclude_fields).await,
        }
    }

    async fn get_bug_rest(
        &self,
        id: &str,
        include_fields: Option<&str>,
        exclude_fields: Option<&str>,
    ) -> Result<Bug> {
        let fields = include_fields.unwrap_or(BUG_DEFAULT_FIELDS);
        let mut req_builder = self
            .http
            .get(self.url(&format!("bug/{id}")))
            .query(&[("include_fields", fields)]);
        if let Some(fields) = exclude_fields {
            req_builder = req_builder.query(&[("exclude_fields", fields)]);
        }
        let req = self.apply_auth(req_builder);
        let resp = self.send(req).await?;
        let result: Result<BugListResponse> = self.parse_json(resp).await;

        // If the direct endpoint fails with a server internal error (100500),
        // retry via the search endpoint (/rest/bug?id=X). Some Bugzilla
        // extensions only hook into the direct lookup path and crash there.
        if let Err(BzrError::Api {
            code: BUGZILLA_INTERNAL_ERROR,
            ..
        }) = &result
        {
            tracing::debug!("direct bug lookup returned 100500, retrying via search endpoint");
            return self.get_bug_via_search(id, fields, exclude_fields).await;
        }

        result?
            .bugs
            .into_iter()
            .next()
            .ok_or_else(|| BzrError::NotFound {
                resource: "bug",
                id: id.to_string(),
            })
    }

    async fn get_bug_via_search(
        &self,
        id: &str,
        include_fields: &str,
        exclude_fields: Option<&str>,
    ) -> Result<Bug> {
        let mut req_builder = self
            .http
            .get(self.url("bug"))
            .query(&[("id", id), ("include_fields", include_fields)]);
        if let Some(fields) = exclude_fields {
            req_builder = req_builder.query(&[("exclude_fields", fields)]);
        }
        let req = self.apply_auth(req_builder);
        let resp = self.send(req).await?;
        let data: BugListResponse = self.parse_json(resp).await?;
        data.bugs
            .into_iter()
            .next()
            .ok_or_else(|| BzrError::NotFound {
                resource: "bug",
                id: id.to_string(),
            })
    }

    /// Create a new bug. Always uses REST (XML-RPC mutation support is not implemented).
    pub async fn create_bug(&self, params: &CreateBugParams) -> Result<u64> {
        self.post_json_id("bug", params).await
    }

    /// Update a bug. Always uses REST (XML-RPC mutation support is not implemented).
    pub async fn update_bug(&self, id: u64, updates: &UpdateBugParams) -> Result<()> {
        self.put_json(&format!("bug/{id}"), updates).await
    }
}

#[cfg(test)]
#[expect(clippy::unwrap_used)]
mod tests {
    use wiremock::matchers::{method, path, query_param};
    use wiremock::{Mock, MockServer, ResponseTemplate};

    use super::*;
    use crate::client::test_helpers::{test_client, test_client_hybrid};

    #[tokio::test]
    async fn get_bug_history_returns_entries() {
        let mock = MockServer::start().await;
        Mock::given(method("GET"))
            .and(path("/rest/bug/42/history"))
            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
                "bugs": [{
                    "id": 42,
                    "alias": [],
                    "history": [
                        {
                            "who": "alice@example.com",
                            "when": "2025-01-15T10:30:00Z",
                            "changes": [
                                {
                                    "field_name": "status",
                                    "removed": "NEW",
                                    "added": "ASSIGNED"
                                },
                                {
                                    "field_name": "assigned_to",
                                    "removed": "",
                                    "added": "alice@example.com"
                                }
                            ]
                        },
                        {
                            "who": "bob@example.com",
                            "when": "2025-01-16T14:00:00Z",
                            "changes": [
                                {
                                    "field_name": "status",
                                    "removed": "ASSIGNED",
                                    "added": "RESOLVED"
                                }
                            ]
                        }
                    ]
                }]
            })))
            .mount(&mock)
            .await;

        let client = test_client(&mock.uri());
        let history = client.get_bug_history_since(42, None).await.unwrap();
        assert_eq!(history.len(), 2);
        assert_eq!(history[0].who, "alice@example.com");
        assert_eq!(history[0].changes.len(), 2);
        assert_eq!(history[0].changes[0].field_name, "status");
        assert_eq!(history[0].changes[0].removed, "NEW");
        assert_eq!(history[0].changes[0].added, "ASSIGNED");
        assert_eq!(history[1].changes.len(), 1);
    }

    #[tokio::test]
    async fn get_bug_history_empty() {
        let mock = MockServer::start().await;
        Mock::given(method("GET"))
            .and(path("/rest/bug/99/history"))
            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
                "bugs": [{"id": 99, "alias": [], "history": []}]
            })))
            .mount(&mock)
            .await;

        let client = test_client(&mock.uri());
        let history = client.get_bug_history_since(99, None).await.unwrap();
        assert!(history.is_empty());
    }

    #[tokio::test]
    async fn get_bug_history_with_attachment_id() {
        let mock = MockServer::start().await;
        Mock::given(method("GET"))
            .and(path("/rest/bug/10/history"))
            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
                "bugs": [{
                    "id": 10,
                    "alias": [],
                    "history": [{
                        "who": "carol@example.com",
                        "when": "2025-02-01T09:00:00Z",
                        "changes": [{
                            "field_name": "attachments.isobsolete",
                            "removed": "0",
                            "added": "1",
                            "attachment_id": 555
                        }]
                    }]
                }]
            })))
            .mount(&mock)
            .await;

        let client = test_client(&mock.uri());
        let history = client.get_bug_history_since(10, None).await.unwrap();
        assert_eq!(history.len(), 1);
        assert_eq!(history[0].changes[0].attachment_id, Some(555));
    }

    #[tokio::test]
    async fn get_bug_history_since_filters_by_date() {
        let mock = MockServer::start().await;
        Mock::given(method("GET"))
            .and(path("/rest/bug/42/history"))
            .and(query_param("new_since", "2025-06-01T00:00:00Z"))
            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
                "bugs": [{
                    "id": 42,
                    "alias": [],
                    "history": [{
                        "who": "alice@example.com",
                        "when": "2025-06-15T10:00:00Z",
                        "changes": [{"field_name": "status", "removed": "NEW", "added": "ASSIGNED"}]
                    }]
                }]
            })))
            .mount(&mock)
            .await;

        let client = test_client(&mock.uri());
        let history = client
            .get_bug_history_since(42, Some("2025-06-01T00:00:00Z"))
            .await
            .unwrap();
        assert_eq!(history.len(), 1);
    }

    #[tokio::test]
    async fn get_bug_passes_params() {
        let mock = MockServer::start().await;
        Mock::given(method("GET"))
            .and(path("/rest/bug/1"))
            .and(query_param("include_fields", "id,summary"))
            .respond_with(ResponseTemplate::new(200).set_body_json(
                serde_json::json!({"bugs": [{"id": 1, "summary": "test", "status": "NEW"}]}),
            ))
            .mount(&mock)
            .await;

        let client = test_client(&mock.uri());
        let bug = client.get_bug("1", Some("id,summary"), None).await.unwrap();
        assert_eq!(bug.id, 1);
    }

    #[tokio::test]
    async fn get_bug_falls_back_on_100500() {
        let mock = MockServer::start().await;

        // Direct endpoint returns 100500 (server extension crash)
        Mock::given(method("GET"))
            .and(path("/rest/bug/99"))
            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
                "error": true,
                "code": 100_500,
                "message": "Extension crash"
            })))
            .mount(&mock)
            .await;

        // Search endpoint returns the bug successfully
        Mock::given(method("GET"))
            .and(path("/rest/bug"))
            .and(query_param("id", "99"))
            .respond_with(ResponseTemplate::new(200).set_body_json(
                serde_json::json!({"bugs": [{"id": 99, "summary": "fallback bug", "status": "NEW"}]}),
            ))
            .mount(&mock)
            .await;

        let client = test_client(&mock.uri());
        let bug = client.get_bug("99", None, None).await.unwrap();
        assert_eq!(bug.id, 99);
        assert_eq!(bug.summary, "fallback bug");
    }

    #[tokio::test]
    async fn search_bugs_sends_option_fields() {
        let mock = MockServer::start().await;
        Mock::given(method("GET"))
            .and(path("/rest/bug"))
            .and(query_param("cc", "user@example.com"))
            .and(query_param("alias", "my-alias"))
            .and(query_param("summary", "crash"))
            .and(query_param("limit", "25"))
            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
                "bugs": []
            })))
            .expect(1)
            .mount(&mock)
            .await;

        let client = test_client(&mock.uri());
        let params = SearchParams {
            cc: Some("user@example.com".into()),
            alias: Some("my-alias".into()),
            summary: Some("crash".into()),
            limit: Some(25),
            ..Default::default()
        };
        let bugs = client.search_bugs(&params).await.unwrap();
        assert!(bugs.is_empty());
    }

    #[tokio::test]
    async fn search_bugs_sends_product_filter() {
        let mock = MockServer::start().await;
        Mock::given(method("GET"))
            .and(path("/rest/bug"))
            .and(query_param("product", "Product"))
            .and(query_param("limit", "50"))
            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
                "bugs": [{
                    "id": 217_630,
                    "summary": "Test bug",
                    "status": "WORKING",
                    "product": "Product",
                    "component": "Triage",
                    "assigned_to": "test@example.com",
                    "priority": "P1",
                    "severity": "high",
                    "creation_time": "2026-03-09T09:33:08Z",
                    "last_change_time": "2026-03-18T05:49:05Z"
                }]
            })))
            .expect(1)
            .mount(&mock)
            .await;

        let client = test_client(&mock.uri());
        let params = SearchParams {
            product: vec!["Product".into()],
            limit: Some(50),
            ..Default::default()
        };
        let bugs = client.search_bugs(&params).await.unwrap();
        assert_eq!(bugs.len(), 1);
        assert_eq!(bugs[0].id, 217_630);
    }

    use crate::test_helpers::xmlrpc_bug_response;

    #[tokio::test]
    async fn hybrid_search_rest_has_results_no_xmlrpc_call() {
        let mock = MockServer::start().await;

        Mock::given(method("GET"))
            .and(path("/rest/bug"))
            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
                "bugs": [{"id": 1, "summary": "REST bug", "status": "NEW"}]
            })))
            .expect(1)
            .mount(&mock)
            .await;

        Mock::given(method("POST"))
            .and(path("/xmlrpc.cgi"))
            .respond_with(ResponseTemplate::new(200))
            .expect(0)
            .mount(&mock)
            .await;

        let client = test_client_hybrid(&mock.uri());
        let params = SearchParams {
            product: vec!["P".into()],
            ..Default::default()
        };
        let bugs = client.search_bugs(&params).await.unwrap();
        assert_eq!(bugs.len(), 1);
        assert_eq!(bugs[0].summary, "REST bug");
    }

    #[tokio::test]
    async fn hybrid_search_rest_empty_with_filters_falls_back_to_xmlrpc() {
        let mock = MockServer::start().await;

        Mock::given(method("GET"))
            .and(path("/rest/bug"))
            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"bugs": []})))
            .expect(1)
            .mount(&mock)
            .await;

        Mock::given(method("POST"))
            .and(path("/xmlrpc.cgi"))
            .respond_with(
                ResponseTemplate::new(200).set_body_string(xmlrpc_bug_response(99, "XML-RPC bug")),
            )
            .expect(1)
            .mount(&mock)
            .await;

        let client = test_client_hybrid(&mock.uri());
        let params = SearchParams {
            product: vec!["P".into()],
            ..Default::default()
        };
        let bugs = client.search_bugs(&params).await.unwrap();
        assert_eq!(bugs.len(), 1);
        assert_eq!(bugs[0].id, 99);
        assert_eq!(bugs[0].summary, "XML-RPC bug");
    }

    #[tokio::test]
    async fn hybrid_search_rest_empty_without_filters_no_fallback() {
        let mock = MockServer::start().await;

        Mock::given(method("GET"))
            .and(path("/rest/bug"))
            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"bugs": []})))
            .expect(1)
            .mount(&mock)
            .await;

        Mock::given(method("POST"))
            .and(path("/xmlrpc.cgi"))
            .respond_with(ResponseTemplate::new(200))
            .expect(0)
            .mount(&mock)
            .await;

        let client = test_client_hybrid(&mock.uri());
        let params = SearchParams::default();
        let bugs = client.search_bugs(&params).await.unwrap();
        assert!(bugs.is_empty());
    }

    #[tokio::test]
    async fn hybrid_get_bug_rest_500_falls_back_to_xmlrpc() {
        let mock = MockServer::start().await;

        Mock::given(method("GET"))
            .and(path("/rest/bug/42"))
            .respond_with(ResponseTemplate::new(500).set_body_string("error"))
            .mount(&mock)
            .await;

        Mock::given(method("POST"))
            .and(path("/xmlrpc.cgi"))
            .respond_with(
                ResponseTemplate::new(200)
                    .set_body_string(xmlrpc_bug_response(42, "XML-RPC result")),
            )
            .expect(1)
            .mount(&mock)
            .await;

        let client = test_client_hybrid(&mock.uri());
        let bug = client.get_bug("42", None, None).await.unwrap();
        assert_eq!(bug.id, 42);
        assert_eq!(bug.summary, "XML-RPC result");
    }

    #[tokio::test]
    async fn hybrid_get_bug_rest_401_does_not_fall_back() {
        let mock = MockServer::start().await;

        Mock::given(method("GET"))
            .and(path("/rest/bug/42"))
            .respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({
                "error": true,
                "code": 102,
                "message": "Invalid API key"
            })))
            .mount(&mock)
            .await;

        Mock::given(method("POST"))
            .and(path("/xmlrpc.cgi"))
            .respond_with(ResponseTemplate::new(200))
            .expect(0)
            .mount(&mock)
            .await;

        let client = test_client_hybrid(&mock.uri());
        let err = client.get_bug("42", None, None).await.unwrap_err();
        assert!(
            err.to_string().contains("Invalid API key"),
            "should propagate auth error, got: {err}"
        );
    }

    #[test]
    fn has_negated_filters_detects_negation() {
        let params = SearchParams {
            status: vec!["!CLOSED".into()],
            ..Default::default()
        };
        assert!(super::has_negated_filters(&params));
    }

    #[test]
    fn has_negated_filters_false_for_positive_only() {
        let params = SearchParams {
            status: vec!["NEW".into()],
            ..Default::default()
        };
        assert!(!super::has_negated_filters(&params));
    }

    #[test]
    fn has_raw_boolean_chart_params_detects_f1() {
        let params = SearchParams {
            raw_params: vec![
                ("f1".into(), "qa_contact".into()),
                ("o1".into(), "equals".into()),
                ("v1".into(), "user@example.com".into()),
            ],
            ..Default::default()
        };
        assert!(super::has_raw_boolean_chart_params(&params));
    }

    #[test]
    fn has_raw_boolean_chart_params_false_for_non_chart() {
        let params = SearchParams {
            raw_params: vec![("product".into(), "Firefox".into())],
            ..Default::default()
        };
        assert!(!super::has_raw_boolean_chart_params(&params));
    }

    #[test]
    fn has_raw_boolean_chart_params_false_for_empty() {
        let params = SearchParams::default();
        assert!(!super::has_raw_boolean_chart_params(&params));
    }

    #[test]
    fn search_params_has_filters() {
        let empty = SearchParams::default();
        assert!(!empty.has_filters());

        let with_product = SearchParams {
            product: vec!["P".into()],
            ..Default::default()
        };
        assert!(with_product.has_filters());

        let with_quicksearch = SearchParams {
            quicksearch: Some("crash".into()),
            ..Default::default()
        };
        assert!(with_quicksearch.has_filters());
    }

    #[tokio::test]
    async fn search_bugs_multi_value_sends_repeated_params() {
        let mock = MockServer::start().await;
        Mock::given(method("GET"))
            .and(path("/rest/bug"))
            .and(query_param("status", "NEW"))
            .and(query_param("status", "ASSIGNED"))
            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
                "bugs": [{"id": 1, "summary": "Bug 1", "status": "NEW"}]
            })))
            .expect(1)
            .mount(&mock)
            .await;

        let client = test_client(&mock.uri());
        let params = SearchParams {
            status: vec!["NEW".into(), "ASSIGNED".into()],
            ..Default::default()
        };
        let bugs = client.search_bugs(&params).await.unwrap();
        assert_eq!(bugs.len(), 1);
    }

    #[tokio::test]
    async fn search_bugs_negation_sends_boolean_chart() {
        let mock = MockServer::start().await;
        Mock::given(method("GET"))
            .and(path("/rest/bug"))
            .and(query_param("f1", "bug_status"))
            .and(query_param("o1", "notequals"))
            .and(query_param("v1", "CLOSED"))
            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
                "bugs": [{"id": 2, "summary": "Open bug", "status": "NEW"}]
            })))
            .expect(1)
            .mount(&mock)
            .await;

        let client = test_client(&mock.uri());
        let params = SearchParams {
            status: vec!["!CLOSED".into()],
            ..Default::default()
        };
        let bugs = client.search_bugs(&params).await.unwrap();
        assert_eq!(bugs.len(), 1);
    }

    #[tokio::test]
    async fn search_bugs_mixed_positive_and_negated() {
        let mock = MockServer::start().await;
        Mock::given(method("GET"))
            .and(path("/rest/bug"))
            .and(query_param("status", "NEW"))
            .and(query_param("f1", "bug_severity"))
            .and(query_param("o1", "notequals"))
            .and(query_param("v1", "enhancement"))
            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
                "bugs": [{"id": 3, "summary": "Real bug", "status": "NEW"}]
            })))
            .expect(1)
            .mount(&mock)
            .await;

        let client = test_client(&mock.uri());
        let params = SearchParams {
            status: vec!["NEW".into()],
            severity: vec!["!enhancement".into()],
            ..Default::default()
        };
        let bugs = client.search_bugs(&params).await.unwrap();
        assert_eq!(bugs.len(), 1);
    }

    #[tokio::test]
    async fn search_bugs_rejects_negated_plus_raw_boolean_chart() {
        let mock = MockServer::start().await;
        let client = test_client(&mock.uri());
        let params = SearchParams {
            status: vec!["!CLOSED".into()],
            raw_params: vec![
                ("f1".into(), "qa_contact".into()),
                ("o1".into(), "equals".into()),
                ("v1".into(), "user@example.com".into()),
            ],
            ..Default::default()
        };
        let result = client.search_bugs(&params).await;
        assert!(result.is_err());
        let err = result.unwrap_err();
        assert!(
            err.to_string().contains("cannot combine negated filters"),
            "unexpected error: {err}"
        );
    }

    #[tokio::test]
    async fn search_bugs_all_fields_reach_server() {
        let mock = MockServer::start().await;
        Mock::given(method("GET"))
            .and(path("/rest/bug"))
            .and(query_param("product", "Firefox"))
            .and(query_param("component", "General"))
            .and(query_param("status", "NEW"))
            .and(query_param("assigned_to", "dev@test.com"))
            .and(query_param("creator", "reporter@test.com"))
            .and(query_param("priority", "P1"))
            .and(query_param("severity", "major"))
            .and(query_param("cc", "watcher@test.com"))
            .and(query_param("alias", "my-bug"))
            .and(query_param("id", "42"))
            .and(query_param("limit", "10"))
            .and(query_param("summary", "crash"))
            .and(query_param("quicksearch", "qs-term"))
            .and(query_param("include_fields", "id,summary"))
            .and(query_param("exclude_fields", "cc"))
            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
                "bugs": [{"id": 42, "summary": "crash", "status": "NEW"}]
            })))
            .expect(1)
            .mount(&mock)
            .await;

        let client = test_client(&mock.uri());
        let params = SearchParams {
            product: vec!["Firefox".into()],
            component: vec!["General".into()],
            status: vec!["NEW".into()],
            assigned_to: vec!["dev@test.com".into()],
            creator: vec!["reporter@test.com".into()],
            priority: vec!["P1".into()],
            severity: vec!["major".into()],
            cc: Some("watcher@test.com".into()),
            alias: Some("my-bug".into()),
            id: vec![42],
            limit: Some(10),
            summary: Some("crash".into()),
            quicksearch: Some("qs-term".into()),
            include_fields: Some("id,summary".into()),
            exclude_fields: Some("cc".into()),
            ..Default::default()
        };
        let bugs = client.search_bugs(&params).await.unwrap();
        assert_eq!(bugs.len(), 1);
    }
}
