//! Tauri commands for the Bilan (balance sheet) feature — Issue #142 / #155.
//!
//! Commands:
//!   - `compute_account_return` (Issue #142): Modified Dietz return for one
//!     account over a period. Reads snapshot endpoints + linked transfer amounts
//!     in a single Rust pass.
//!   - `fetch_price` (Issue #155): Fetch a price quote from maximus-api for
//!     a given `(symbol, date)` pair. Privacy-strict: sends only
//!     `Authorization`, `Accept`, and `User-Agent` headers.
//!
//! Database access pattern:
//!   - All reads use `rusqlite::Connection::open(app_data_dir / db_filename)`,
//!     matching the existing `repair_migrations` helper in `profile_commands.rs`.
//!   - The frontend passes `db_filename` (the active profile DB), exactly
//!     like it does for `repair_migrations` and `delete_profile_db`. Keeps
//!     the active-profile resolution where it already lives (in TS) and
//!     avoids re-reading `profiles.json` on every call.
//!   - Reads are short-lived: connection opens, runs ≤ 3 SQL statements,
//!     drops at end of function. No connection pooling needed (commands run
//!     on the Tauri async runtime, one at a time per invocation).

use chrono::NaiveDate;
use rusqlite::Connection;
use serde::{Deserialize, Serialize};
use tauri::Manager;

use crate::commands::return_calculator::{modified_dietz, AccountReturn};

// ---------------------------------------------------------------------------
// fetch_price types (Issue #155)
// ---------------------------------------------------------------------------

/// Successful price response from `GET /v1/prices`.
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PriceResponse {
    pub symbol: String,
    pub date: String,
    pub actual_date: Option<String>,
    pub price: f64,
    pub currency: String,
    pub source: String,
    pub fetched_at: String,
    pub cached: bool,
}

/// Typed error returned by `fetch_price`. Serialized as JSON to cross the
/// Tauri command boundary (the JS layer `JSON.parse`s the error string).
///
/// The `tag = "code"` + `rename_all = "snake_case"` combination produces
/// `{"code":"auth"}`, `{"code":"rate_limit","retry_after_s":42}`, etc. —
/// matching the `error.code` shape defined in `docs/api-contract-prices.md §5`.
#[derive(Debug, Serialize, Clone)]
#[serde(tag = "code", rename_all = "snake_case")]
pub enum FetchPriceError {
    Auth,
    PremiumRequired,
    SymbolNotFound,
    RateLimit { retry_after_s: u64 },
    ProviderUnavailable,
    Network,
    Internal,
}

impl std::fmt::Display for FetchPriceError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            FetchPriceError::Auth => write!(f, "auth"),
            FetchPriceError::PremiumRequired => write!(f, "premium_required"),
            FetchPriceError::SymbolNotFound => write!(f, "symbol_not_found"),
            FetchPriceError::RateLimit { retry_after_s } => {
                write!(f, "rate_limit (retry after {}s)", retry_after_s)
            }
            FetchPriceError::ProviderUnavailable => write!(f, "provider_unavailable"),
            FetchPriceError::Network => write!(f, "network"),
            FetchPriceError::Internal => write!(f, "internal"),
        }
    }
}

/// Serialize a `FetchPriceError` to the stable JSON string returned across
/// the Tauri boundary. Falls back to `{"code":"internal"}` on serialization
/// failure (which should never happen in practice).
fn price_error_to_string(err: &FetchPriceError) -> String {
    serde_json::to_string(err).unwrap_or_else(|_| r#"{"code":"internal"}"#.to_string())
}

/// API base URL for maximus-api. Overridable via `MAXIMUS_API_URL` for tests
/// and development environments.
fn base_url() -> String {
    std::env::var("MAXIMUS_API_URL")
        .unwrap_or_else(|_| "https://api.lacompagniemaximus.com".to_string())
}

/// Read the stored activation token from disk (raw JWT string).
/// Returns `Err(FetchPriceError::Auth)` when the file is absent or unreadable.
fn read_stored_activation_token(app: &tauri::AppHandle) -> Result<String, FetchPriceError> {
    let app_dir = app
        .path()
        .app_data_dir()
        .map_err(|_| FetchPriceError::Auth)?;
    let token_path = app_dir.join("activation.token");
    std::fs::read_to_string(&token_path)
        .map(|s| s.trim().to_string())
        .map_err(|_| FetchPriceError::Auth)
}

/// Core implementation — separated from the Tauri command so tests can inject
/// an arbitrary token string and base URL without touching the file system.
///
/// Design note (MEDIUM decision in decisions-log.md): the public `fetch_price`
/// command is a thin wrapper that loads the token then delegates here. Tests
/// call this inner function directly with an explicit `api_base` to avoid
/// env-var races between concurrent test threads.
async fn fetch_price_with_token(
    token: &str,
    symbol: &str,
    date: &str,
) -> Result<PriceResponse, FetchPriceError> {
    fetch_price_inner(token, symbol, date, &base_url()).await
}

async fn fetch_price_inner(
    token: &str,
    symbol: &str,
    date: &str,
    api_base: &str,
) -> Result<PriceResponse, FetchPriceError> {
    let url = format!(
        "{}/v1/prices?symbol={}&date={}",
        api_base,
        urlencoding::encode(symbol),
        urlencoding::encode(date),
    );

    // Build client with User-Agent set on the builder — NOT as a manual header.
    // This satisfies the privacy contract (§3.1): UA is set at the transport
    // level, not injected as an explicit per-request header alongside
    // Accept-Language, cookies, or other identifying headers.
    let client = reqwest::Client::builder()
        .user_agent("simpl-resultat")
        .build()
        .map_err(|_| FetchPriceError::Internal)?;

    let resp = client
        .get(&url)
        .header("Authorization", format!("Bearer {}", token))
        .header("Accept", "application/json")
        // DO NOT add User-Agent here — it is already set on the client builder.
        .timeout(std::time::Duration::from_secs(15))
        .send()
        .await
        .map_err(|_| FetchPriceError::Network)?;

    let status = resp.status();

    match status.as_u16() {
        200 => {
            let price_resp: PriceResponse = resp
                .json()
                .await
                .map_err(|_| FetchPriceError::Internal)?;
            Ok(price_resp)
        }
        401 => Err(FetchPriceError::Auth),
        403 => Err(FetchPriceError::PremiumRequired),
        404 => Err(FetchPriceError::SymbolNotFound),
        429 => {
            // Parse `error.retry_after` from the JSON body.
            let body: serde_json::Value = resp
                .json()
                .await
                .unwrap_or(serde_json::Value::Null);
            let retry_after_s = body
                .pointer("/error/retry_after")
                .and_then(|v| v.as_u64())
                .unwrap_or(60);
            Err(FetchPriceError::RateLimit { retry_after_s })
        }
        s if s >= 500 => Err(FetchPriceError::ProviderUnavailable),
        _ => Err(FetchPriceError::Internal),
    }
}

/// Fetch the price of `symbol` on `date` (ISO `YYYY-MM-DD`) via maximus-api.
///
/// Reads the stored activation token, then calls `GET /v1/prices`. Returns a
/// serialized `FetchPriceError` JSON string on error so the JS layer can
/// `JSON.parse` and branch on `code`.
///
/// Privacy contract (§3.2): only `Authorization`, `Accept`, and `User-Agent`
/// are sent. `User-Agent` is set on the reqwest client builder — not injected
/// as a manual header — so no fingerprinting headers leak.
#[tauri::command]
pub async fn fetch_price(
    app: tauri::AppHandle,
    symbol: String,
    date: String,
) -> Result<PriceResponse, String> {
    let token = read_stored_activation_token(&app).map_err(|e| price_error_to_string(&e))?;
    fetch_price_with_token(&token, &symbol, &date)
        .await
        .map_err(|e| price_error_to_string(&e))
}

/// Compute the Modified Dietz return for one account over the period
/// `[period_start, period_end]`. Reads:
///   - `value_start`: latest snapshot line for the account whose
///     `snapshot_date <= period_start` (None if no prior snapshot).
///   - `value_end`:   latest snapshot line for the account whose
///     `snapshot_date <= period_end`   (None if no snapshot in range).
///   - cash flows:    every linked transfer in `[period_start, period_end]`,
///     sign applied per direction (`in` → `+`, `out` → `−`).
///
/// Both dates must be ISO `YYYY-MM-DD`. Returns a typed `AccountReturn`
/// (Serialize) ready to ship across the Tauri boundary.
#[tauri::command]
pub fn compute_account_return(
    app: tauri::AppHandle,
    db_filename: String,
    account_id: i64,
    period_start: String,
    period_end: String,
) -> Result<AccountReturn, String> {
    let start_date = parse_iso_date(&period_start, "period_start")?;
    let end_date = parse_iso_date(&period_end, "period_end")?;

    let app_dir = app
        .path()
        .app_data_dir()
        .map_err(|e| format!("Cannot get app data dir: {}", e))?;
    let db_path = app_dir.join(&db_filename);
    if !db_path.exists() {
        return Err(format!(
            "Profile database not found: {}",
            db_path.display()
        ));
    }

    let conn = Connection::open(&db_path)
        .map_err(|e| format!("Cannot open database: {}", e))?;

    let value_start = read_value_at_or_before(&conn, account_id, &period_start)?;
    let value_end = read_value_at_or_before(&conn, account_id, &period_end)?;
    let cash_flows = read_cash_flows(&conn, account_id, &period_start, &period_end)?;

    Ok(modified_dietz(
        value_start,
        value_end,
        &cash_flows,
        start_date,
        end_date,
    ))
}

// -----------------------------------------------------------------------------
// Internal helpers
// -----------------------------------------------------------------------------

fn parse_iso_date(input: &str, field: &str) -> Result<NaiveDate, String> {
    NaiveDate::parse_from_str(input, "%Y-%m-%d")
        .map_err(|e| format!("Invalid {} (expected YYYY-MM-DD): {}", field, e))
}

/// Reads the value of the snapshot line for `account_id` at the most recent
/// snapshot whose `snapshot_date <= as_of_date`. Returns `None` when no
/// such snapshot exists for this account.
fn read_value_at_or_before(
    conn: &Connection,
    account_id: i64,
    as_of_date: &str,
) -> Result<Option<f64>, String> {
    // Single-row query: pick the latest snapshot date for this account that
    // is on or before `as_of_date`, then return that line's value. Indexed
    // on `balance_snapshots.snapshot_date` and `balance_snapshot_lines.account_id`.
    let mut stmt = conn
        .prepare(
            "SELECT l.value
               FROM balance_snapshot_lines l
               JOIN balance_snapshots s ON s.id = l.snapshot_id
              WHERE l.account_id = ?1
                AND s.snapshot_date <= ?2
              ORDER BY s.snapshot_date DESC
              LIMIT 1",
        )
        .map_err(|e| format!("prepare value query: {}", e))?;

    let mut rows = stmt
        .query(rusqlite::params![account_id, as_of_date])
        .map_err(|e| format!("execute value query: {}", e))?;

    match rows.next().map_err(|e| format!("read value row: {}", e))? {
        Some(row) => Ok(Some(
            row.get::<_, f64>(0).map_err(|e| format!("decode value: {}", e))?,
        )),
        None => Ok(None),
    }
}

/// Reads every linked transfer for `account_id` whose underlying
/// transaction's `transaction_date` falls inside `[period_start, period_end]`.
/// Returns `(NaiveDate, signed_amount)` — sign applied per `direction`
/// (`in` → `+`, `out` → `−`). Amounts come from the linked transaction.
fn read_cash_flows(
    conn: &Connection,
    account_id: i64,
    period_start: &str,
    period_end: &str,
) -> Result<Vec<(NaiveDate, f64)>, String> {
    // NOTE: the transactions table column is `date` (not `transaction_date`).
    // See `src-tauri/src/database/schema.sql:67`.
    let mut stmt = conn
        .prepare(
            "SELECT t.date,
                    ABS(t.amount) AS abs_amount,
                    bat.direction
               FROM balance_account_transfers bat
               JOIN transactions t ON t.id = bat.transaction_id
              WHERE bat.account_id = ?1
                AND t.date BETWEEN ?2 AND ?3
              ORDER BY t.date",
        )
        .map_err(|e| format!("prepare flows query: {}", e))?;

    let rows = stmt
        .query_map(
            rusqlite::params![account_id, period_start, period_end],
            |row| {
                // `transactions.date` may come back as String (TEXT) — keep
                // the decoder generic enough.
                let date_str: String = row.get(0)?;
                let amount: f64 = row.get(1)?;
                let direction: String = row.get(2)?;
                Ok((date_str, amount, direction))
            },
        )
        .map_err(|e| format!("execute flows query: {}", e))?;

    let mut flows: Vec<(NaiveDate, f64)> = Vec::new();
    for row_result in rows {
        let (date_str, amount, direction) =
            row_result.map_err(|e| format!("decode flow row: {}", e))?;
        // `transaction_date` is stored as `YYYY-MM-DD` (TEXT date column —
        // see consolidated_schema.sql). Defensive trim of any trailing
        // time component just in case.
        let iso = date_str.split('T').next().unwrap_or(&date_str).to_string();
        let date = parse_iso_date(&iso, "transaction_date")?;
        let signed = match direction.as_str() {
            "in" => amount,
            "out" => -amount,
            other => {
                return Err(format!(
                    "Invalid transfer direction stored in DB: {}",
                    other
                ));
            }
        };
        flows.push((date, signed));
    }
    Ok(flows)
}

// =============================================================================
// Tests for fetch_price (Issue #155)
// =============================================================================
//
// Strategy: use `mockito::Server::new_async()` as an in-process HTTP server.
// Each test calls `fetch_price_inner` directly, passing the mock server URL
// as `api_base`. This avoids env-var races between concurrent test threads
// (all tokio tests share the same process) and bypasses the file-system
// activation token loading.
#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn it_returns_price_on_200() {
        let mut server = mockito::Server::new_async().await;
        let _m = server
            .mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string()))
            .with_status(200)
            .with_header("content-type", "application/json")
            .with_body(
                r#"{"symbol":"AAPL","date":"2026-04-25","actual_date":null,"price":173.45,"currency":"USD","source":"yahoo","fetched_at":"2026-04-25T14:32:11Z","cached":false}"#,
            )
            .create_async()
            .await;

        let result = fetch_price_inner("test-token", "AAPL", "2026-04-25", &server.url()).await;
        assert!(result.is_ok(), "expected Ok, got {:?}", result);
        let resp = result.unwrap();
        assert_eq!(resp.symbol, "AAPL");
        assert!((resp.price - 173.45).abs() < f64::EPSILON);
        assert_eq!(resp.currency, "USD");
        assert!(!resp.cached);
    }

    #[tokio::test]
    async fn it_returns_auth_error_on_401() {
        let mut server = mockito::Server::new_async().await;
        let _m = server
            .mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string()))
            .with_status(401)
            .with_header("content-type", "application/json")
            .with_body(r#"{"error":{"code":"invalid_token","message":"Invalid token"}}"#)
            .create_async()
            .await;

        let result = fetch_price_inner("bad-token", "AAPL", "2026-04-25", &server.url()).await;
        let err_str = price_error_to_string(&result.unwrap_err());
        let parsed: serde_json::Value = serde_json::from_str(&err_str).unwrap();
        assert_eq!(parsed["code"], "auth");
    }

    #[tokio::test]
    async fn it_returns_premium_required_on_403() {
        let mut server = mockito::Server::new_async().await;
        let _m = server
            .mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string()))
            .with_status(403)
            .with_header("content-type", "application/json")
            .with_body(r#"{"error":{"code":"premium_required","message":"Premium required"}}"#)
            .create_async()
            .await;

        let result = fetch_price_inner("base-token", "AAPL", "2026-04-25", &server.url()).await;
        let err_str = price_error_to_string(&result.unwrap_err());
        let parsed: serde_json::Value = serde_json::from_str(&err_str).unwrap();
        assert_eq!(parsed["code"], "premium_required");
    }

    #[tokio::test]
    async fn it_returns_symbol_not_found_on_404() {
        let mut server = mockito::Server::new_async().await;
        let _m = server
            .mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string()))
            .with_status(404)
            .with_header("content-type", "application/json")
            .with_body(r#"{"error":{"code":"symbol_not_found","message":"Unknown symbol"}}"#)
            .create_async()
            .await;

        let result = fetch_price_inner("tok", "BOGUS", "2026-04-25", &server.url()).await;
        let err_str = price_error_to_string(&result.unwrap_err());
        let parsed: serde_json::Value = serde_json::from_str(&err_str).unwrap();
        assert_eq!(parsed["code"], "symbol_not_found");
    }

    #[tokio::test]
    async fn it_parses_retry_after_on_429() {
        let mut server = mockito::Server::new_async().await;
        let _m = server
            .mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string()))
            .with_status(429)
            .with_header("content-type", "application/json")
            .with_body(r#"{"error":{"code":"rate_limit_exceeded","message":"Rate limit exceeded","retry_after":42}}"#)
            .create_async()
            .await;

        let result = fetch_price_inner("tok", "AAPL", "2026-04-25", &server.url()).await;
        let err_str = price_error_to_string(&result.unwrap_err());
        let parsed: serde_json::Value = serde_json::from_str(&err_str).unwrap();
        assert_eq!(parsed["code"], "rate_limit");
        assert_eq!(parsed["retry_after_s"], 42);
    }

    #[tokio::test]
    async fn it_returns_provider_unavailable_on_502() {
        let mut server = mockito::Server::new_async().await;
        let _m = server
            .mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string()))
            .with_status(502)
            .with_header("content-type", "application/json")
            .with_body(r#"{"error":{"code":"provider_unavailable","message":"Yahoo unavailable"}}"#)
            .create_async()
            .await;

        let result = fetch_price_inner("tok", "AAPL", "2026-04-25", &server.url()).await;
        let err_str = price_error_to_string(&result.unwrap_err());
        let parsed: serde_json::Value = serde_json::from_str(&err_str).unwrap();
        assert_eq!(parsed["code"], "provider_unavailable");
    }

    /// Privacy assertion: the request must only carry `Authorization`, `Accept`,
    /// `User-Agent`, and `Host`. No `Accept-Language`, no cookies, no `X-*`
    /// tracking headers.
    ///
    /// mockito's `match_header` with `Matcher::Missing` asserts that a header
    /// is absent from the request. We assert absence for each forbidden header.
    #[tokio::test]
    async fn it_sends_only_authorization_accept_user_agent() {
        let mut server = mockito::Server::new_async().await;

        // Forbidden headers — must be absent from every request.
        let forbidden = [
            "cookie",
            "accept-language",
            "x-forwarded-for",
            "x-real-ip",
            "x-custom-tracking",
        ];
        let mut mock_builder = server
            .mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string()))
            .with_status(200)
            .with_header("content-type", "application/json")
            .with_body(
                r#"{"symbol":"BTC","date":"2026-04-25","actual_date":null,"price":60000.0,"currency":"USD","source":"kraken","fetched_at":"2026-04-25T10:00:00Z","cached":true}"#,
            );

        for header in &forbidden {
            mock_builder = mock_builder.match_header(*header, mockito::Matcher::Missing);
        }

        // Also assert the required headers ARE present.
        mock_builder = mock_builder
            .match_header("authorization", mockito::Matcher::Regex("^Bearer ".to_string()))
            .match_header("accept", "application/json")
            .match_header("user-agent", "simpl-resultat");

        let _m = mock_builder.create_async().await;

        let result =
            fetch_price_inner("test-privacy-token", "BTC", "2026-04-25", &server.url()).await;
        assert!(
            result.is_ok(),
            "expected Ok for privacy test, got {:?}",
            result
        );
        // If any forbidden header was present, mockito would return 501 and the
        // JSON parse would fail. A successful 200 parse confirms the privacy contract.
        assert_eq!(result.unwrap().symbol, "BTC");
    }
}
