use std::collections::{HashMap, HashSet};
use std::sync::Mutex;
use std::time::Duration;

use chrono::{FixedOffset, NaiveDate, NaiveTime, TimeZone, Timelike, Utc};
use chrono_tz::America::New_York;
use futures::future::BoxFuture;
use regex::Regex;
use reqwest::header::{HeaderMap, HeaderValue, ACCEPT_LANGUAGE, REFERER};
use reqwest::{Client, StatusCode};
use serde_json::{json, Value};
use tokio::sync::Mutex as AsyncMutex;

use crate::terminal::security_lookup::{
    get_cached_value, get_cached_value_within, normalize_search_query, normalize_symbol,
    store_cached_value, CacheEntry, RequestGate, SecurityKind, SecurityLookup, SecurityLookupError,
    SecurityMatch,
};
use crate::terminal::{Company, CompanyPricePoint, CompanyProfile};

const GOOGLE_FINANCE_HOME: &str = "https://www.google.com/finance/";
const GOOGLE_FINANCE_BATCH_ENDPOINT: &str =
    "https://www.google.com/finance/_/GoogleFinanceUi/data/batchexecute";
const DEFAULT_USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
const DEFAULT_MIN_REQUEST_SPACING_MS: u64 = 1_500;
const DEFAULT_COOLDOWN_SECS: u64 = 15 * 60;
const SEARCH_CACHE_TTL: Duration = Duration::from_secs(24 * 60 * 60);
const SYMBOL_EXCHANGE_CACHE_TTL: Duration = Duration::from_secs(7 * 24 * 60 * 60);
const SESSION_CACHE_TTL: Duration = Duration::from_secs(30 * 60);
const COMPANY_ACTIVE_CACHE_TTL: Duration = Duration::from_secs(2 * 60);
const COMPANY_INACTIVE_CACHE_TTL: Duration = Duration::from_secs(12 * 60 * 60);
const COMPANY_STALE_IF_ERROR_TTL: Duration = Duration::from_secs(24 * 60 * 60);
const MIN_REQUEST_JITTER: Duration = Duration::from_millis(150);
const MAX_REQUEST_JITTER: Duration = Duration::from_millis(350);
const RETRY_DELAYS: [Duration; 2] = [Duration::from_secs(1), Duration::from_secs(3)];
const PRICE_CHART_RANGES: [(&str, i64); 8] = [
    ("1D", 1),
    ("5D", 2),
    ("1M", 3),
    ("6M", 4),
    ("YTD", 5),
    ("1Y", 6),
    ("5Y", 7),
    ("MAX", 8),
];

pub(crate) struct GoogleFinanceLookup {
    client: Client,
    search_cache: Mutex<HashMap<String, CacheEntry<Vec<SecurityMatch>>>>,
    symbol_exchange_cache: Mutex<HashMap<String, CacheEntry<String>>>,
    company_cache: Mutex<HashMap<String, CacheEntry<Company>>>,
    session_cache: Mutex<Option<CacheEntry<BatchSession>>>,
    request_gate: AsyncMutex<RequestGate>,
    request_lane: AsyncMutex<()>,
    cooldown: Duration,
}

#[derive(Debug, Clone)]
struct BatchSession {
    sid: String,
    bl: String,
}

#[derive(Debug)]
enum HttpFetchError {
    Blocked(String),
    Failed(String),
}

impl Default for GoogleFinanceLookup {
    fn default() -> Self {
        let user_agent = std::env::var("GOOGLE_FINANCE_USER_AGENT")
            .ok()
            .filter(|value| !value.trim().is_empty())
            .unwrap_or_else(|| DEFAULT_USER_AGENT.to_string());
        let min_request_spacing_ms = std::env::var("GOOGLE_FINANCE_MIN_REQUEST_SPACING_MS")
            .ok()
            .and_then(|value| value.parse::<u64>().ok())
            .unwrap_or(DEFAULT_MIN_REQUEST_SPACING_MS);
        let cooldown_secs = std::env::var("GOOGLE_FINANCE_COOLDOWN_SECS")
            .ok()
            .and_then(|value| value.parse::<u64>().ok())
            .unwrap_or(DEFAULT_COOLDOWN_SECS);

        let mut default_headers = HeaderMap::new();
        default_headers.insert(ACCEPT_LANGUAGE, HeaderValue::from_static("en-US,en;q=0.9"));
        default_headers.insert(REFERER, HeaderValue::from_static(GOOGLE_FINANCE_HOME));

        let client = Client::builder()
            .cookie_store(true)
            .default_headers(default_headers)
            .user_agent(user_agent)
            .build()
            .expect("google finance reqwest client");

        Self {
            client,
            search_cache: Mutex::new(HashMap::new()),
            symbol_exchange_cache: Mutex::new(HashMap::new()),
            company_cache: Mutex::new(HashMap::new()),
            session_cache: Mutex::new(None),
            request_gate: AsyncMutex::new(RequestGate::new(
                Duration::from_millis(min_request_spacing_ms),
                MIN_REQUEST_JITTER,
                MAX_REQUEST_JITTER,
            )),
            request_lane: AsyncMutex::new(()),
            cooldown: Duration::from_secs(cooldown_secs),
        }
    }
}

impl SecurityLookup for GoogleFinanceLookup {
    fn provider_name(&self) -> &'static str {
        "Google Finance"
    }

    fn search<'a>(
        &'a self,
        query: &'a str,
    ) -> BoxFuture<'a, Result<Vec<SecurityMatch>, SecurityLookupError>> {
        Box::pin(async move {
            let normalized_query = normalize_search_query(query);
            if let Some(cached_matches) =
                get_cached_value(&self.search_cache, &normalized_query, SEARCH_CACHE_TTL)
            {
                return Ok(cached_matches);
            }

            let session = self.batch_session().await.map_err(|detail| {
                SecurityLookupError::SearchUnavailable {
                    query: query.to_string(),
                    detail,
                }
            })?;

            let body = self
                .post_batch_rpc(
                    "mKsvE",
                    "/finance/",
                    json!([query, Value::Null, 1, 1]),
                    &session,
                    GOOGLE_FINANCE_HOME,
                )
                .await
                .map_err(|error| self.map_search_http_error(query, error))?;

            let matches = decode_search_matches(&body).map_err(|detail| {
                SecurityLookupError::SearchUnavailable {
                    query: query.to_string(),
                    detail,
                }
            })?;

            for security_match in &matches {
                if let Some(exchange) = security_match.exchange.as_deref() {
                    self.store_symbol_exchange(&security_match.symbol, exchange);
                }
            }

            store_cached_value(&self.search_cache, normalized_query, matches.clone());
            Ok(matches)
        })
    }

    fn load_company<'a>(
        &'a self,
        security_match: &'a SecurityMatch,
    ) -> BoxFuture<'a, Result<Company, SecurityLookupError>> {
        Box::pin(async move {
            let symbol = normalize_symbol(&security_match.symbol);
            let exchange = self.resolve_exchange(security_match).await?;
            let cache_key = format!("{symbol}:{exchange}");

            if let Some(cached_company) =
                get_cached_value(&self.company_cache, &cache_key, company_cache_ttl())
            {
                return Ok(cached_company);
            }

            let quote_url = format!(
                "https://www.google.com/finance/quote/{}:{}?hl=en-US",
                symbol, exchange
            );
            let quote_html = match self.fetch_text(&quote_url, &quote_url).await {
                Ok(body) => body,
                Err(error) => {
                    if let Some(stale_company) = get_cached_value_within(
                        &self.company_cache,
                        &cache_key,
                        COMPANY_STALE_IF_ERROR_TTL,
                    ) {
                        return Ok(stale_company);
                    }

                    return Err(self.map_detail_http_error(&symbol, error));
                }
            };

            let mut batch_session = parse_batch_session(&quote_html).ok().inspect(|session| {
                if let Ok(mut guard) = self.session_cache.lock() {
                    *guard = Some(CacheEntry::new(session.clone()));
                }
            });
            if batch_session.is_none() {
                batch_session = self.batch_session().await.ok();
            }
            let price_chart_ranges = if let Some(session) = batch_session.as_ref() {
                self.fetch_price_chart_ranges(&symbol, &exchange, session, &quote_url)
                    .await
                    .ok()
            } else {
                None
            };

            let company = parse_company_from_quote_html(
                &quote_html,
                security_match,
                &symbol,
                &exchange,
                price_chart_ranges,
            )
            .map_err(|detail| SecurityLookupError::DetailUnavailable {
                symbol: symbol.clone(),
                detail,
            })?;

            store_cached_value(&self.company_cache, cache_key, company.clone());
            self.store_symbol_exchange(&symbol, &exchange);
            Ok(company)
        })
    }
}

impl GoogleFinanceLookup {
    async fn batch_session(&self) -> Result<BatchSession, String> {
        if let Ok(guard) = self.session_cache.lock() {
            if let Some(session) = guard
                .as_ref()
                .filter(|entry| entry.is_fresh(SESSION_CACHE_TTL))
                .map(|entry| entry.cloned_value())
            {
                return Ok(session);
            }
        }

        let body = self
            .fetch_text(GOOGLE_FINANCE_HOME, GOOGLE_FINANCE_HOME)
            .await
            .map_err(|error| self.describe_http_error(error))?;
        let session = parse_batch_session(&body)?;

        if let Ok(mut guard) = self.session_cache.lock() {
            *guard = Some(CacheEntry::new(session.clone()));
        }

        Ok(session)
    }

    async fn post_batch_rpc(
        &self,
        rpc_id: &str,
        source_path: &str,
        request: Value,
        session: &BatchSession,
        referer: &str,
    ) -> Result<String, HttpFetchError> {
        let request_json = serde_json::to_string(&request)
            .map_err(|error| HttpFetchError::Failed(error.to_string()))?;
        let payload =
            serde_json::to_string(&json!([[[rpc_id, request_json, Value::Null, "generic"]]]))
                .map_err(|error| HttpFetchError::Failed(error.to_string()))?;
        let req_id = (Utc::now().timestamp_subsec_millis() % 900_000) + 100_000;
        let url = format!(
            "{GOOGLE_FINANCE_BATCH_ENDPOINT}?rpcids={rpc_id}&source-path={}&f.sid={}&bl={}&hl=en-US&soc-app=162&soc-platform=1&soc-device=1&_reqid={req_id}&rt=c",
            urlencoding::encode(source_path),
            urlencoding::encode(&session.sid),
            urlencoding::encode(&session.bl),
        );
        let body = format!("f.req={}&", urlencoding::encode(&payload));

        self.execute_request(
            || {
                self.client
                    .post(&url)
                    .header(
                        "Content-Type",
                        "application/x-www-form-urlencoded;charset=UTF-8",
                    )
                    .header(REFERER, referer)
                    .header("Origin", "https://www.google.com")
                    .header("X-Same-Domain", "1")
                    .body(body.clone())
            },
            true,
        )
        .await
    }

    async fn post_batch_rpc_calls(
        &self,
        rpc_id: &str,
        source_path: &str,
        requests: &[Value],
        session: &BatchSession,
        referer: &str,
    ) -> Result<String, HttpFetchError> {
        let mut calls = Vec::with_capacity(requests.len());
        for request in requests {
            let request_json = serde_json::to_string(request)
                .map_err(|error| HttpFetchError::Failed(error.to_string()))?;
            calls.push(json!([rpc_id, request_json, Value::Null, "generic"]));
        }

        let payload = serde_json::to_string(&vec![calls])
            .map_err(|error| HttpFetchError::Failed(error.to_string()))?;
        let req_id = (Utc::now().timestamp_subsec_millis() % 900_000) + 100_000;
        let url = format!(
            "{GOOGLE_FINANCE_BATCH_ENDPOINT}?rpcids={rpc_id}&source-path={}&f.sid={}&bl={}&hl=en-US&soc-app=162&soc-platform=1&soc-device=1&_reqid={req_id}&rt=c",
            urlencoding::encode(source_path),
            urlencoding::encode(&session.sid),
            urlencoding::encode(&session.bl),
        );
        let body = format!("f.req={}&", urlencoding::encode(&payload));

        self.execute_request(
            || {
                self.client
                    .post(&url)
                    .header(
                        "Content-Type",
                        "application/x-www-form-urlencoded;charset=UTF-8",
                    )
                    .header(REFERER, referer)
                    .header("Origin", "https://www.google.com")
                    .header("X-Same-Domain", "1")
                    .body(body.clone())
            },
            true,
        )
        .await
    }

    async fn fetch_text(&self, url: &str, referer: &str) -> Result<String, HttpFetchError> {
        self.execute_request(|| self.client.get(url).header(REFERER, referer), true)
            .await
    }

    async fn execute_request<F>(
        &self,
        mut build_request: F,
        allow_transient_retries: bool,
    ) -> Result<String, HttpFetchError>
    where
        F: FnMut() -> reqwest::RequestBuilder,
    {
        let _request_lane = self.request_lane.lock().await;
        let mut last_error = None;

        for retry_delay in RETRY_DELAYS
            .into_iter()
            .map(Some)
            .chain(std::iter::once(None))
        {
            self.wait_for_request_slot().await;

            match build_request().send().await {
                Ok(response) => {
                    let status = response.status();
                    let body = response.text().await.unwrap_or_else(|error| {
                        format!("Failed to read Google Finance response body: {error}")
                    });

                    if is_blocked_response(status, &body) {
                        self.record_block().await;
                        return Err(HttpFetchError::Blocked(blocked_detail(status, &body)));
                    }

                    if status.is_success() {
                        return Ok(body);
                    }

                    if allow_transient_retries && status.is_server_error() {
                        if let Some(delay) = retry_delay {
                            last_error = Some(format!("Google Finance returned HTTP {status}."));
                            tokio::time::sleep(delay).await;
                            continue;
                        }
                    }

                    return Err(HttpFetchError::Failed(format!(
                        "Google Finance returned HTTP {status}."
                    )));
                }
                Err(error) => {
                    if allow_transient_retries && (error.is_connect() || error.is_timeout()) {
                        if let Some(delay) = retry_delay {
                            last_error = Some(error.to_string());
                            tokio::time::sleep(delay).await;
                            continue;
                        }
                    }

                    return Err(HttpFetchError::Failed(error.to_string()));
                }
            }
        }

        Err(HttpFetchError::Failed(last_error.unwrap_or_else(|| {
            "Google Finance request failed.".to_string()
        })))
    }

    async fn wait_for_request_slot(&self) {
        let wait_duration = {
            let mut gate = self.request_gate.lock().await;
            gate.reserve_slot()
        };

        if !wait_duration.is_zero() {
            tokio::time::sleep(wait_duration).await;
        }
    }

    async fn record_block(&self) {
        let mut gate = self.request_gate.lock().await;
        gate.extend_cooldown(self.cooldown);
    }

    fn map_search_http_error(&self, query: &str, error: HttpFetchError) -> SecurityLookupError {
        SecurityLookupError::SearchUnavailable {
            query: query.to_string(),
            detail: self.describe_http_error(error),
        }
    }

    fn map_detail_http_error(&self, symbol: &str, error: HttpFetchError) -> SecurityLookupError {
        SecurityLookupError::DetailUnavailable {
            symbol: symbol.to_string(),
            detail: self.describe_http_error(error),
        }
    }

    fn describe_http_error(&self, error: HttpFetchError) -> String {
        match error {
            HttpFetchError::Blocked(detail) | HttpFetchError::Failed(detail) => detail,
        }
    }

    fn store_symbol_exchange(&self, symbol: &str, exchange: &str) {
        store_cached_value(
            &self.symbol_exchange_cache,
            normalize_symbol(symbol),
            exchange.to_string(),
        );
    }

    async fn resolve_exchange(
        &self,
        security_match: &SecurityMatch,
    ) -> Result<String, SecurityLookupError> {
        if let Some(exchange) = security_match
            .exchange
            .as_deref()
            .map(str::trim)
            .filter(|value| !value.is_empty())
        {
            self.store_symbol_exchange(&security_match.symbol, exchange);
            return Ok(exchange.to_string());
        }

        let symbol = normalize_symbol(&security_match.symbol);
        if let Some(exchange) = get_cached_value(
            &self.symbol_exchange_cache,
            &symbol,
            SYMBOL_EXCHANGE_CACHE_TTL,
        ) {
            return Ok(exchange);
        }

        let matches = self.search(&symbol).await?;
        let selected = matches
            .iter()
            .filter(|candidate| candidate.symbol.eq_ignore_ascii_case(&symbol))
            .min_by_key(|candidate| exchange_priority(candidate.exchange.as_deref()))
            .and_then(|candidate| candidate.exchange.clone())
            .ok_or_else(|| SecurityLookupError::DetailUnavailable {
                symbol: symbol.clone(),
                detail: format!("Google Finance did not resolve an exchange for \"{symbol}\"."),
            })?;

        self.store_symbol_exchange(&symbol, &selected);
        Ok(selected)
    }

    async fn fetch_price_chart_ranges(
        &self,
        symbol: &str,
        exchange: &str,
        session: &BatchSession,
        referer: &str,
    ) -> Result<HashMap<String, Vec<CompanyPricePoint>>, String> {
        let source_path = format!("/finance/quote/{symbol}:{exchange}");
        let requests = PRICE_CHART_RANGES
            .iter()
            .map(|(_, window_index)| {
                json!([
                    [[Value::Null, [symbol, exchange]]],
                    window_index,
                    Value::Null,
                    Value::Null,
                    Value::Null,
                    Value::Null,
                    Value::Null,
                    0
                ])
            })
            .collect::<Vec<_>>();

        let body = self
            .post_batch_rpc_calls("AiCwsd", &source_path, &requests, session, referer)
            .await
            .map_err(|error| self.describe_http_error(error))?;
        decode_chart_range_map(&body)
    }
}

fn parse_batch_session(body: &str) -> Result<BatchSession, String> {
    let sid = capture_string(body, "FdrFJe").ok_or_else(|| {
        "Google Finance did not expose a batch session id for this request.".to_string()
    })?;
    let bl = capture_string(body, "cfb2h").ok_or_else(|| {
        "Google Finance did not expose a build label for this request.".to_string()
    })?;

    Ok(BatchSession { sid, bl })
}

fn capture_string(body: &str, key: &str) -> Option<String> {
    let pattern = format!(r#""{}":"([^"]+)""#, regex::escape(key));
    Regex::new(&pattern)
        .ok()?
        .captures(body)
        .and_then(|captures| captures.get(1))
        .map(|value| value.as_str().to_string())
}

fn decode_search_matches(response: &str) -> Result<Vec<SecurityMatch>, String> {
    let payload = decode_batchexecute_payload(response, "mKsvE")?;
    let value = serde_json::from_str::<Value>(&payload)
        .map_err(|error| format!("Failed to decode Google Finance search payload: {error}"))?;

    let mut matches = Vec::new();
    collect_search_matches(&value, &mut matches);

    let mut seen = HashSet::new();
    let deduped = matches
        .into_iter()
        .filter(|security_match| {
            let key = format!(
                "{}:{}",
                security_match.symbol,
                security_match.exchange.as_deref().unwrap_or_default()
            );
            seen.insert(key)
        })
        .collect::<Vec<_>>();

    Ok(deduped)
}

fn decode_batchexecute_payload(response: &str, rpc_id: &str) -> Result<String, String> {
    decode_batchexecute_payloads(response, rpc_id)?
        .into_iter()
        .next()
        .ok_or_else(|| {
            format!("Google Finance batchexecute response did not include an {rpc_id} payload.")
        })
}

fn decode_batchexecute_payloads(response: &str, rpc_id: &str) -> Result<Vec<String>, String> {
    let mut payloads = Vec::new();

    for line in response
        .lines()
        .map(str::trim)
        .filter(|line| !line.is_empty())
    {
        if !line.starts_with("[[") {
            continue;
        }

        let rows = serde_json::from_str::<Value>(line).map_err(|error| {
            format!("Failed to decode Google Finance batchexecute envelope: {error}")
        })?;
        let Some(rows) = rows.as_array() else {
            continue;
        };

        for row in rows {
            let Some(row_items) = row.as_array() else {
                continue;
            };
            if row_items.get(1).and_then(Value::as_str) != Some(rpc_id) {
                continue;
            }

            if let Some(payload) = row_items.get(2).and_then(Value::as_str) {
                payloads.push(payload.to_string());
            }
        }
    }

    if payloads.is_empty() {
        Err(format!(
            "Google Finance batchexecute response did not include an {rpc_id} payload."
        ))
    } else {
        Ok(payloads)
    }
}

fn collect_search_matches(value: &Value, matches: &mut Vec<SecurityMatch>) {
    let Some(items) = value.as_array() else {
        return;
    };

    if let Some(security_match) = match_from_value(items) {
        matches.push(security_match);
    }

    for item in items {
        collect_search_matches(item, matches);
    }
}

fn match_from_value(items: &[Value]) -> Option<SecurityMatch> {
    if items.first()?.as_str()?.trim().is_empty() {
        return None;
    }

    let listing = items.get(1)?.as_array()?;
    let symbol = listing.first()?.as_str()?.trim();
    let exchange = listing.get(1)?.as_str()?.trim();
    let name = items.get(2)?.as_str()?.trim();
    let type_code = items.get(3)?.as_i64()?;

    if symbol.is_empty() || exchange.is_empty() || name.is_empty() {
        return None;
    }

    Some(SecurityMatch {
        symbol: symbol.to_string(),
        name: Some(name.to_string()),
        exchange: Some(exchange.to_string()),
        kind: match type_code {
            0 => SecurityKind::Equity,
            5 => SecurityKind::Fund,
            other => SecurityKind::Other(format!("type-code-{other}")),
        },
    })
}

fn parse_company_from_quote_html(
    html: &str,
    security_match: &SecurityMatch,
    symbol: &str,
    exchange: &str,
    price_chart_ranges: Option<HashMap<String, Vec<CompanyPricePoint>>>,
) -> Result<Company, String> {
    let embedded = parse_embedded_quote_data(html);

    let has_embedded_price = embedded.as_ref().and_then(|data| data.price).is_some();
    if !html.contains("/finance/quote/") && !has_embedded_price {
        return Err(
            "Google Finance quote page was missing the expected price markers.".to_string(),
        );
    }

    let parsed_symbol = parse_canonical_quote_part(html, 1).unwrap_or_else(|| symbol.to_string());
    let _parsed_exchange =
        parse_canonical_quote_part(html, 2).unwrap_or_else(|| exchange.to_string());
    let name = parse_quote_name(html)
        .or_else(|| embedded.as_ref().and_then(|data| data.name.clone()))
        .or_else(|| security_match.name.clone())
        .unwrap_or_else(|| parsed_symbol.clone());
    let price = capture_attribute(html, "data-last-price")
        .and_then(|value| parse_numeric_value(&value))
        .or_else(|| embedded.as_ref().and_then(|data| data.price))
        .ok_or_else(|| "Google Finance quote page did not expose a last price.".to_string())?;
    let previous_close = extract_metric_text(html, "Previous close")
        .and_then(|value| parse_numeric_value(&value))
        .or_else(|| embedded.as_ref().and_then(|data| data.previous_close));
    let market_cap = extract_metric_text(html, "Market cap")
        .and_then(|value| parse_compact_number(&value))
        .or_else(|| embedded.as_ref().and_then(|data| data.market_cap))
        .unwrap_or(0.0);
    let year_range = extract_metric_text(html, "Year range")
        .and_then(|value| {
            let mut parts = value.split('-').map(str::trim);
            let low = parts.next().and_then(parse_numeric_value)?;
            let high = parts.next().and_then(parse_numeric_value)?;
            Some((low, high))
        })
        .or_else(|| {
            embedded
                .as_ref()
                .and_then(|data| Some((data.low52_week?, data.high52_week?)))
        });
    let pe = extract_metric_text(html, "P/E ratio")
        .and_then(|value| parse_numeric_value(&value))
        .or_else(|| embedded.as_ref().and_then(|data| data.pe));
    let eps = extract_metric_text(html, "EPS")
        .and_then(|value| parse_numeric_value(&value))
        .or_else(|| embedded.as_ref().and_then(|data| data.eps))
        .or_else(|| match pe {
            Some(pe) if pe > 0.0 => Some(((price / pe) * 100_000.0).round() / 100_000.0),
            _ => None,
        });
    let volume = extract_metric_text(html, "Volume")
        .and_then(|value| parse_compact_number(&value))
        .map(|value| value.round() as u64)
        .map(|value| (value, "Volume".to_string()))
        .or_else(|| {
            extract_metric_text(html, "Avg Volume")
                .and_then(|value| parse_compact_number(&value))
                .map(|value| (value.round() as u64, "Avg Volume".to_string()))
        })
        .or_else(|| {
            embedded
                .as_ref()
                .and_then(|data| data.volume)
                .map(|value| (value, "Volume".to_string()))
        })
        .or_else(|| {
            embedded
                .as_ref()
                .and_then(|data| data.avg_volume)
                .map(|value| (value, "Avg Volume".to_string()))
        });
    let profile = parse_company_profile(html);
    let price_chart = price_chart_ranges
        .as_ref()
        .and_then(|ranges| ranges.get("1D").cloned())
        .or_else(|| parse_price_chart(html));

    let (change, change_percent) = match previous_close {
        Some(previous_close) if previous_close > 0.0 => {
            let change = price - previous_close;
            (change, (change / previous_close) * 100.0)
        }
        _ => (
            embedded
                .as_ref()
                .and_then(|data| data.change)
                .unwrap_or(0.0),
            embedded
                .as_ref()
                .and_then(|data| data.change_percent)
                .unwrap_or(0.0),
        ),
    };

    Ok(Company {
        symbol: parsed_symbol,
        name,
        price,
        change,
        change_percent,
        market_cap,
        volume: volume.as_ref().map(|(value, _)| *value),
        volume_label: volume.map(|(_, label)| label),
        pe,
        eps,
        high52_week: year_range.map(|(_, high)| high),
        low52_week: year_range.map(|(low, _)| low),
        profile,
        price_chart,
        price_chart_ranges,
    })
}

fn parse_quote_name(html: &str) -> Option<String> {
    let title = Regex::new(r"(?s)<title>(.*?)</title>")
        .ok()?
        .captures(html)?
        .get(1)?
        .as_str()
        .to_string();
    let title = html_entity_decode(&title);
    title
        .split(" (")
        .next()
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToString::to_string)
}

fn parse_canonical_quote_part(html: &str, capture_index: usize) -> Option<String> {
    let captures = Regex::new(
        r#"<link rel="canonical" href="https://www\.google\.com/finance/quote/([^:]+):([^"?]+)"#,
    )
    .ok()?
    .captures(html)?;
    captures
        .get(capture_index)
        .map(|value| value.as_str().trim().to_string())
}

fn capture_attribute(html: &str, attribute: &str) -> Option<String> {
    let pattern = format!(r#"{attribute}="([^"]+)""#);
    Regex::new(&pattern)
        .ok()?
        .captures(html)
        .and_then(|captures| captures.get(1))
        .map(|value| html_entity_decode(value.as_str()))
}

fn extract_metric_text(html: &str, label: &str) -> Option<String> {
    let pattern = format!(
        r#"(?s)>{label}</div>.*?<div[^>]*class="P6K39c"[^>]*>([^<]+)</div>"#,
        label = regex::escape(label)
    );
    Regex::new(&pattern)
        .ok()?
        .captures(html)
        .and_then(|captures| captures.get(1))
        .map(|value| normalize_space(&html_entity_decode(value.as_str())))
}

fn parse_numeric_value(value: &str) -> Option<f64> {
    let cleaned = value
        .replace('\u{a0}', " ")
        .chars()
        .filter(|character| {
            character.is_ascii_digit()
                || matches!(character, '+' | '-' | '.' | ',' | ' ' | '\u{202f}')
        })
        .collect::<String>()
        .trim()
        .to_string();

    if cleaned.is_empty() {
        return None;
    }

    let compact = cleaned.replace([' ', '\u{202f}'], "");
    normalize_number_string(&compact).and_then(|normalized| normalized.parse::<f64>().ok())
}

fn parse_compact_number(value: &str) -> Option<f64> {
    let suffix = value
        .chars()
        .find(|character| matches!(character, 'K' | 'M' | 'B' | 'T' | 'k' | 'm' | 'b' | 't'))
        .map(|character| character.to_ascii_uppercase());
    let numeric = Regex::new(r"[-+]?[\d.,\u{202f}\s]+")
        .ok()?
        .find(value)?
        .as_str()
        .trim()
        .to_string();
    let base = parse_numeric_value(&numeric)?;
    let multiplier = match suffix {
        Some('K') => 1_000.0,
        Some('M') => 1_000_000.0,
        Some('B') => 1_000_000_000.0,
        Some('T') => 1_000_000_000_000.0,
        _ => 1.0,
    };

    Some(base * multiplier)
}

fn normalize_number_string(value: &str) -> Option<String> {
    let value = value.trim();
    if value.is_empty() {
        return None;
    }

    let last_dot = value.rfind('.');
    let last_comma = value.rfind(',');

    let normalized = match (last_dot, last_comma) {
        (Some(dot), Some(comma)) if comma > dot => value.replace('.', "").replace(',', "."),
        (Some(_), Some(_)) => value.replace(',', ""),
        (None, Some(comma)) => {
            let decimals = value.len().saturating_sub(comma + 1);
            if decimals <= 2 {
                value.replace(',', ".")
            } else {
                value.replace(',', "")
            }
        }
        _ => value.replace(',', ""),
    };

    Some(normalized)
}

fn normalize_space(value: &str) -> String {
    value.split_whitespace().collect::<Vec<_>>().join(" ")
}

fn html_entity_decode(value: &str) -> String {
    value
        .replace("&amp;", "&")
        .replace("&quot;", "\"")
        .replace("&#39;", "'")
        .replace("&middot;", "·")
        .replace("&#45;", "-")
}

#[derive(Debug, Clone, Default)]
struct EmbeddedQuoteData {
    name: Option<String>,
    price: Option<f64>,
    previous_close: Option<f64>,
    market_cap: Option<f64>,
    volume: Option<u64>,
    avg_volume: Option<u64>,
    pe: Option<f64>,
    eps: Option<f64>,
    high52_week: Option<f64>,
    low52_week: Option<f64>,
    change: Option<f64>,
    change_percent: Option<f64>,
}

fn parse_company_profile(html: &str) -> Option<CompanyProfile> {
    let payload = extract_init_data_payload(html, "ds:1")?;
    let value = serde_json::from_str::<Value>(&payload).ok()?;
    let fields = value.as_array()?.first()?.as_array()?.first()?.as_array()?;
    let headquarters = fields
        .get(3)
        .and_then(Value::as_array)
        .map(|parts| {
            parts
                .iter()
                .take(3)
                .filter_map(Value::as_str)
                .map(str::trim)
                .filter(|value| !value.is_empty())
                .collect::<Vec<_>>()
                .join(", ")
        })
        .filter(|value| !value.is_empty());
    let profile = CompanyProfile {
        description: fields
            .get(2)
            .and_then(Value::as_str)
            .map(normalize_space)
            .filter(|value| !value.is_empty()),
        wiki_url: fields
            .get(31)
            .and_then(Value::as_str)
            .map(ToString::to_string)
            .filter(|value| !value.is_empty()),
        ceo: fields
            .get(5)
            .and_then(Value::as_str)
            .map(ToString::to_string)
            .filter(|value| !value.is_empty()),
        headquarters,
        employees: as_u64_value(fields.get(6)?),
        founded: fields
            .get(4)
            .and_then(Value::as_array)
            .and_then(|values| values.first())
            .and_then(Value::as_i64)
            .map(|value| value as i32),
        sector: fields
            .iter()
            .rev()
            .find_map(Value::as_str)
            .map(str::trim)
            .filter(|value| {
                !value.is_empty()
                    && !value.starts_with("http")
                    && *value != "USD"
                    && *value != "NASDAQ"
                    && !value.contains("wiki")
            })
            .map(ToString::to_string),
        website: fields
            .get(23)
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|value| {
                !value.is_empty()
                    && value.starts_with("http")
                    && *value != "USD"
                    && !value.contains("wikipedia.org")
                    && !value.contains("google.com")
            })
            .map(ToString::to_string),
    };

    if profile.description.is_none()
        && profile.wiki_url.is_none()
        && profile.ceo.is_none()
        && profile.headquarters.is_none()
        && profile.employees.is_none()
        && profile.founded.is_none()
        && profile.sector.is_none()
        && profile.website.is_none()
    {
        None
    } else {
        Some(profile)
    }
}

fn parse_price_chart(html: &str) -> Option<Vec<CompanyPricePoint>> {
    for key in ["ds:10", "ds:11"] {
        let Some(payload) = extract_init_data_payload(html, key) else {
            continue;
        };
        if let Some(points) = parse_price_chart_payload(&payload) {
            return Some(points);
        }
    }

    None
}

fn decode_chart_range_map(
    response: &str,
) -> Result<HashMap<String, Vec<CompanyPricePoint>>, String> {
    let payloads = decode_batchexecute_payloads(response, "AiCwsd")?;
    let mut ranges = HashMap::new();

    for ((range_key, _), payload) in PRICE_CHART_RANGES.iter().zip(payloads.iter()) {
        if let Some(points) = parse_price_chart_payload(payload) {
            ranges.insert((*range_key).to_string(), points);
        }
    }

    if ranges.is_empty() {
        Err("Google Finance chart response returned no usable price history.".to_string())
    } else {
        Ok(ranges)
    }
}

fn parse_price_chart_payload(payload: &str) -> Option<Vec<CompanyPricePoint>> {
    let value = serde_json::from_str::<Value>(payload).ok()?;
    let mut points = Vec::new();
    collect_chart_points(&value, &mut points);
    (points.len() > 1).then_some(points)
}

fn collect_chart_points(value: &Value, points: &mut Vec<CompanyPricePoint>) {
    let Some(items) = value.as_array() else {
        return;
    };

    if let Some(point) = parse_chart_point(items) {
        points.push(point);
        return;
    }

    for item in items {
        collect_chart_points(item, points);
    }
}

fn parse_chart_point(items: &[Value]) -> Option<CompanyPricePoint> {
    let timestamp = items.first()?.as_array()?;
    let price_info = items.get(1)?.as_array()?;
    let price = price_info.first()?.as_f64()?;

    Some(CompanyPricePoint {
        label: chart_label_from_timestamp(timestamp)?,
        price,
        volume: items.get(2).and_then(as_u64_value),
        timestamp: chart_timestamp_from_parts(timestamp),
    })
}

fn chart_label_from_timestamp(parts: &[Value]) -> Option<String> {
    let year = parts.first()?.as_i64()?;
    let month = parts.get(1)?.as_i64()?;
    let day = parts.get(2)?.as_i64()?;
    let hour = parts.get(3).and_then(Value::as_i64);
    let minute = parts.get(4).and_then(Value::as_i64).unwrap_or(0);

    Some(match hour {
        Some(hour) => format!("{hour:02}:{minute:02}"),
        None => format!("{year:04}-{month:02}-{day:02}"),
    })
}

fn chart_timestamp_from_parts(parts: &[Value]) -> Option<String> {
    let year = parts.first()?.as_i64()?;
    let month = parts.get(1)?.as_i64()?;
    let day = parts.get(2)?.as_i64()?;
    let hour = parts.get(3).and_then(Value::as_i64).unwrap_or(0);
    let minute = parts.get(4).and_then(Value::as_i64).unwrap_or(0);
    let offset_seconds = parts
        .get(6)
        .and_then(Value::as_array)
        .and_then(|values| values.first())
        .and_then(Value::as_i64)
        .unwrap_or(0);

    let date = NaiveDate::from_ymd_opt(year as i32, month as u32, day as u32)?;
    let time = NaiveTime::from_hms_opt(hour as u32, minute as u32, 0)?;
    let naive = date.and_time(time);
    let offset = FixedOffset::east_opt(offset_seconds as i32)?;

    offset
        .from_local_datetime(&naive)
        .single()
        .map(|value| value.to_rfc3339())
}

fn parse_embedded_quote_data(html: &str) -> Option<EmbeddedQuoteData> {
    let payload = extract_init_data_payload(html, "ds:1")?;
    let value = serde_json::from_str::<Value>(&payload).ok()?;
    let fields = value.as_array()?.first()?.as_array()?.first()?.as_array()?;

    let price = fields.get(9).and_then(Value::as_f64);
    let previous_close = fields.get(8).and_then(Value::as_f64);

    Some(EmbeddedQuoteData {
        name: fields
            .get(1)
            .and_then(Value::as_str)
            .map(ToString::to_string),
        price,
        previous_close,
        market_cap: fields.get(7).and_then(Value::as_f64),
        volume: fields.get(14).and_then(Value::as_u64).or_else(|| {
            fields
                .get(14)
                .and_then(Value::as_f64)
                .map(|value| value.round() as u64)
        }),
        avg_volume: fields.get(18).and_then(Value::as_u64).or_else(|| {
            fields
                .get(18)
                .and_then(Value::as_f64)
                .map(|value| value.round() as u64)
        }),
        pe: fields.get(16).and_then(Value::as_f64),
        eps: fields.get(20).and_then(Value::as_f64),
        high52_week: fields.get(12).and_then(Value::as_f64),
        low52_week: fields.get(13).and_then(Value::as_f64),
        change: match (price, previous_close) {
            (Some(price), Some(previous_close)) => Some(price - previous_close),
            _ => None,
        },
        change_percent: match (price, previous_close) {
            (Some(price), Some(previous_close)) if previous_close > 0.0 => {
                Some(((price - previous_close) / previous_close) * 100.0)
            }
            _ => None,
        },
    })
}

fn as_u64_value(value: &Value) -> Option<u64> {
    value
        .as_u64()
        .or_else(|| value.as_i64().and_then(|value| u64::try_from(value).ok()))
        .or_else(|| value.as_f64().map(|value| value.round() as u64))
}

fn extract_init_data_payload(html: &str, key: &str) -> Option<String> {
    let marker = format!("AF_initDataCallback({{key: '{key}'");
    let marker_index = html.find(&marker)?;
    let data_index = html[marker_index..].find("data:")? + marker_index + "data:".len();
    let start_index = html[data_index..]
        .char_indices()
        .find(|(_, character)| !character.is_whitespace())
        .map(|(offset, _)| data_index + offset)?;

    if html[start_index..].chars().next()? != '[' {
        return None;
    }

    let mut depth = 0usize;
    let mut in_string = false;
    let mut escaped = false;

    for (offset, character) in html[start_index..].char_indices() {
        if in_string {
            if escaped {
                escaped = false;
            } else if character == '\\' {
                escaped = true;
            } else if character == '"' {
                in_string = false;
            }
            continue;
        }

        match character {
            '"' => in_string = true,
            '[' => depth += 1,
            ']' => {
                depth = depth.saturating_sub(1);
                if depth == 0 {
                    let end_index = start_index + offset + character.len_utf8();
                    return Some(html[start_index..end_index].to_string());
                }
            }
            _ => {}
        }
    }

    None
}

fn company_cache_ttl() -> Duration {
    let now = Utc::now().with_timezone(&New_York);
    if (4..20).contains(&now.hour()) {
        COMPANY_ACTIVE_CACHE_TTL
    } else {
        COMPANY_INACTIVE_CACHE_TTL
    }
}

fn exchange_priority(exchange: Option<&str>) -> usize {
    match exchange {
        Some("NASDAQ") => 0,
        Some("NYSE") => 1,
        Some("AMEX") => 2,
        Some("NYSEARCA") => 3,
        Some("BATS") => 4,
        _ => 5,
    }
}

fn is_blocked_response(status: StatusCode, body: &str) -> bool {
    if matches!(
        status,
        StatusCode::TOO_MANY_REQUESTS | StatusCode::FORBIDDEN
    ) {
        return true;
    }

    let body = body.to_ascii_lowercase();
    body.contains("unusual traffic")
        || body.contains("our systems have detected")
        || body.contains("consent.google.com")
        || body.contains("/sorry/")
        || body.contains("captcha")
}

fn blocked_detail(status: StatusCode, body: &str) -> String {
    if matches!(
        status,
        StatusCode::TOO_MANY_REQUESTS | StatusCode::FORBIDDEN
    ) {
        return format!("Google Finance returned HTTP {status}.");
    }

    if body.to_ascii_lowercase().contains("unusual traffic") {
        return "Google Finance blocked the request as unusual traffic.".to_string();
    }

    "Google Finance blocked the request.".to_string()
}

#[cfg(test)]
mod tests {
    use super::{
        company_cache_ttl, decode_batchexecute_payload, decode_chart_range_map,
        decode_search_matches, extract_init_data_payload, parse_company_from_quote_html,
        parse_company_profile, parse_embedded_quote_data, parse_numeric_value, parse_price_chart,
    };
    use crate::terminal::security_lookup::{SecurityKind, SecurityMatch};

    const MKSVE_RESPONSE: &str = r##"
)]}'

4484
[["wrb.fr","mKsvE","[[[\"/m/07zln_9\",null,null,[\"/m/07zln_9\",[\"MSFT\",\"NASDAQ\"],\"Microsoft Corp\",0,\"USD\",[373.46,4.0899963,1.1072898,2,2,2],null,369.37,\"#737373\",\"US\",\"/m/04sv4\",[1775176200],\"America/New_York\",-14400,\"/m/07zln_9\",null,[373.3999,-0.06008911,-0.016089838,2,3,3],[1775160001],[1775174397],[[1,[2026,4,2,9,30,null,null,[-14400]],[2026,4,2,16,null,null,null,[-14400]]]],null,\"MSFT:NASDAQ\",0,null,null,null,false],[null,[\"MSFT\",\"NASDAQ\"]],null,\"\",\"msft:nasdaq\"],[\"/g/11fzpf6_1y\",null,null,[\"NVD\",[\"NVD\",\"NASDAQ\"],\"GraniteShares 2x Long NVDA Daily ETF\",5,\"USD\",[60.68,1.21,2.03,2,2,2],null,59.47,\"#737373\",\"US\",\"/m/0hjjjhh\",[1775176200],\"America/New_York\",-14400,\"/g/11fzpf6_1y\",null,[60.58,-0.1,-0.16,2,3,3],[1775160001],[1775174397],[[1,[2026,4,2,9,30,null,null,[-14400]],[2026,4,2,16,null,null,null,[-14400]]]],null,\"NVD:NASDAQ\",0,null,null,null,false],[null,[\"NVD\",\"NASDAQ\"]],null,\"\",\"nvd:nasdaq\"]]]",null,null,null,"generic"]]
"##;

    const QUOTE_HTML: &str = r#"
<!doctype html>
<html lang="en-US">
  <head>
    <link rel="canonical" href="https://www.google.com/finance/quote/AAPL:NASDAQ">
    <title>Apple Inc (AAPL) Stock Price &amp; News - Google Finance</title>
    <script>window.WIZ_global_data = {"FdrFJe":"6076535557142440343","cfb2h":"boq_finance-ui_20260330.01_p0"};</script>
    <script class="ds:1">AF_initDataCallback({key: 'ds:1', hash: '2', data:[[["/m/0k8z","Apple","Apple Inc. designs and sells devices.",["Cupertino","California","United States","US","1 Apple Park Way"],[1976],"Tim Cook",161000,3760000000000,255.63,255.0,258.0,254.7,288.61,169.21,51234567,"USD",32.38,0.0,41190000,0.0,0.0,0.0,"https://www.apple.com/","USD","NASDAQ",3,3,1,1,null,"https://en.wikipedia.org/wiki/Apple_Inc.",null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,2,2,2,2,2,3,0,null,null,null,null,null,null,null,null,null,null,null,null,0,0,0,0,0,[2025,9,27,4,null,null,null,[]],"Technology"]]], sideChannel: {}});</script>
    <script class="ds:10">AF_initDataCallback({key: 'ds:10', hash: '11', data:[[[["AAPL","NASDAQ"],"/m/0k8z","USD",[[[1,[2026,4,2,9,30,null,null,[-14400]],[2026,4,2,16,null,null,null,[-14400]]],[[[2026,4,2,9,30,null,null,[-14400]],[255.12,-0.51,-0.19,2,2,4],120],[[2026,4,2,9,31,null,null,[-14400]],[255.55,-0.08,-0.03,2,2,4],220],[[2026,4,2,9,32,null,null,[-14400]],[255.92,0.29,0.11,2,2,4],180]]]]]]], sideChannel: {}});</script>
  </head>
  <body>
    <div data-exchange="NASDAQ" data-currency-code="USD" data-last-price="255.92" data-last-normal-market-timestamp="1775160000"></div>
    <div>Previous close</div><div class="P6K39c">$255.63</div>
    <div>Year range</div><div class="P6K39c">$169.21 - $288.61</div>
    <div>Market cap</div><div class="P6K39c">3.76T USD</div>
    <div>Avg Volume</div><div class="P6K39c">41.19M</div>
    <div>P/E ratio</div><div class="P6K39c">32.38</div>
  </body>
</html>
"#;

    #[test]
    fn decodes_batchexecute_payload() {
        let payload = decode_batchexecute_payload(MKSVE_RESPONSE, "mKsvE").expect("mKsvE payload");
        assert!(payload.contains("MSFT"));
    }

    #[test]
    fn decodes_search_matches_from_mksve() {
        let matches = decode_search_matches(MKSVE_RESPONSE).expect("search matches");
        assert_eq!(matches[0].symbol, "MSFT");
        assert_eq!(matches[0].exchange.as_deref(), Some("NASDAQ"));
        assert_eq!(matches[0].name.as_deref(), Some("Microsoft Corp"));
        assert!(matches[0].kind.is_supported());
        assert_eq!(matches[1].symbol, "NVD");
        assert!(matches[1].kind.is_supported());
    }

    #[test]
    fn parses_company_from_quote_html_with_avg_volume_fallback() {
        let company = parse_company_from_quote_html(
            QUOTE_HTML,
            &SecurityMatch {
                symbol: "AAPL".to_string(),
                name: Some("Apple Inc".to_string()),
                exchange: Some("NASDAQ".to_string()),
                kind: SecurityKind::Equity,
            },
            "AAPL",
            "NASDAQ",
            None,
        )
        .expect("company parse");

        assert_eq!(company.symbol, "AAPL");
        assert_eq!(company.name, "Apple Inc");
        assert_eq!(company.price, 255.92);
        assert_eq!(company.volume, Some(41_190_000));
        assert_eq!(company.volume_label.as_deref(), Some("Avg Volume"));
        assert_eq!(company.pe, Some(32.38));
        assert_eq!(company.low52_week, Some(169.21));
        assert_eq!(company.high52_week, Some(288.61));
        assert_eq!(
            company
                .profile
                .as_ref()
                .and_then(|profile| profile.ceo.as_deref()),
            Some("Tim Cook")
        );
        assert_eq!(company.price_chart.as_ref().map(Vec::len), Some(3));
    }

    #[test]
    fn parses_localized_numbers() {
        assert_eq!(parse_numeric_value("$1,234.56"), Some(1234.56));
        assert_eq!(parse_numeric_value("1.234,56"), Some(1234.56));
        assert_eq!(parse_numeric_value("41,19"), Some(41.19));
    }

    #[test]
    fn extracts_embedded_quote_payload_and_supports_partial_quote_returns() {
        let html = r#"
<script class="ds:1">AF_initDataCallback({key: 'ds:1', hash: '2', data:[[["/m/07zm6yd","Casey's",null,null,null,null,null,27476081450.975952,737.16,743.42,746.57,727.32,746.57,397.8,368239,"USD",42.67327,0.3066907,519009]]], sideChannel: {}});</script>
<div data-last-price="743.42"></div>
<link rel="canonical" href="https://www.google.com/finance/quote/CASY:NASDAQ">
<title>Caseys General Stores Inc (CASY) Stock Price &amp; News - Google Finance</title>
"#;

        let payload = extract_init_data_payload(html, "ds:1").expect("ds:1 payload");
        assert!(payload.contains("Casey's"));

        let embedded = parse_embedded_quote_data(html).expect("embedded quote");
        assert_eq!(embedded.previous_close, Some(737.16));
        assert_eq!(embedded.volume, Some(368_239));
        assert_eq!(embedded.avg_volume, Some(519_009));

        let company = parse_company_from_quote_html(
            html,
            &SecurityMatch {
                symbol: "CASY".to_string(),
                name: Some("Caseys General Stores Inc".to_string()),
                exchange: Some("NASDAQ".to_string()),
                kind: SecurityKind::Equity,
            },
            "CASY",
            "NASDAQ",
            None,
        )
        .expect("partial company parse");

        assert_eq!(company.symbol, "CASY");
        assert_eq!(company.price, 743.42);
        assert!((company.change - 6.26).abs() < 0.0001);
        assert_eq!(company.volume, Some(368_239));
        assert_eq!(company.eps, Some(17.42121));
    }

    #[test]
    fn parses_quote_when_price_attribute_is_absent() {
        let html = r#"
<script class="ds:1">AF_initDataCallback({key: 'ds:1', hash: '2', data:[[["/m/07zm6yd","Qualcomm Inc",null,null,null,null,null,170000000000.0,146.11,148.85,149.54,145.49,180.95,120.8,6116040,"USD",15.82,1.8753,8300000,9.41]]], sideChannel: {}});</script>
<link rel="canonical" href="https://www.google.com/finance/quote/QCOM:NASDAQ">
<title>Qualcomm Inc (QCOM) Stock Price &amp; News - Google Finance</title>
"#;

        let company = parse_company_from_quote_html(
            html,
            &SecurityMatch {
                symbol: "QCOM".to_string(),
                name: Some("Qualcomm Inc".to_string()),
                exchange: Some("NASDAQ".to_string()),
                kind: SecurityKind::Equity,
            },
            "QCOM",
            "NASDAQ",
            None,
        )
        .expect("company parse");

        assert_eq!(company.symbol, "QCOM");
        assert_eq!(company.name, "Qualcomm Inc");
        assert_eq!(company.price, 148.85);
        assert_eq!(company.volume, Some(6_116_040));
        assert_eq!(company.pe, Some(15.82));
        assert!((company.change - 2.74).abs() < 0.0001);
    }

    #[test]
    fn parses_profile_and_intraday_chart() {
        let html = r#"
<script class="ds:1">AF_initDataCallback({key: 'ds:1', hash: '2', data:[[["/m/07zm6yd","Casey's","Profile body",["Ankeny","Iowa","United States","US","One SE Convenience Blvd"],[1968],"Darren Rebelez",36305,27476081450.975952,737.16,735.4,746.57,727.32,746.57,397.8,368239,"USD",42.67327,0.3066907,519009,17.42121,0.6062,36959000,"http://www.caseys.com/","USD","NASDAQ",3,3,1,1,null,"https://en.wikipedia.org/wiki/Casey's",null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,2,2,2,2,2,3,0,null,null,null,null,null,null,null,null,null,null,null,null,0,0,0,0,0,[2025,10,31,4,null,null,null,[]],"Consumer"]]], sideChannel: {}});</script>
<script class="ds:10">AF_initDataCallback({key: 'ds:10', hash: '11', data:[[[["CASY","NASDAQ"],"/m/07zm6yd","USD",[[[1,[2026,4,2,9,30,null,null,[-14400]],[2026,4,2,16,null,null,null,[-14400]]],[[[2026,4,2,9,30,null,null,[-14400]],[735.99,-1.17,-0.15,2,2,4],120],[[2026,4,2,9,31,null,null,[-14400]],[732.665,-4.49,-0.60,2,2,4],3854],[[2026,4,2,9,32,null,null,[-14400]],[743.42,6.26,0.84,2,2,4],131]]]]]]], sideChannel: {}});</script>
"#;

        let profile = parse_company_profile(html).expect("profile");
        assert_eq!(profile.ceo.as_deref(), Some("Darren Rebelez"));
        assert_eq!(
            profile.headquarters.as_deref(),
            Some("Ankeny, Iowa, United States")
        );
        assert_eq!(profile.sector.as_deref(), Some("Consumer"));

        let chart = parse_price_chart(html).expect("chart");
        assert_eq!(chart.len(), 3);
        assert_eq!(chart[0].label, "09:30");
        assert_eq!(chart[2].price, 743.42);
    }

    #[test]
    fn decodes_chart_range_map_from_batched_aicwsd_payloads() {
        let response = r#"
)]}'

512
        [["wrb.fr","AiCwsd","[[[[\"CASY\",\"NASDAQ\"],\"/m/07zm6yd\",\"USD\",[[[1,[2026,4,2,9,30,null,null,[-14400]],[2026,4,2,16,null,null,null,[-14400]]],[[[2026,4,2,9,30,null,null,[-14400]],[735.99,-1.17,-0.15,2,2,4],120],[[2026,4,2,9,31,null,null,[-14400]],[743.42,6.26,0.84,2,2,4],131]]]]]]]",null,null,null,"generic"],["wrb.fr","AiCwsd","[[[[\"CASY\",\"NASDAQ\"],\"/m/07zm6yd\",\"USD\",[[[1,[2026,3,27,9,30,null,null,[-14400]],[2026,4,2,16,null,null,null,[-14400]]],[[[2026,3,27,9,30,null,null,[-14400]],[708.15,0,0,2,2,2],3111],[[2026,4,2,16,null,null,null,[-14400]],[743.42,35.27,0.0498,2,2,2],68444]]]]]]]",null,null,null,"generic"]]
"#;

        let ranges = decode_chart_range_map(response).expect("range map");
        assert_eq!(ranges.get("1D").map(Vec::len), Some(2));
        assert_eq!(ranges.get("5D").map(Vec::len), Some(2));
        assert_eq!(
            ranges
                .get("1D")
                .and_then(|points| points.first())
                .map(|point| point.label.as_str()),
            Some("09:30")
        );
        assert_eq!(
            ranges
                .get("5D")
                .and_then(|points| points.first())
                .map(|point| point.label.as_str()),
            Some("09:30")
        );
    }

    #[test]
    fn company_cache_ttl_is_non_zero() {
        assert!(company_cache_ttl() > std::time::Duration::ZERO);
    }
}
