use async_trait::async_trait;
use reqwest::{Method, StatusCode, Url};
use serde_json::{json, Map, Value};
use tokio::time::sleep;
use tracing::warn;

use crate::engine::config::NotionConfig;
use crate::lfd::pm::notion_blocks::{blocks_to_markdown, markdown_to_blocks};
use crate::lfd::pm::{
    PmError, PmItem, PmItemCreate, PmItemUpdate, PmProject, PmProvider, PmResult, PriorityBucket,
    RATE_LIMIT_RETRIES,
};

const NOTION_BASE_URL: &str = "https://api.notion.com/v1";
const NOTION_VERSION: &str = "2022-06-28";
const DEFAULT_TEAM_NAME: &str = "Waves";
const NOTION_PAGE_SIZE: usize = 100;
const NOTION_TODO_STATUS: &str = "Todo";
const NOTION_IN_PROGRESS_STATUS: &str = "In Progress";

#[derive(Debug, Clone)]
pub struct NotionClient {
    client: reqwest::Client,
    token: String,
    config: NotionConfig,
    base_url: String,
}

impl NotionClient {
    pub fn new(token: String, config: NotionConfig) -> Self {
        Self {
            client: reqwest::Client::new(),
            token,
            config,
            base_url: NOTION_BASE_URL.to_string(),
        }
    }

    #[cfg(test)]
    fn with_base_url(token: String, config: NotionConfig, base_url: String) -> Self {
        Self {
            client: reqwest::Client::new(),
            token,
            config,
            base_url,
        }
    }

    fn title_property_name(&self) -> &str {
        self.config.title_property.as_deref().unwrap_or("Name")
    }

    fn status_property_name(&self) -> &str {
        self.config.status_property.as_deref().unwrap_or("Status")
    }

    fn done_value(&self) -> &str {
        self.config.done_value.as_deref().unwrap_or("Done")
    }

    fn priority_property_name(&self) -> &str {
        self.config
            .priority_property
            .as_deref()
            .unwrap_or("Priority")
    }

    fn request(&self, method: Method, path: &str) -> reqwest::RequestBuilder {
        let url = Url::parse(&format!("{}{}", self.base_url, path))
            .expect("notion base URL should be valid");
        self.client
            .request(method, url)
            .bearer_auth(&self.token)
            .header("Notion-Version", NOTION_VERSION)
    }

    fn request_with_start_cursor(
        &self,
        method: Method,
        path: &str,
        start_cursor: Option<&str>,
    ) -> reqwest::RequestBuilder {
        let mut url = Url::parse(&format!("{}{}", self.base_url, path))
            .expect("notion base URL should be valid");
        if let Some(cursor) = start_cursor {
            url.query_pairs_mut().append_pair("start_cursor", cursor);
        }
        self.client
            .request(method, url)
            .bearer_auth(&self.token)
            .header("Notion-Version", NOTION_VERSION)
    }

    async fn send_json<F>(&self, mut make_request: F) -> PmResult<Value>
    where
        F: FnMut() -> reqwest::RequestBuilder,
    {
        for attempt in 0..=RATE_LIMIT_RETRIES {
            let response = make_request()
                .send()
                .await
                .map_err(|err| PmError::Message(format!("notion request failed: {err}")))?;

            if response.status() == StatusCode::TOO_MANY_REQUESTS && attempt < RATE_LIMIT_RETRIES {
                let delay = super::retry_after_delay(response.headers());
                warn!(
                    attempt = attempt + 1,
                    delay_seconds = delay.as_secs(),
                    "notion rate limited; retrying"
                );
                sleep(delay).await;
                continue;
            }

            return parse_response(response).await;
        }

        Err(PmError::Message(
            "notion request failed after retries".to_string(),
        ))
    }

    async fn collect_paginated_results<F>(&self, mut make_request: F) -> PmResult<Vec<Value>>
    where
        F: FnMut(Option<&str>) -> reqwest::RequestBuilder,
    {
        let mut results = Vec::new();
        let mut next_cursor: Option<String> = None;

        loop {
            let cursor = next_cursor.clone();
            let response = self.send_json(|| make_request(cursor.as_deref())).await?;

            results.extend(
                response
                    .get("results")
                    .and_then(Value::as_array)
                    .cloned()
                    .unwrap_or_default(),
            );

            if !response
                .get("has_more")
                .and_then(Value::as_bool)
                .unwrap_or(false)
            {
                break;
            }

            next_cursor = response
                .get("next_cursor")
                .and_then(Value::as_str)
                .map(str::to_string);
        }

        Ok(results)
    }

    async fn resolve_team_for_project_bootstrap(&self) -> PmResult<String> {
        if let Some(parent) = &self.config.parent_page {
            return Ok(parent.clone());
        }
        if let Some(team_id) = self.find_team(DEFAULT_TEAM_NAME).await? {
            return Ok(team_id);
        }
        self.create_team(DEFAULT_TEAM_NAME).await
    }

    async fn search(&self, query: &str, value: &str) -> PmResult<Vec<Value>> {
        self.collect_paginated_results(|start_cursor| {
            let mut body = json!({
                "query": query,
                "page_size": NOTION_PAGE_SIZE,
                "filter": {
                    "property": "object",
                    "value": value,
                }
            });
            if let Some(cursor) = start_cursor {
                body["start_cursor"] = Value::String(cursor.to_string());
            }
            self.request(Method::POST, "/search").json(&body)
        })
        .await
    }

    async fn fetch_page(&self, page_id: &str) -> PmResult<Value> {
        self.send_json(|| self.request(Method::GET, &format!("/pages/{page_id}")))
            .await
    }

    async fn fetch_page_body_markdown(&self, page_id: &str) -> PmResult<String> {
        let blocks = self.fetch_block_children(page_id).await?;
        Ok(blocks_to_markdown(&blocks))
    }

    async fn fetch_direct_block_children(&self, block_id: &str) -> PmResult<Vec<Value>> {
        self.collect_paginated_results(|start_cursor| {
            self.request_with_start_cursor(
                Method::GET,
                &format!("/blocks/{block_id}/children"),
                start_cursor,
            )
        })
        .await
    }

    async fn fetch_block_children(&self, block_id: &str) -> PmResult<Vec<Value>> {
        let mut children = self.fetch_direct_block_children(block_id).await?;

        for block in &mut children {
            if block
                .get("has_children")
                .and_then(Value::as_bool)
                .unwrap_or(false)
            {
                let id = value_string(block, "id")?;
                let nested = Box::pin(self.fetch_block_children(&id)).await?;
                if let Some(block_type) = block
                    .get("type")
                    .and_then(Value::as_str)
                    .map(str::to_string)
                {
                    if let Some(data) = block.get_mut(&block_type).and_then(Value::as_object_mut) {
                        data.insert("children".to_string(), Value::Array(nested));
                    }
                }
            }
        }

        Ok(children)
    }

    async fn fetch_top_level_block_ids(&self, block_id: &str) -> PmResult<Vec<String>> {
        self.fetch_direct_block_children(block_id)
            .await?
            .iter()
            .map(|block| value_string(block, "id"))
            .collect()
    }

    async fn delete_page_body(&self, page_id: &str) -> PmResult<()> {
        for block_id in self.fetch_top_level_block_ids(page_id).await? {
            let _ = self
                .send_json(|| self.request(Method::DELETE, &format!("/blocks/{block_id}")))
                .await?;
        }
        Ok(())
    }

    async fn append_page_body(&self, page_id: &str, markdown: &str) -> PmResult<()> {
        let blocks = markdown_to_blocks(markdown);
        if blocks.is_empty() {
            return Ok(());
        }

        let _ = self
            .send_json(|| {
                self.request(Method::PATCH, &format!("/blocks/{page_id}/children"))
                    .json(&json!({ "children": blocks }))
            })
            .await?;
        Ok(())
    }

    fn page_title_property(&self, text: &str) -> Value {
        json!({
            self.title_property_name(): {
                "title": [text_span(text)]
            }
        })
    }

    fn page_status_property_name(&self, name: &str) -> Value {
        json!({
            self.status_property_name(): {
                "status": { "name": name }
            }
        })
    }

    fn page_status_property(&self, done: bool) -> Value {
        let name = if done {
            self.done_value()
        } else {
            NOTION_TODO_STATUS
        };
        self.page_status_property_name(name)
    }

    fn page_priority_property(&self, rank: u32) -> Value {
        json!({
            self.priority_property_name(): {
                "select": {
                    "name": PriorityBucket::from_rank(rank).semantic_label()
                }
            }
        })
    }

    fn item_properties(&self, item: &PmItemCreate) -> Map<String, Value> {
        let mut properties = Map::new();
        properties.extend(value_object(self.page_title_property(&item.name)));
        properties.extend(value_object(self.page_status_property(false)));
        properties.extend(value_object(self.page_priority_property(item.rank)));
        properties
    }

    async fn query_database_pages(&self, database_id: &str) -> PmResult<Vec<Value>> {
        self.collect_paginated_results(|start_cursor| {
            let mut body = json!({ "page_size": NOTION_PAGE_SIZE });
            if let Some(cursor) = start_cursor {
                body["start_cursor"] = Value::String(cursor.to_string());
            }
            self.request(Method::POST, &format!("/databases/{database_id}/query"))
                .json(&body)
        })
        .await
    }

    pub async fn create_team(&self, name: &str) -> PmResult<String> {
        let response = self
            .send_json(|| {
                self.request(Method::POST, "/pages").json(&json!({
                    "parent": { "workspace": true },
                    "properties": {
                        "title": [text_span(name)]
                    }
                }))
            })
            .await?;
        value_string(&response, "id")
    }

    pub async fn find_team(&self, name: &str) -> PmResult<Option<String>> {
        let target = name.trim();
        for page in self.search(target, "page").await? {
            let title = page_title(&page).unwrap_or_default();
            if title.eq_ignore_ascii_case(target) {
                return Ok(Some(value_string(&page, "id")?));
            }
        }
        Ok(None)
    }

    pub async fn create_project_in_team(
        &self,
        team_id: &str,
        name: &str,
        description: &str,
    ) -> PmResult<String> {
        let response = self
            .send_json(|| {
                self.request(Method::POST, "/databases").json(&json!({
                    "parent": { "type": "page_id", "page_id": team_id },
                    "title": [text_span(name)],
                    "description": [text_span(&truncate_text(description, 2000))],
                    "properties": {
                        self.title_property_name(): { "title": {} },
                        self.status_property_name(): {
                            "status": {
                                "options": [
                                    { "name": NOTION_TODO_STATUS, "color": "default" },
                                    { "name": NOTION_IN_PROGRESS_STATUS, "color": "blue" },
                                    { "name": self.done_value(), "color": "green" }
                                ]
                            }
                        },
                        self.priority_property_name(): {
                            "select": {
                                "options": [
                                    { "name": PriorityBucket::Urgent.semantic_label(), "color": "red" },
                                    { "name": PriorityBucket::High.semantic_label(), "color": "orange" },
                                    { "name": PriorityBucket::Medium.semantic_label(), "color": "yellow" },
                                    { "name": PriorityBucket::Low.semantic_label(), "color": "gray" }
                                ]
                            }
                        }
                    }
                }))
            })
            .await?;
        value_string(&response, "id")
    }
}

#[async_trait]
impl PmProvider for NotionClient {
    async fn create_project(&self, name: &str, description: &str) -> PmResult<String> {
        let team_id = self.resolve_team_for_project_bootstrap().await?;
        self.create_project_in_team(&team_id, name, description)
            .await
    }

    async fn list_projects(&self, team_id: &str) -> PmResult<Vec<PmProject>> {
        let mut projects = Vec::new();
        for database in self.search("", "database").await? {
            if parent_page_id(&database).as_deref() != Some(team_id) {
                continue;
            }
            projects.push(PmProject {
                id: value_string(&database, "id")?,
                name: database_title(&database).unwrap_or_default(),
            });
        }
        projects.sort_by(|left, right| left.name.cmp(&right.name));
        Ok(projects)
    }

    async fn list_items(&self, project_id: &str) -> PmResult<Vec<PmItem>> {
        let pages = self.query_database_pages(project_id).await?;
        let mut items = Vec::new();
        for page in pages {
            let id = value_string(&page, "id")?;
            let description = self.fetch_page_body_markdown(&id).await?;
            let status = property_select_name(
                item_property(&page, self.status_property_name()).unwrap_or(&Value::Null),
            );
            items.push(PmItem {
                id,
                name: page_item_name(&page, self.title_property_name())?,
                description,
                rank: page_item_rank(&page, self.priority_property_name()),
                completed: page_item_completed(
                    &page,
                    self.status_property_name(),
                    self.done_value(),
                ),
                assignee: status
                    .as_deref()
                    .filter(|value| value.eq_ignore_ascii_case(NOTION_IN_PROGRESS_STATUS))
                    .map(str::to_string),
            });
        }
        items.sort_by(|left, right| {
            left.rank
                .cmp(&right.rank)
                .then_with(|| left.name.cmp(&right.name))
        });
        Ok(items)
    }

    async fn create_item(&self, project_id: &str, item: &PmItemCreate) -> PmResult<String> {
        let properties = self.item_properties(item);
        let response = self
            .send_json(|| {
                self.request(Method::POST, "/pages").json(&json!({
                    "parent": { "database_id": project_id },
                    "properties": properties,
                }))
            })
            .await?;
        let page_id = value_string(&response, "id")?;
        self.append_page_body(&page_id, &item.description).await?;
        Ok(page_id)
    }

    async fn update_item(&self, item_id: &str, update: &PmItemUpdate) -> PmResult<()> {
        let mut properties = Map::new();
        if let Some(name) = &update.name {
            properties.extend(value_object(self.page_title_property(name)));
        }
        if let Some(rank) = update.rank {
            properties.extend(value_object(self.page_priority_property(rank)));
        }

        if !properties.is_empty() {
            let _ = self
                .send_json(|| {
                    self.request(Method::PATCH, &format!("/pages/{item_id}"))
                        .json(&json!({ "properties": properties }))
                })
                .await?;
        }

        if let Some(description) = &update.description {
            self.delete_page_body(item_id).await?;
            self.append_page_body(item_id, description).await?;
        }

        Ok(())
    }

    async fn complete_item(&self, item_id: &str) -> PmResult<()> {
        let page = self.fetch_page(item_id).await?;
        let property = item_property(&page, self.status_property_name());
        let value = match property
            .and_then(|value| value.get("type"))
            .and_then(Value::as_str)
            .unwrap_or("status")
        {
            "checkbox" => json!({ "checkbox": true }),
            "select" => json!({ "select": { "name": self.done_value() } }),
            _ => json!({ "status": { "name": self.done_value() } }),
        };

        let _ = self
            .send_json(|| {
                self.request(Method::PATCH, &format!("/pages/{item_id}"))
                    .json(&json!({
                        "properties": {
                            self.status_property_name(): value,
                        }
                    }))
            })
            .await?;
        Ok(())
    }

    async fn comment(&self, item_id: &str, body: &str) -> PmResult<()> {
        let _ = self
            .send_json(|| {
                self.request(Method::POST, "/comments").json(&json!({
                    "parent": { "page_id": item_id },
                    "rich_text": parse_comment_rich_text(body),
                }))
            })
            .await?;
        Ok(())
    }

    async fn claim_item(&self, item_id: &str, _branch: &str) -> PmResult<()> {
        // Best-effort notification: mark the page "In Progress" so other workers
        // see it as assigned on their next list_items call. Notion doesn't support
        // conditional writes or claimant identity on the status field, so two
        // concurrent claims can both succeed. Duplicate work is caught at PR time.
        let response = self
            .request(Method::PATCH, &format!("/pages/{item_id}"))
            .json(&json!({
                "properties": self.page_status_property_name(NOTION_IN_PROGRESS_STATUS)
            }))
            .send()
            .await
            .map_err(|err| PmError::Message(format!("notion request failed: {err}")))?;

        let _ = parse_response(response).await?;
        Ok(())
    }
}

fn truncate_text(text: &str, max_len: usize) -> String {
    if text.len() <= max_len {
        return text.to_string();
    }
    let mut end = max_len.min(text.len());
    while !text.is_char_boundary(end) {
        end -= 1;
    }
    format!("{}…", &text[..end - 1])
}

fn text_span(text: &str) -> Value {
    json!({
        "type": "text",
        "text": { "content": text }
    })
}

fn parse_comment_rich_text(body: &str) -> Vec<Value> {
    if body.trim().is_empty() {
        vec![text_span("")]
    } else {
        vec![text_span(body)]
    }
}

fn value_object(value: Value) -> Map<String, Value> {
    match value {
        Value::Object(map) => map,
        _ => Map::new(),
    }
}

fn value_string(value: &Value, key: &str) -> PmResult<String> {
    value
        .get(key)
        .and_then(Value::as_str)
        .map(str::to_string)
        .ok_or_else(|| PmError::Message(format!("notion response missing string field `{key}`")))
}

fn page_title(page: &Value) -> Option<String> {
    page.get("properties")
        .and_then(Value::as_object)
        .and_then(|properties| properties.get("title"))
        .and_then(|property| property.get("title"))
        .and_then(Value::as_array)
        .map(|value| rich_text_array_plain_text(value))
        .filter(|title| !title.is_empty())
}

fn database_title(database: &Value) -> Option<String> {
    database
        .get("title")
        .and_then(Value::as_array)
        .map(|value| rich_text_array_plain_text(value))
        .filter(|title| !title.is_empty())
}

fn parent_page_id(value: &Value) -> Option<String> {
    value
        .get("parent")
        .and_then(|parent| parent.get("page_id"))
        .and_then(Value::as_str)
        .map(str::to_string)
}

fn item_property<'a>(page: &'a Value, name: &str) -> Option<&'a Value> {
    page.get("properties")?.get(name)
}

fn page_item_name(page: &Value, title_property: &str) -> PmResult<String> {
    let property = item_property(page, title_property).ok_or_else(|| {
        PmError::Message(format!(
            "notion page is missing title property `{title_property}`"
        ))
    })?;
    let title = property
        .get("title")
        .and_then(Value::as_array)
        .map(|value| rich_text_array_plain_text(value))
        .unwrap_or_default();
    if title.is_empty() {
        return Err(PmError::Message(format!(
            "notion page title property `{title_property}` is empty"
        )));
    }
    Ok(title)
}

fn page_item_completed(page: &Value, status_property: &str, done_value: &str) -> bool {
    let Some(property) = item_property(page, status_property) else {
        return false;
    };

    if let Some(checked) = property.get("checkbox").and_then(Value::as_bool) {
        return checked;
    }

    property_select_name(property).is_some_and(|name| name.eq_ignore_ascii_case(done_value))
}

fn page_item_rank(page: &Value, priority_property: &str) -> u32 {
    let Some(property) = item_property(page, priority_property) else {
        return 0;
    };

    property_select_name(property)
        .and_then(|name| PriorityBucket::from_semantic_label(&name))
        .map(PriorityBucket::rank)
        .unwrap_or(0)
}

fn property_select_name(property: &Value) -> Option<String> {
    let property_type = property
        .get("type")
        .and_then(Value::as_str)
        .unwrap_or_default();
    match property_type {
        "status" => property
            .get("status")
            .and_then(|status| status.get("name"))
            .and_then(Value::as_str)
            .map(str::to_string),
        "select" => property
            .get("select")
            .and_then(|select| select.get("name"))
            .and_then(Value::as_str)
            .map(str::to_string),
        _ => None,
    }
}

fn rich_text_array_plain_text(values: &[Value]) -> String {
    values
        .iter()
        .filter_map(|value| {
            value.get("plain_text").and_then(Value::as_str).or_else(|| {
                value
                    .get("text")
                    .and_then(|text| text.get("content"))
                    .and_then(Value::as_str)
            })
        })
        .collect::<Vec<_>>()
        .join("")
}

async fn parse_response(response: reqwest::Response) -> PmResult<Value> {
    let status = response.status();
    let body = response
        .bytes()
        .await
        .map_err(|err| PmError::Message(format!("failed to read notion response: {err}")))?;

    if !status.is_success() {
        return Err(PmError::Message(parse_error_message(status, &body)));
    }

    serde_json::from_slice(&body)
        .map_err(|err| PmError::Message(format!("failed to decode notion response: {err}")))
}

fn parse_error_message(status: StatusCode, body: &[u8]) -> String {
    if let Ok(payload) = serde_json::from_slice::<Value>(body) {
        if let Some(message) = payload.get("message").and_then(Value::as_str) {
            return format!("notion request failed with status {status}: {message}");
        }
    }

    let body_text = String::from_utf8_lossy(body).trim().to_string();
    if body_text.is_empty() {
        format!("notion request failed with status {status}")
    } else {
        format!("notion request failed with status {status}: {body_text}")
    }
}

#[cfg(test)]
mod tests {
    use axum::http::StatusCode;
    use serde_json::json;

    use super::*;
    use crate::engine::config::NotionConfig;
    use crate::lfd::pm::test_server::{self, json_response, response};
    use crate::lfd::pm::PmProvider;

    #[tokio::test]
    async fn create_team_creates_workspace_page() {
        let (base_url, requests) = test_server::spawn(vec![json_response(
            StatusCode::CREATED,
            json!({ "id": "team-1" }),
        )])
        .await;
        let client = NotionClient::with_base_url(
            "secret-token".to_string(),
            NotionConfig::default(),
            base_url,
        );

        let team_id = client.create_team("Waves").await.expect("team id");
        assert_eq!(team_id, "team-1");

        let request = requests.lock().await[0].clone();
        assert_eq!(request.method, "POST");
        assert_eq!(request.path, "/pages");
        assert_eq!(
            request.authorization.as_deref(),
            Some("Bearer secret-token")
        );
        assert!(request.body.contains("\"workspace\":true"));
        assert!(request.body.contains("Waves"));
    }

    #[tokio::test]
    async fn find_team_filters_search_results_by_title() {
        let (base_url, _requests) = test_server::spawn(vec![json_response(
            StatusCode::OK,
            json!({
                "results": [
                    {
                        "id": "team-1",
                        "properties": { "title": { "title": [{ "plain_text": "Waves" }] } }
                    },
                    {
                        "id": "team-2",
                        "properties": { "title": { "title": [{ "plain_text": "Other" }] } }
                    }
                ]
            }),
        )])
        .await;
        let client = NotionClient::with_base_url(
            "secret-token".to_string(),
            NotionConfig::default(),
            base_url,
        );

        let team_id = client.find_team("waves").await.expect("find team");
        assert_eq!(team_id.as_deref(), Some("team-1"));
    }

    #[tokio::test]
    async fn create_project_in_team_creates_database_schema() {
        let (base_url, requests) = test_server::spawn(vec![json_response(
            StatusCode::CREATED,
            json!({ "id": "db-1" }),
        )])
        .await;
        let client = NotionClient::with_base_url(
            "secret-token".to_string(),
            NotionConfig::default(),
            base_url,
        );

        let project_id = client
            .create_project_in_team("team-1", "PM", "Project notes")
            .await
            .expect("project id");
        assert_eq!(project_id, "db-1");

        let request = requests.lock().await[0].clone();
        assert_eq!(request.path, "/databases");
        assert!(request.body.contains("\"page_id\":\"team-1\""));
        assert!(request.body.contains("\"Name\""));
        assert!(request.body.contains("\"Status\""));
        assert!(request.body.contains("\"Priority\""));
    }

    #[tokio::test]
    async fn list_projects_filters_to_team_parent() {
        let (base_url, _requests) = test_server::spawn(vec![json_response(
            StatusCode::OK,
            json!({
                "results": [
                    {
                        "id": "db-2",
                        "parent": { "page_id": "team-2" },
                        "title": [{ "plain_text": "Other" }]
                    },
                    {
                        "id": "db-1",
                        "parent": { "page_id": "team-1" },
                        "title": [{ "plain_text": "PM" }]
                    }
                ]
            }),
        )])
        .await;
        let client = NotionClient::with_base_url(
            "secret-token".to_string(),
            NotionConfig::default(),
            base_url,
        );

        let projects = client.list_projects("team-1").await.expect("projects");
        assert_eq!(
            projects,
            vec![PmProject {
                id: "db-1".to_string(),
                name: "PM".to_string()
            }]
        );
    }

    #[tokio::test]
    async fn list_items_queries_pages_and_fetches_block_bodies() {
        let (base_url, requests) = test_server::spawn(vec![
            json_response(
                StatusCode::OK,
                json!({
                    "results": [
                        {
                            "id": "page-1",
                            "properties": {
                                "Name": { "type": "title", "title": [{ "plain_text": "Build it" }] },
                                "Priority": { "type": "select", "select": { "name": "High" } },
                                "Status": { "type": "status", "status": { "name": "Done" } }
                            }
                        }
                    ],
                    "has_more": false
                }),
            ),
            json_response(
                StatusCode::OK,
                json!({
                    "results": [
                        {
                            "id": "block-1",
                            "type": "paragraph",
                            "paragraph": {
                                "rich_text": [{ "plain_text": "First paragraph" }]
                            },
                            "has_children": false
                        }
                    ],
                    "has_more": false
                }),
            ),
        ])
        .await;
        let client = NotionClient::with_base_url(
            "secret-token".to_string(),
            NotionConfig::default(),
            base_url,
        );

        let items = client.list_items("db-1").await.expect("items");
        assert_eq!(items.len(), 1);
        assert_eq!(items[0].id, "page-1");
        assert_eq!(items[0].name, "Build it");
        assert_eq!(items[0].rank, 1);
        assert!(items[0].completed);
        assert_eq!(items[0].description, "First paragraph");

        let requests = requests.lock().await.clone();
        assert_eq!(requests[0].path, "/databases/db-1/query");
        assert_eq!(requests[1].path, "/blocks/page-1/children");
    }

    #[tokio::test]
    async fn create_item_creates_page_then_appends_blocks() {
        let (base_url, requests) = test_server::spawn(vec![
            json_response(StatusCode::CREATED, json!({ "id": "page-1" })),
            json_response(StatusCode::OK, json!({ "results": [] })),
        ])
        .await;
        let client = NotionClient::with_base_url(
            "secret-token".to_string(),
            NotionConfig::default(),
            base_url,
        );

        let item_id = client
            .create_item(
                "db-1",
                &PmItemCreate {
                    name: "Build it".to_string(),
                    description: "# Heading\n\nBody".to_string(),
                    rank: 0,
                },
            )
            .await
            .expect("item id");
        assert_eq!(item_id, "page-1");

        let requests = requests.lock().await.clone();
        assert_eq!(requests[0].path, "/pages");
        assert!(requests[0].body.contains("\"database_id\":\"db-1\""));
        assert!(requests[0].body.contains("\"Priority\""));
        assert!(requests[0].body.contains("Urgent"));
        assert_eq!(requests[1].path, "/blocks/page-1/children");
        assert!(requests[1].body.contains("heading_1"));
        assert!(requests[1].body.contains("paragraph"));
    }

    #[tokio::test]
    async fn update_item_updates_properties_and_rewrites_description() {
        let (base_url, requests) = test_server::spawn(vec![
            json_response(StatusCode::OK, json!({ "id": "page-1" })),
            json_response(
                StatusCode::OK,
                json!({
                    "results": [{ "id": "block-1" }],
                    "has_more": false
                }),
            ),
            json_response(StatusCode::OK, json!({ "archived": true })),
            json_response(StatusCode::OK, json!({ "results": [] })),
        ])
        .await;
        let client = NotionClient::with_base_url(
            "secret-token".to_string(),
            NotionConfig::default(),
            base_url,
        );

        client
            .update_item(
                "page-1",
                &PmItemUpdate {
                    name: Some("New title".to_string()),
                    description: Some("Updated body".to_string()),
                    rank: Some(2),
                },
            )
            .await
            .expect("update item");

        let requests = requests.lock().await.clone();
        assert_eq!(requests[0].path, "/pages/page-1");
        assert!(requests[0].body.contains("New title"));
        assert!(requests[0].body.contains("\"Priority\""));
        assert!(requests[0].body.contains("Medium"));
        assert_eq!(requests[1].path, "/blocks/page-1/children");
        assert_eq!(requests[2].method, "DELETE");
        assert_eq!(requests[2].path, "/blocks/block-1");
        assert_eq!(requests[3].path, "/blocks/page-1/children");
        assert!(requests[3].body.contains("Updated body"));
    }

    #[tokio::test]
    async fn complete_item_uses_checkbox_when_status_property_is_checkbox() {
        let (base_url, requests) = test_server::spawn(vec![
            json_response(
                StatusCode::OK,
                json!({
                    "id": "page-1",
                    "properties": {
                        "Status": { "type": "checkbox", "checkbox": false }
                    }
                }),
            ),
            json_response(StatusCode::OK, json!({ "id": "page-1" })),
        ])
        .await;
        let client = NotionClient::with_base_url(
            "secret-token".to_string(),
            NotionConfig::default(),
            base_url,
        );

        client.complete_item("page-1").await.expect("complete item");

        let requests = requests.lock().await.clone();
        assert_eq!(requests[0].path, "/pages/page-1");
        assert_eq!(requests[1].path, "/pages/page-1");
        assert!(requests[1].body.contains("\"checkbox\":true"));
    }

    #[tokio::test]
    async fn comment_posts_rich_text_comment() {
        let (base_url, requests) = test_server::spawn(vec![json_response(
            StatusCode::CREATED,
            json!({ "id": "comment-1" }),
        )])
        .await;
        let client = NotionClient::with_base_url(
            "secret-token".to_string(),
            NotionConfig::default(),
            base_url,
        );

        client
            .comment("page-1", "Looks good")
            .await
            .expect("comment");

        let request = requests.lock().await[0].clone();
        assert_eq!(request.path, "/comments");
        assert!(request.body.contains("\"page_id\":\"page-1\""));
        assert!(request.body.contains("Looks good"));
    }

    #[tokio::test]
    async fn claim_item_marks_page_in_progress() {
        let (base_url, requests) = test_server::spawn(vec![json_response(
            StatusCode::OK,
            json!({ "id": "page-1" }),
        )])
        .await;
        let client = NotionClient::with_base_url(
            "secret-token".to_string(),
            NotionConfig::default(),
            base_url,
        );

        client
            .claim_item("page-1", "feature/test")
            .await
            .expect("claim item");

        let request = requests.lock().await[0].clone();
        assert_eq!(request.method, "PATCH");
        assert_eq!(request.path, "/pages/page-1");
        assert!(request.body.contains("In Progress"));
    }

    #[tokio::test]
    async fn retries_after_rate_limit() {
        let (base_url, _requests) = test_server::spawn(vec![
            response(
                StatusCode::TOO_MANY_REQUESTS,
                vec![("content-type", "application/json"), ("retry-after", "0")],
                json!({ "message": "slow down" }).to_string(),
            ),
            json_response(StatusCode::CREATED, json!({ "id": "team-1" })),
        ])
        .await;
        let client = NotionClient::with_base_url(
            "secret-token".to_string(),
            NotionConfig::default(),
            base_url,
        );

        let team_id = client.create_team("Waves").await.expect("team id");
        assert_eq!(team_id, "team-1");
    }

    #[test]
    fn parse_error_message_prefers_json_message() {
        let message = parse_error_message(
            StatusCode::BAD_REQUEST,
            &json!({ "message": "bad schema" }).to_string().into_bytes(),
        );
        assert_eq!(
            message,
            "notion request failed with status 400 Bad Request: bad schema"
        );
    }
}
