asteroid-tui 1.0.6

Tools for minor planets researchers: observation scheduling and planning
use crate::{settings::Settings, utils::is_visible};
use anyhow::{anyhow, Context, Result};
use chrono::{Datelike, TimeZone, Timelike, Utc};
use percent_encoding::percent_decode_str;
use reqwest;
use serde::{Deserialize, Serialize};
//use serde_json::Result;
//use serde_repr::{Deserialize_repr, Serialize_repr};
//use std::fmt::Display;
//use std::{fmt, thread::current};

/// Indices of table columns from whatsup.html:
pub mod table_indices {
    /// Object designation
    pub const DESIGNATION: usize = 0;
    /// Object magnitude
    pub const MAGNITUDE: usize = 1;
    /// Solar elongation
    pub const SOLAR_ELONG: usize = 2;
    /// Lunar elongation
    pub const LUNAR_ELONG: usize = 3;
    /// Begin time
    pub const BEGIN_TIME: usize = 4;
    /// Begin right ascension
    pub const BEG_RA: usize = 5;
    /// Begin declination
    pub const BEG_DEC: usize = 6;
    /// Begin altitude
    pub const BEG_ALT: usize = 7;
    /// Maximum time
    pub const MAX_TIME: usize = 8;
    /// Maximum right ascension
    pub const MAX_RA: usize = 9;
    /// Maximum declination
    pub const MAX_DEC: usize = 10;
    /// Maximum altitude
    pub const MAX_ALT: usize = 11;
    /// End time
    pub const END_TIME: usize = 12;
    /// End right ascension
    pub const END_RA: usize = 13;
    /// End declination
    pub const END_DEC: usize = 14;
    /// End altitude
    pub const END_ALT: usize = 15;
}

/// Possible target structure
///
/// * `designation`: Object designation
/// * `ra`: Object RA
/// * `dec`: Object Dec
/// * `magnitude`: Object magnitude
/// * `altitude`: Object altitude
#[derive(Debug, Deserialize, Serialize)]
pub struct PossibleTarget {
    /// Object designation
    pub designation: String,
    /// Object RA
    pub ra: String,
    /// Object Dec
    pub dec: String,
    /// Object magnitude
    pub magnitude: f32,
    /// Object altitude
    pub altitude: f32,
}

/// Request parameters struct
///
/// * `year`: Year of scheduled observation
/// * `month`: Month of scheduled observation
/// * `day`: Day of scheduled observation
/// * `hour`: Hour of scheduled observation
/// * `minute`: Minutes of scheduled observation
/// * `duration`: Duration of scheduled observation
/// * `max_objects`: Maximum number of object to retrieve
/// * `min_alt`: Minimum Altitude of object
/// * `solar_elong`: Minimum Solar elongation
/// * `lunar_elong`: Minimum Lunar elongation
/// * `object_type`: Object type
#[derive(Debug)]
pub struct WhatsUpParams {
    /// Year of scheduled observation
    pub year: String,
    /// Month of scheduled observation
    pub month: String,
    /// Day of scheduled observation
    pub day: String,
    /// Hour of scheduled observation
    pub hour: String,
    /// Minute of scheduled observation
    pub minute: String,
    /// Duration of scheduled observation
    pub duration: String,
    /// Maximum number of object to retrieve
    pub max_objects: String,
    /// Minimum Altitude of object
    pub min_alt: String,
    /// Minimum Solar elongation
    pub solar_elong: String,
    /// Minimum Lunar elongation
    pub lunar_elong: String,
    /// Object type
    pub object_type: String,
}

impl Default for WhatsUpParams {
    fn default() -> Self {
        let current_datetime = Utc::now();
        let params: WhatsUpParams = WhatsUpParams {
            year: current_datetime.year().to_string(),
            month: current_datetime.month().to_string(),
            day: current_datetime.day().to_string(),
            minute: current_datetime.minute().to_string(),
            hour: current_datetime.hour().to_string(),
            duration: "1".to_string(),
            max_objects: "10".to_string(),
            min_alt: "10".to_string(),
            solar_elong: "0".to_string(),
            lunar_elong: "0".to_string(),
            object_type: "mp".to_string(),
        };
        params
    }
}

impl Default for PossibleTarget {
    fn default() -> Self {
        PossibleTarget {
            designation: "None".to_string(),
            ra: "None".to_string(),
            dec: "None".to_string(),
            magnitude: 0.0,
            altitude: 0.0,
        }
    }
}

/// Gets raw observing target list from MPC
///
/// * `params`: WhatsupParams struct with all requested parameters
fn get_observing_target_list(params: &WhatsUpParams) -> Result<String> {
    let settings = Settings::new()
        .context("Failed to load settings")?;
    let mut full_params: Vec<(&str, &str)> = Vec::new();
    let encoded_param = "%E2%9C%93";
    let decoded = percent_decode_str(encoded_param)
        .decode_utf8_lossy()
        .into_owned();
    full_params.push(("utf8", decoded.as_str()));
    
    // Get auth token from settings (which checks environment variable first)
    let auth_token = settings.get_mpc_auth_token();
    let decoded_auth_token = percent_decode_str(&auth_token)
        .decode_utf8_lossy()
        .into_owned();
    full_params.push(("authenticity_token", decoded_auth_token.as_str()));
    
    let latitude = settings.get_latitude().to_string();
    full_params.push(("latitude", latitude.as_str()));
    let longitude = settings.get_longitude().to_string();
    full_params.push(("longitude", longitude.as_str()));
    full_params.push(("year", params.year.as_str()));
    full_params.push(("month", params.month.as_str()));
    full_params.push(("day", params.day.as_str()));
    full_params.push(("hour", params.hour.as_str()));
    full_params.push(("minute", params.minute.as_str()));
    full_params.push(("duration", params.duration.as_str()));
    full_params.push(("max_objects", params.max_objects.as_str()));
    full_params.push(("min_alt", params.min_alt.as_str()));
    full_params.push(("solar_elong", params.solar_elong.as_str()));
    full_params.push(("lunar_elong", params.lunar_elong.as_str()));
    full_params.push(("object_type", params.object_type.as_str()));
    full_params.push(("submit", "Submit"));
    
    let url = reqwest::Url::parse_with_params(
        "https://www.minorplanetcenter.net/whatsup/index",
        full_params,
    )
    .context("Failed to create MPC URL")?;
    
    let client = reqwest::blocking::Client::new();
    let response = client
        .post(url)
        .send()
        .context("Failed to send request to MPC")?
        .text()
        .context("Failed to read MPC response")?;
    
    Ok(response)
}

//TODO: Add altitude filtering on different directions
//TODO: Write better documentation

/// Returns data from what's up list of MPC
///
/// * `params`: WhatsupParams struct with all requested parameters
pub fn parse_whats_up_response(params: &WhatsUpParams) -> Result<Vec<PossibleTarget>> {
    let mut objects: Vec<PossibleTarget> = Vec::new();
    let data = get_observing_target_list(params)?;
    let document = scraper::Html::parse_document(data.as_str());
    
    let table_item_selector = scraper::Selector::parse("td")
        .map_err(|e| anyhow!("Failed to parse table item selector: {:?}", e))?;
    let rows_selector = scraper::Selector::parse("#main table:nth-child(1) tr:not(:first-child)")
        .map_err(|e| anyhow!("Failed to parse rows selector: {:?}", e))?;
    
    let rows: Vec<scraper::ElementRef<'_>> = document.select(&rows_selector).collect();
    
    // Parse date once for all objects
    let date = Utc
        .with_ymd_and_hms(
            params.year.parse()
                .context("Failed to parse year")?,
            params.month.parse()
                .context("Failed to parse month")?,
            params.day.parse()
                .context("Failed to parse day")?,
            params.hour.parse()
                .context("Failed to parse hour")?,
            params.minute.parse()
                .context("Failed to parse minute")?,
            0,
        )
        .single()
        .context("Invalid date/time")?;
    
    for row in rows {
        let cells: Vec<scraper::ElementRef<'_>> = row.select(&table_item_selector).collect();
        match create_possible_target(cells) {
            Ok(object) => {
                if is_visible(
                    &object.ra.replace(" ", ":"),
                    &object.dec.replace(" ", ":"),
                    date,
                ) {
                    objects.push(object);
                }
            }
            Err(e) => {
                // Log error but continue processing other objects
                eprintln!("Warning: Failed to create object: {}", e);
            }
        }
    }
    
    Ok(objects)
}

fn create_possible_target(item: Vec<scraper::ElementRef<'_>>) -> Result<PossibleTarget> {
    let mut possible_target = PossibleTarget::default();

    // Verifica che ci siano abbastanza elementi
    if item.len() < 8 {
        return Err(anyhow!("Not enough elements in input vector"));
    }

    let designation_selector =
        scraper::Selector::parse("a").map_err(|e| anyhow!("Failed to parse selector: {}", e))?;

    let designation = item[table_indices::DESIGNATION]
        .select(&designation_selector)
        .next()
        .ok_or_else(|| anyhow!("Designation element not found"))?;

    possible_target.designation = designation.inner_html();

    possible_target.magnitude = item[table_indices::MAGNITUDE]
        .inner_html()
        .parse::<f32>()
        .map_err(|e| anyhow!("Failed to parse magnitude: {}", e))?;

    possible_target.altitude = item[table_indices::BEG_ALT]
        .inner_html()
        .replace(' ', "")
        .parse::<f32>()
        .map_err(|e| anyhow!("Failed to parse altitude: {}", e))?;

    possible_target.ra = item[table_indices::BEG_RA].inner_html();
    possible_target.dec = item[table_indices::BEG_DEC].inner_html();

    Ok(possible_target)
}

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

    #[test]
    fn test_get_observing_target_list() {
        let result = get_observing_target_list(&WhatsUpParams::default());
        assert!(result.is_ok());
        assert!(result.unwrap().contains("Designation"));
    }

    #[test]
    fn test_parse_whats_up_response() {
        let result = parse_whats_up_response(&WhatsUpParams::default());
        // Test that the function doesn't panic and returns a valid result
        // Note: The result may be empty depending on observatory settings and current data
        assert!(result.is_ok());
        let _objects = result.unwrap();
        // Just verify we got a Vec, even if empty
    }
}