use super::constants::HOURS;
use super::{resources::Resource, skill::MAX_SKILL, types::Population};
use crate::core::skill::GameSkill;
use crate::core::utils::is_default;
use crate::core::{PlanetUpgradeTarget, SpaceCove, SpaceCoveUpgradeTarget, Upgrade, MIN_SKILL};
use crate::types::{SystemTimeTick, Tick};
use crate::{
    types::*,
    types::{PlanetId, TeamId},
};
use anyhow::anyhow;
use libp2p::PeerId;
use rand::prelude::Distribution;
use rand::seq::IndexedRandom;
use rand::{RngExt, SeedableRng};
use rand_chacha::ChaCha8Rng;
use rand_distr::weighted::WeightedIndex;
use serde::{Deserialize, Serialize};
use serde_repr::{Deserialize_repr, Serialize_repr};
use std::collections::HashSet;
use std::{
    collections::HashMap,
    hash::{DefaultHasher, Hash, Hasher},
};
use strum_macros::{Display, EnumIter};

const TRADE_DELTA_SCARCITY: f32 = 3.25;
const TRADE_DELTA_BUY_SELL: f32 = 0.05;
const TRADE_DELTA_BUY_SELL_ASTEROID: f32 = 0.125;
const RESOURCE_PRICE_REFRESH_RATE_MILLIS: Tick = 2 * HOURS;

fn deserialize_upgrades<'de, D>(deserializer: D) -> Result<HashSet<PlanetUpgradeTarget>, D::Error>
where
    D: serde::Deserializer<'de>,
{
    // Legacy saves stored AsteroidUpgradeTarget including Market(2); drop unknown reprs.
    let raw = Vec::<u8>::deserialize(deserializer)?;
    Ok(raw
        .into_iter()
        .filter_map(|v| match v {
            0 => Some(PlanetUpgradeTarget::TeleportationPad),
            1 => Some(PlanetUpgradeTarget::SpaceCove),
            _ => None,
        })
        .collect())
}

#[derive(
    Debug, Display, Clone, Copy, Serialize_repr, Deserialize_repr, PartialEq, Default, EnumIter,
)]
#[repr(u8)]
pub enum PlanetType {
    BlackHole,
    Sol,
    #[default]
    Earth,
    Lava,
    Ice,
    Gas,
    Islands,
    Ring,
    Rocky,
    Wet,
    Asteroid,
}

#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq)]
pub struct Planet {
    pub id: PlanetId,
    pub peer_id: Option<PeerId>,
    pub version: u64,
    pub name: String,
    pub populations: HashMap<Population, u32>,
    pub resources: ResourceMap,
    pub filename: String,
    pub rotation_period: usize,
    pub revolution_period: usize,
    pub gravity: usize,
    pub asteroid_probability: f64, // Currently we always spawn if we should (no random chance)
    pub planet_type: PlanetType,
    pub satellites: Vec<PlanetId>,
    pub satellite_of: Option<PlanetId>,
    pub axis: (f32, f32),
    #[serde(skip_serializing_if = "is_default")]
    #[serde(default)]
    pub team_ids: Vec<TeamId>,
    #[serde(skip_serializing_if = "Option::is_none")]
    #[serde(default)]
    pub custom_radio_stream: Option<String>,
    #[serde(skip_serializing_if = "is_default")]
    #[serde(default)]
    pub pending_upgrade: Option<Upgrade<PlanetUpgradeTarget>>,
    #[serde(
        skip_serializing_if = "is_default",
        default,
        deserialize_with = "deserialize_upgrades"
    )]
    pub upgrades: HashSet<PlanetUpgradeTarget>,
    #[serde(skip_serializing_if = "is_default")]
    #[serde(default)]
    pub allow_external_teleport: bool,
}

impl Planet {
    fn price_delta(&self, merchant_bonus: f32) -> f32 {
        if self.planet_type == PlanetType::Asteroid {
            (TRADE_DELTA_BUY_SELL_ASTEROID + 1.0 / (10.0 + self.total_population() as f32))
                / merchant_bonus
        } else {
            (TRADE_DELTA_BUY_SELL + 1.0 / (10.0 + self.total_population() as f32)) / merchant_bonus
        }
    }

    fn resource_price(&self, resource: Resource) -> f32 {
        // Resource price follows a hyperbolic tangent curve
        let relative_amount =
            ((self.resources.value(&resource) as f32).bound() / MAX_SKILL).powf(2.0);
        let amount_modifier =
            relative_amount / TRADE_DELTA_SCARCITY + (1.0 - relative_amount) * TRADE_DELTA_SCARCITY;

        let random_fluctuation =
            0.2 * ((Tick::now() / RESOURCE_PRICE_REFRESH_RATE_MILLIS) as f32).sin();

        let mut s = DefaultHasher::new();
        self.name.hash(&mut s);
        let planet_fluctation = 0.05 * (s.finish() as f32).sin();

        let price = resource.base_price()
            * amount_modifier
            * (1.0 + random_fluctuation + planet_fluctation);
        log::debug!(
            "Calculated price for {} (amount={}): {} * {} = {}",
            resource,
            relative_amount,
            resource.base_price(),
            amount_modifier,
            price
        );

        price
    }

    pub fn resource_buy_price(&self, resource: Resource, merchant_bonus: f32) -> u32 {
        let price = self.resource_price(resource);
        let delta = self.price_delta(merchant_bonus);
        let buy_price = price * (1.0 + delta);

        log::debug!(
            "Buy price: {} * {} = {}",
            price,
            delta,
            (buy_price as u32).max(1)
        );
        (buy_price as u32).max(1)
    }

    pub fn resource_sell_price(&self, resource: Resource, merchant_bonus: f32) -> u32 {
        let price = self.resource_price(resource);
        let delta = self.price_delta(merchant_bonus);
        let sell_price = price * (1.0 - delta);

        log::debug!(
            "Sell price: {} * {} = {}",
            price,
            delta,
            (sell_price as u32).max(1)
        );
        sell_price as u32
    }

    pub fn total_population(&self) -> u32 {
        self.populations.values().sum()
    }

    pub fn has_market(&self, space_cove: Option<&SpaceCove>) -> bool {
        // Note: order is important. If a space cove has a tavern, population >0, but the market may not be present.
        if let Some(cove) = space_cove {
            return cove.upgrades.contains(&SpaceCoveUpgradeTarget::Market);
        }
        self.total_population() > 0
    }

    pub fn random_population(&self, rng: &mut ChaCha8Rng) -> Option<Population> {
        let weights = self
            .populations
            .iter()
            .map(|(pop, n)| (*pop, *n))
            .collect::<Vec<(Population, u32)>>();

        let dist = WeightedIndex::new(weights.iter().map(|(_, w)| w)).ok()?;
        Some(weights[dist.sample(rng)].0)
    }

    pub fn can_be_travelled_to(&self) -> AppResult<()> {
        // Cannot travel to network asteroid unless it has a space cove.
        if self.peer_id.is_some() && !self.upgrades.contains(&PlanetUpgradeTarget::SpaceCove) {
            return Err(anyhow!(
                "Cannot travel to network asteroid without a space cove."
            ));
        }

        // Cannot travel to planet with no population unless its an asteroid.
        if self.total_population() == 0 && self.planet_type != PlanetType::Asteroid {
            return Err(anyhow!("Planet {} is inhabitable", self.name));
        }

        Ok(())
    }

    pub fn asteroid(name: String, filename: String, satellite_of: PlanetId) -> Self {
        let rng = &mut ChaCha8Rng::from_rng(&mut rand::rng());
        let revolution_period: usize = [120, 180, 360]
            .choose(rng)
            .copied()
            .expect("Should select a random value");

        let mut resources = ResourceMap::new();

        resources.insert(
            Resource::SCRAPS,
            rng.random_range(MIN_SKILL + 2.5..=MAX_SKILL) as u32,
        );
        resources.insert(
            Resource::GOLD,
            rng.random_range(MIN_SKILL..=MAX_SKILL - 8.0) as u32,
        );

        Self {
            id: PlanetId::new_v4(),
            peer_id: None,
            version: 0,
            name,
            populations: HashMap::new(),
            resources,
            filename,
            rotation_period: rng.random_range(1..24),
            revolution_period,
            gravity: rng.random_range(1..4),
            asteroid_probability: 0.0,
            planet_type: PlanetType::Asteroid,
            satellites: Vec::new(),
            satellite_of: Some(satellite_of),
            axis: (rng.random_range(10.0..60.0), rng.random_range(10.0..60.0)),
            team_ids: Vec::new(),
            //TODO: add option to customize asteroid radio stream
            custom_radio_stream: None,
            pending_upgrade: None,
            upgrades: HashSet::new(),
            allow_external_teleport: false,
        }
    }
}

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

    #[test]
    fn drops_legacy_market_upgrade() {
        let mut de = serde_json::Deserializer::from_str("[0,1,2]");
        let set = deserialize_upgrades(&mut de).unwrap();
        assert_eq!(set.len(), 2);
        assert!(set.contains(&PlanetUpgradeTarget::TeleportationPad));
        assert!(set.contains(&PlanetUpgradeTarget::SpaceCove));
    }
}
