use serde_json::Value;
use tail_fin_common::html::strip_html as strip_html_raw;

use crate::types::{
    Article, ArticleContent, FinancialCell, FinancialRow, FinancialStatement, Quote,
};

/// Parse the raw `/api/v3/symbols/{ticker}/fundamentals_metrics` response
/// (a JSON array of section objects) into a `FinancialStatement`.
pub fn parse_financials(
    raw: &Value,
    ticker: &str,
    statement_type: &str,
    period: &str,
) -> FinancialStatement {
    let sections = match raw.as_array() {
        Some(arr) => arr,
        None => {
            return FinancialStatement {
                ticker: ticker.to_string(),
                statement_type: statement_type.to_string(),
                period: period.to_string(),
                rows: vec![],
            }
        }
    };

    let mut rows = Vec::new();
    for section in sections {
        let section_rows = match section.get("rows").and_then(|v| v.as_array()) {
            Some(r) => r,
            None => continue,
        };
        for row in section_rows {
            if let Some(parsed) = parse_row(row) {
                rows.push(parsed);
            }
        }
    }

    FinancialStatement {
        ticker: ticker.to_string(),
        statement_type: statement_type.to_string(),
        period: period.to_string(),
        rows,
    }
}

fn parse_row(row: &Value) -> Option<FinancialRow> {
    let name = row.get("name")?.as_str()?.to_string();
    let label = row
        .get("value")
        .and_then(|v| v.as_str())
        .unwrap_or(&name)
        .to_string();
    let bold = row.get("bold").and_then(|v| v.as_bool()).unwrap_or(false);
    let cells_val = row.get("cells")?.as_array()?;

    let cells: Vec<FinancialCell> = cells_val.iter().map(parse_cell).collect();

    Some(FinancialRow {
        name,
        label,
        bold,
        cells,
    })
}

fn parse_cell(cell: &Value) -> FinancialCell {
    let period = cell
        .get("name")
        .and_then(|v| v.as_str())
        .unwrap_or("")
        .to_string();
    let locked = cell.get("class").and_then(|v| v.as_str()) == Some("archive-lock");

    if locked {
        return FinancialCell {
            period,
            raw_value: None,
            value: String::new(),
            yoy_value: None,
            locked: true,
        };
    }

    let raw_value = cell.get("raw_value").and_then(|v| v.as_f64());
    let value = cell
        .get("value")
        .and_then(|v| v.as_str())
        .unwrap_or("")
        .to_string();
    let yoy_value = cell
        .get("yoy_value")
        .and_then(|v| v.as_str())
        .filter(|s| !s.is_empty() && *s != "-")
        .map(|s| s.to_string());

    FinancialCell {
        period,
        raw_value,
        value,
        yoy_value,
        locked: false,
    }
}

/// Parse the raw `/api/v3/symbols/{ticker}/metrics` response into a `Quote`.
///
/// The response has `data` (metric values) and `included` (field name definitions).
/// We build a field_name → value map and extract known fields.
pub fn parse_quote(raw: &Value, ticker: &str) -> Quote {
    // Build metric_type_id -> field_name from included
    let mut id_to_field: std::collections::HashMap<&str, &str> = std::collections::HashMap::new();
    if let Some(included) = raw.get("included").and_then(|v| v.as_array()) {
        for item in included {
            if let (Some(id), Some(field)) = (
                item.get("id").and_then(|v| v.as_str()),
                item.get("attributes")
                    .and_then(|a| a.get("field"))
                    .and_then(|v| v.as_str()),
            ) {
                id_to_field.insert(id, field);
            }
        }
    }

    // Build field_name -> value from data
    let mut field_to_val: std::collections::HashMap<&str, Option<f64>> =
        std::collections::HashMap::new();
    if let Some(data) = raw.get("data").and_then(|v| v.as_array()) {
        for item in data {
            let id_str = match item.get("id").and_then(|v| v.as_str()) {
                Some(s) => s,
                None => continue,
            };
            // Parse "[ticker_id, metric_type_id]" → metric_type_id
            let Some(metric_type_id) = id_str
                .trim_matches(|c| c == '[' || c == ']')
                .split(',')
                .nth(1)
                .map(|s| s.trim())
                .filter(|s| !s.is_empty())
            else {
                continue;
            };

            if let Some(field) = id_to_field.get(metric_type_id) {
                let val = item
                    .get("attributes")
                    .and_then(|a| a.get("value"))
                    .and_then(|v| v.as_f64());
                field_to_val.insert(field, val);
            }
        }
    }

    let get = |field: &str| field_to_val.get(field).copied().flatten();

    Quote {
        ticker: ticker.to_string(),
        price: get("primary_price"),
        close: get("close"),
        high_52w: get("price_high_52w"),
        low_52w: get("price_low_52w"),
        market_cap: get("marketcap"),
        pe_ratio: get("pe_ratio"),
        eps: get("diluted_eps"),
        today_return: get("today_return"),
        avg_volume_1m: get("avg_daily_share_volume_1m"),
    }
}

/// Parse a JSON:API articles list response into `Vec<Article>`.
///
/// Works for both `/api/v3/news` and `/api/v3/analysis` responses.
/// The `content_type` parameter should be `"news"` or `"analysis"` and is used
/// to construct the correct URL prefix.
pub fn parse_articles(raw: &Value, content_type: &str) -> Vec<Article> {
    // Build author_id -> name map from "included"
    // The API uses type "author" for analysis and "newsAuthorUser" for news.
    let mut author_map: std::collections::HashMap<&str, &str> = std::collections::HashMap::new();
    if let Some(included) = raw.get("included").and_then(|v| v.as_array()) {
        for item in included {
            let item_type = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
            if item_type != "author" && item_type != "newsAuthorUser" {
                continue;
            }
            if let (Some(id), Some(nick)) = (
                item.get("id").and_then(|v| v.as_str()),
                item.get("attributes")
                    .and_then(|a| a.get("nick"))
                    .and_then(|v| v.as_str()),
            ) {
                author_map.insert(id, nick);
            }
        }
    }

    let data = match raw.get("data").and_then(|v| v.as_array()) {
        Some(arr) => arr,
        None => return vec![],
    };

    data.iter()
        .filter_map(|item| {
            let id = item.get("id").and_then(|v| v.as_str())?;
            let attrs = item.get("attributes")?;
            let title = attrs
                .get("title")
                .and_then(|v| v.as_str())
                .unwrap_or("")
                .to_string();
            let published_at = attrs
                .get("publishOn")
                .and_then(|v| v.as_str())
                .unwrap_or("")
                .to_string();
            let summary = attrs
                .get("summary")
                .or_else(|| attrs.get("content"))
                .and_then(|v| v.as_str())
                .filter(|s| !s.is_empty())
                .map(strip_html);
            let premium = attrs
                .get("isPaywalled")
                .and_then(|v| v.as_bool())
                .unwrap_or(false);

            // Resolve author from relationships
            let author_id = item
                .pointer("/relationships/author/data/id")
                .and_then(|v| v.as_str())
                .unwrap_or("");
            let author = author_map.get(author_id).unwrap_or(&"").to_string();

            let url_prefix = if content_type == "news" {
                "news"
            } else {
                "article"
            };
            let url = format!("https://seekingalpha.com/{}/{}", url_prefix, id);

            Some(Article {
                id: id.to_string(),
                title,
                author,
                published_at,
                summary,
                url,
                premium,
                article_type: content_type.to_string(),
            })
        })
        .collect()
}

/// Parse a JSON:API single article response into `ArticleContent`.
pub fn parse_article_content(raw: &Value) -> Option<ArticleContent> {
    let data = raw.get("data")?;
    let id = data.get("id").and_then(|v| v.as_str())?;
    let attrs = data.get("attributes")?;
    let title = attrs
        .get("title")
        .and_then(|v| v.as_str())
        .unwrap_or("")
        .to_string();
    let published_at = attrs
        .get("publishOn")
        .and_then(|v| v.as_str())
        .unwrap_or("")
        .to_string();
    let premium = attrs
        .get("isPaywalled")
        .and_then(|v| v.as_bool())
        .unwrap_or(false);
    let teaser = attrs
        .get("teaser")
        .and_then(|v| v.as_bool())
        .unwrap_or(false);

    let html_content = attrs.get("content").and_then(|v| v.as_str()).unwrap_or("");
    let content = strip_html(html_content);
    let truncated = teaser || (premium && content.len() < 500);

    // Resolve author from included (type is "author" or "newsAuthorUser")
    let author_id = data
        .pointer("/relationships/author/data/id")
        .and_then(|v| v.as_str())
        .unwrap_or("");
    let mut author = String::new();
    if let Some(included) = raw.get("included").and_then(|v| v.as_array()) {
        for item in included {
            let item_type = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
            if (item_type == "author" || item_type == "newsAuthorUser")
                && item.get("id").and_then(|v| v.as_str()) == Some(author_id)
            {
                author = item
                    .get("attributes")
                    .and_then(|a| a.get("nick"))
                    .and_then(|v| v.as_str())
                    .unwrap_or("")
                    .to_string();
                break;
            }
        }
    }

    let article_type = data
        .get("type")
        .and_then(|v| v.as_str())
        .unwrap_or("article");
    let url_prefix = if article_type == "news" {
        "news"
    } else {
        "article"
    };
    let url = format!("https://seekingalpha.com/{}/{}", url_prefix, id);

    Some(ArticleContent {
        id: id.to_string(),
        title,
        author,
        published_at,
        content,
        url,
        premium,
        truncated,
    })
}

/// Strip HTML tags and decode common HTML entities.
fn strip_html(html: &str) -> String {
    strip_html_raw(html).trim().to_string()
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

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

    // --- parse_financials ---

    #[test]
    fn test_parse_financials_basic_row() {
        let raw = json!([
            {
                "title": "Revenue",
                "type": "section",
                "rows": [
                    {
                        "name": "revenues",
                        "value": "Revenues",
                        "bold": false,
                        "item_id": 112,
                        "type": "row",
                        "force_display": false,
                        "cells": [
                            {
                                "class": "archive-lock",
                                "name": "Dec 2020",
                                "revenue_percent": "",
                                "value": false
                            },
                            {
                                "name": "Dec 2021",
                                "raw_value": 2906771.0,
                                "revenue_percent": "100.00%",
                                "value": "2.9",
                                "yoy_value": "-"
                            },
                            {
                                "name": "TTM",
                                "raw_value": 50731000.0,
                                "revenue_percent": "100.00%",
                                "value": "50.7",
                                "yoy_value": "605.28%"
                            }
                        ]
                    }
                ]
            }
        ]);

        let stmt = parse_financials(&raw, "ONDS", "income-statement", "annual");
        assert_eq!(stmt.ticker, "ONDS");
        assert_eq!(stmt.rows.len(), 1);

        let row = &stmt.rows[0];
        assert_eq!(row.name, "revenues");
        assert_eq!(row.label, "Revenues");
        assert!(!row.bold);
        assert_eq!(row.cells.len(), 3);

        // Locked cell
        assert!(row.cells[0].locked);
        assert_eq!(row.cells[0].period, "Dec 2020");
        assert!(row.cells[0].raw_value.is_none());

        // Unlocked cell
        assert!(!row.cells[1].locked);
        assert_eq!(row.cells[1].period, "Dec 2021");
        assert_eq!(row.cells[1].raw_value, Some(2906771.0));
        assert_eq!(row.cells[1].value, "2.9");
        assert!(row.cells[1].yoy_value.is_none()); // "-" is filtered to None

        // TTM cell
        assert_eq!(row.cells[2].period, "TTM");
        assert_eq!(row.cells[2].raw_value, Some(50731000.0));
        assert_eq!(row.cells[2].yoy_value.as_deref(), Some("605.28%"));
    }

    #[test]
    fn test_parse_financials_bold_row() {
        let raw = json!([{
            "type": "section",
            "rows": [{
                "name": "gross_profit",
                "value": "Gross Profit",
                "bold": true,
                "type": "row",
                "cells": []
            }]
        }]);

        let stmt = parse_financials(&raw, "AAPL", "income-statement", "annual");
        assert!(stmt.rows[0].bold);
        assert_eq!(stmt.rows[0].label, "Gross Profit");
    }

    #[test]
    fn test_parse_financials_multiple_sections() {
        let raw = json!([
            { "type": "section", "rows": [
                { "name": "revenues", "value": "Revenues", "bold": false, "type": "row", "cells": [] },
                { "name": "cogs", "value": "Cost Of Revenues", "bold": false, "type": "row", "cells": [] }
            ]},
            { "type": "section", "rows": [
                { "name": "sga", "value": "SG&A", "bold": false, "type": "row", "cells": [] }
            ]}
        ]);

        let stmt = parse_financials(&raw, "ONDS", "income-statement", "annual");
        assert_eq!(stmt.rows.len(), 3);
        assert_eq!(stmt.rows[0].name, "revenues");
        assert_eq!(stmt.rows[2].name, "sga");
    }

    #[test]
    fn test_parse_financials_empty_response() {
        let raw = json!([]);
        let stmt = parse_financials(&raw, "ONDS", "income-statement", "annual");
        assert!(stmt.rows.is_empty());
    }

    #[test]
    fn test_parse_financials_not_array() {
        let raw = json!({ "error": "bad request" });
        let stmt = parse_financials(&raw, "ONDS", "income-statement", "annual");
        assert!(stmt.rows.is_empty());
    }

    // --- parse_quote ---

    fn make_quote_response(fields: &[(&str, &str, Option<f64>)]) -> Value {
        // fields: (metric_type_id, field_name, value)
        let mut data = vec![];
        let mut included = vec![];
        for (type_id, field, val) in fields {
            data.push(json!({
                "id": format!("[578851, {}]", type_id),
                "type": "metric",
                "attributes": { "value": val }
            }));
            included.push(json!({
                "id": type_id,
                "type": "metric_type",
                "attributes": { "field": field }
            }));
        }
        json!({ "data": data, "included": included })
    }

    #[test]
    fn test_parse_quote_full() {
        let raw = make_quote_response(&[
            ("1536472", "primary_price", Some(9.14)),
            ("234834", "close", Some(9.13)),
            ("211383", "price_high_52w", Some(15.28)),
            ("211384", "price_low_52w", Some(0.6904)),
            ("7915", "marketcap", Some(4403993000.0)),
            ("111", "pe_ratio", None),
            ("574861", "diluted_eps", Some(-0.61853)),
            ("3133642", "today_return", Some(-0.109409)),
            ("110540", "avg_daily_share_volume_1m", Some(75274491.0)),
        ]);

        let quote = parse_quote(&raw, "ONDS");
        assert_eq!(quote.ticker, "ONDS");
        assert_eq!(quote.price, Some(9.14));
        assert_eq!(quote.close, Some(9.13));
        assert_eq!(quote.high_52w, Some(15.28));
        assert_eq!(quote.low_52w, Some(0.6904));
        assert_eq!(quote.market_cap, Some(4403993000.0));
        assert!(quote.pe_ratio.is_none()); // null for loss-making companies
        assert_eq!(quote.eps, Some(-0.61853));
        assert!(quote.today_return.is_some());
        assert!(quote.avg_volume_1m.is_some());
    }

    #[test]
    fn test_parse_quote_missing_fields() {
        // Only price is present
        let raw = make_quote_response(&[("1536472", "primary_price", Some(42.0))]);
        let quote = parse_quote(&raw, "TSLA");
        assert_eq!(quote.price, Some(42.0));
        assert!(quote.market_cap.is_none());
        assert!(quote.pe_ratio.is_none());
    }

    #[test]
    fn test_parse_quote_empty_response() {
        let raw = json!({ "data": [], "included": [] });
        let quote = parse_quote(&raw, "AAPL");
        assert_eq!(quote.ticker, "AAPL");
        assert!(quote.price.is_none());
    }

    // --- parse_articles ---

    #[test]
    fn test_parse_articles_basic() {
        let raw = json!({
            "data": [
                {
                    "id": "4756321",
                    "type": "news",
                    "attributes": {
                        "title": "Stock Surges on Earnings Beat",
                        "publishOn": "2026-04-12T10:30:00Z",
                        "summary": "Company reported strong Q1 results.",
                        "isPaywalled": false
                    },
                    "relationships": {
                        "author": { "data": { "id": "12345", "type": "author" } }
                    }
                }
            ],
            "included": [
                {
                    "id": "12345",
                    "type": "author",
                    "attributes": { "nick": "John Doe" }
                }
            ]
        });

        let articles = parse_articles(&raw, "news");
        assert_eq!(articles.len(), 1);
        assert_eq!(articles[0].id, "4756321");
        assert_eq!(articles[0].title, "Stock Surges on Earnings Beat");
        assert_eq!(articles[0].author, "John Doe");
        assert_eq!(articles[0].published_at, "2026-04-12T10:30:00Z");
        assert_eq!(
            articles[0].summary.as_deref(),
            Some("Company reported strong Q1 results.")
        );
        assert_eq!(articles[0].url, "https://seekingalpha.com/news/4756321");
        assert!(!articles[0].premium);
        assert_eq!(articles[0].article_type, "news");
    }

    #[test]
    fn test_parse_articles_analysis_url() {
        let raw = json!({
            "data": [{
                "id": "999",
                "type": "article",
                "attributes": {
                    "title": "Deep Dive",
                    "publishOn": "2026-04-11T08:00:00Z",
                    "isPaywalled": true
                },
                "relationships": {
                    "author": { "data": { "id": "1", "type": "author" } }
                }
            }],
            "included": [
                { "id": "1", "type": "author", "attributes": { "nick": "Analyst A" } }
            ]
        });

        let articles = parse_articles(&raw, "analysis");
        assert_eq!(articles[0].url, "https://seekingalpha.com/article/999");
        assert!(articles[0].premium);
        assert_eq!(articles[0].article_type, "analysis");
        assert!(articles[0].summary.is_none());
    }

    #[test]
    fn test_parse_articles_empty() {
        let raw = json!({ "data": [], "included": [] });
        assert!(parse_articles(&raw, "news").is_empty());
    }

    #[test]
    fn test_parse_articles_missing_data() {
        let raw = json!({ "error": "not found" });
        assert!(parse_articles(&raw, "news").is_empty());
    }

    #[test]
    fn test_parse_articles_unknown_author() {
        let raw = json!({
            "data": [{
                "id": "1",
                "type": "news",
                "attributes": {
                    "title": "Title",
                    "publishOn": "2026-01-01T00:00:00Z"
                },
                "relationships": {
                    "author": { "data": { "id": "unknown_id", "type": "author" } }
                }
            }],
            "included": []
        });

        let articles = parse_articles(&raw, "news");
        assert_eq!(articles[0].author, "");
    }

    // --- parse_article_content ---

    #[test]
    fn test_parse_article_content_basic() {
        let raw = json!({
            "data": {
                "id": "4756321",
                "type": "article",
                "attributes": {
                    "title": "Deep Analysis",
                    "publishOn": "2026-04-12T10:30:00Z",
                    "content": "<p>This is the <b>full</b> article content.</p><p>Second paragraph with &amp; entity.</p>",
                    "isPaywalled": false,
                    "teaser": false
                },
                "relationships": {
                    "author": { "data": { "id": "42", "type": "author" } }
                }
            },
            "included": [
                { "id": "42", "type": "author", "attributes": { "nick": "Jane Smith" } }
            ]
        });

        let content = parse_article_content(&raw).unwrap();
        assert_eq!(content.id, "4756321");
        assert_eq!(content.title, "Deep Analysis");
        assert_eq!(content.author, "Jane Smith");
        assert_eq!(
            content.content,
            "This is the full article content.Second paragraph with & entity."
        );
        assert_eq!(content.url, "https://seekingalpha.com/article/4756321");
        assert!(!content.premium);
        assert!(!content.truncated);
    }

    #[test]
    fn test_parse_article_content_truncated_teaser() {
        let raw = json!({
            "data": {
                "id": "100",
                "type": "article",
                "attributes": {
                    "title": "Premium Article",
                    "publishOn": "2026-04-12T00:00:00Z",
                    "content": "<p>Short preview.</p>",
                    "isPaywalled": true,
                    "teaser": true
                },
                "relationships": {
                    "author": { "data": { "id": "1", "type": "author" } }
                }
            },
            "included": [
                { "id": "1", "type": "author", "attributes": { "nick": "Author" } }
            ]
        });

        let content = parse_article_content(&raw).unwrap();
        assert!(content.premium);
        assert!(content.truncated);
    }

    #[test]
    fn test_parse_article_content_news_url() {
        let raw = json!({
            "data": {
                "id": "200",
                "type": "news",
                "attributes": {
                    "title": "Breaking News",
                    "publishOn": "2026-04-12T00:00:00Z",
                    "content": "Plain text content",
                    "isPaywalled": false,
                    "teaser": false
                },
                "relationships": {
                    "author": { "data": { "id": "1", "type": "author" } }
                }
            },
            "included": [
                { "id": "1", "type": "author", "attributes": { "nick": "Reporter" } }
            ]
        });

        let content = parse_article_content(&raw).unwrap();
        assert_eq!(content.url, "https://seekingalpha.com/news/200");
    }

    #[test]
    fn test_parse_article_content_missing_data() {
        let raw = json!({ "error": "not found" });
        assert!(parse_article_content(&raw).is_none());
    }

    // --- strip_html ---

    #[test]
    fn test_strip_html() {
        assert_eq!(strip_html("<p>Hello <b>world</b></p>"), "Hello world");
    }

    #[test]
    fn test_strip_html_entities() {
        assert_eq!(
            strip_html("A &amp; B &lt; C &gt; D &quot;E&quot; F&#39;s"),
            "A & B < C > D \"E\" F's"
        );
    }

    #[test]
    fn test_strip_html_empty() {
        assert_eq!(strip_html(""), "");
    }

    #[test]
    fn test_strip_html_nested_and_unclosed() {
        assert_eq!(
            strip_html("<div><p>Nested <b>bold</b></p><br>After break"),
            "Nested boldAfter break"
        );
    }

    #[test]
    fn test_strip_html_nbsp() {
        assert_eq!(strip_html("A&nbsp;B"), "A B");
    }

    // --- edge cases: parse_articles ---

    #[test]
    fn test_parse_articles_mixed_included_types() {
        let raw = json!({
            "data": [{
                "id": "1",
                "type": "news",
                "attributes": {
                    "title": "Title",
                    "publishOn": "2026-01-01T00:00:00Z"
                },
                "relationships": {
                    "author": { "data": { "id": "42", "type": "newsAuthorUser" } }
                }
            }],
            "included": [
                { "id": "99", "type": "tag", "attributes": { "name": "AAPL" } },
                { "id": "42", "type": "newsAuthorUser", "attributes": { "nick": "News Editor" } },
                { "id": "50", "type": "sentiment", "attributes": { "score": 0.8 } }
            ]
        });

        let articles = parse_articles(&raw, "news");
        assert_eq!(articles.len(), 1);
        assert_eq!(articles[0].author, "News Editor");
    }

    #[test]
    fn test_parse_articles_missing_relationships() {
        let raw = json!({
            "data": [{
                "id": "1",
                "type": "news",
                "attributes": {
                    "title": "No Author Article",
                    "publishOn": "2026-01-01T00:00:00Z"
                }
            }],
            "included": []
        });

        let articles = parse_articles(&raw, "news");
        assert_eq!(articles.len(), 1);
        assert_eq!(articles[0].author, "");
        assert_eq!(articles[0].title, "No Author Article");
    }

    #[test]
    fn test_parse_articles_content_as_summary() {
        let raw = json!({
            "data": [{
                "id": "1",
                "type": "news",
                "attributes": {
                    "title": "Title",
                    "publishOn": "2026-01-01T00:00:00Z",
                    "content": "<p>This is the <b>news</b> content.</p>"
                },
                "relationships": {}
            }],
            "included": []
        });

        let articles = parse_articles(&raw, "news");
        assert_eq!(
            articles[0].summary.as_deref(),
            Some("This is the news content.")
        );
    }

    // --- edge cases: parse_article_content ---

    #[test]
    fn test_parse_article_content_missing_content_field() {
        let raw = json!({
            "data": {
                "id": "1",
                "type": "article",
                "attributes": {
                    "title": "No Content",
                    "publishOn": "2026-01-01T00:00:00Z",
                    "isPaywalled": false,
                    "teaser": false
                },
                "relationships": {}
            },
            "included": []
        });

        let content = parse_article_content(&raw).unwrap();
        assert_eq!(content.content, "");
        assert!(!content.truncated);
    }

    #[test]
    fn test_parse_article_content_premium_short_content() {
        let raw = json!({
            "data": {
                "id": "1",
                "type": "article",
                "attributes": {
                    "title": "Premium Short",
                    "publishOn": "2026-01-01T00:00:00Z",
                    "content": "<p>Short preview only.</p>",
                    "isPaywalled": true,
                    "teaser": false
                },
                "relationships": {}
            },
            "included": []
        });

        let content = parse_article_content(&raw).unwrap();
        assert!(content.premium);
        assert!(content.truncated); // premium + short content = truncated
    }

    // --- edge cases: parse_quote ---

    #[test]
    fn test_parse_quote_malformed_id() {
        let raw = json!({
            "data": [
                {
                    "id": "not_a_bracket_id",
                    "type": "metric",
                    "attributes": { "value": 42.0 }
                }
            ],
            "included": [
                { "id": "123", "type": "metric_type", "attributes": { "field": "primary_price" } }
            ]
        });

        let quote = parse_quote(&raw, "TEST");
        assert_eq!(quote.ticker, "TEST");
        assert!(quote.price.is_none()); // malformed ID can't resolve
    }
}
