// {{{ Imports
use std::array;
use std::num::NonZeroU64;

use anyhow::anyhow;
use anyhow::Context;
use chrono::NaiveDateTime;
use chrono::Utc;
use num::traits::Euclid;
use num::CheckedDiv;
use num::Rational32;
use num::Zero;
use poise::serenity_prelude::{CreateAttachment, CreateEmbed, CreateEmbedAuthor, Timestamp};
use rusqlite::Row;
use serde::Deserialize;
use serde::Serialize;

use crate::arcaea::chart::{Chart, Song};
use crate::commands::DataSource;
use crate::context::ErrorKind;
use crate::context::TagError;
use crate::context::TaggedError;
use crate::context::{Error, UserContext};
use crate::user::User;

use super::rating::{rating_as_fixed, rating_as_float};
use super::score::{Score, ScoringSystem};
// }}}

// {{{ Create play
#[derive(Debug, Clone)]
pub struct CreatePlay {
	discord_attachment_id: Option<NonZeroU64>,

	// Scoring details
	score: Score,
	max_recall: Option<u32>,
	far_notes: Option<u32>,
}

impl CreatePlay {
	#[inline]
	pub fn new(score: Score) -> Self {
		Self {
			discord_attachment_id: None,
			score,
			max_recall: None,
			far_notes: None,
		}
	}

	#[inline]
	pub fn with_attachment(mut self, attachment_id: NonZeroU64) -> Self {
		self.discord_attachment_id = Some(attachment_id);
		self
	}

	#[inline]
	pub fn with_fars(mut self, far_count: Option<u32>) -> Self {
		self.far_notes = far_count;
		self
	}

	#[inline]
	pub fn with_max_recall(mut self, max_recall: Option<u32>) -> Self {
		self.max_recall = max_recall;
		self
	}

	// {{{ Save
	pub async fn save(
		self,
		ctx: &UserContext,
		user: &User,
		chart: &Chart,
	) -> Result<Play, TaggedError> {
		let conn = ctx.db.get()?;
		let attachment_id = self.discord_attachment_id.map(|i| i.get() as i64);

		// {{{ Save current data to play
		let (id, created_at) = conn
			.prepare_cached(
				"
        INSERT INTO plays(
            user_id,chart_id,discord_attachment_id,
            max_recall,far_notes
        )
        VALUES(?,?,?,?,?)
        RETURNING id, created_at
      ",
			)?
			.query_row(
				(
					user.id,
					chart.id,
					attachment_id,
					self.max_recall,
					self.far_notes,
				),
				|row| {
					Ok((
						row.get("id")?,
						default_while_testing(row.get("created_at")?),
					))
				},
			)
			.with_context(|| {
				format!(
					"Could not create play {self:?} with user {:?} and chart {:?}",
					user.id, chart.id
				)
			})?;
		// }}}
		// {{{ Update creation ptt data
		let scores = ScoreCollection::from_standard_score(self.score, chart);

		for system in ScoringSystem::SCORING_SYSTEMS {
			let i = system.to_index();
			let creation_ptt = try_compute_ptt(ctx, user, DataSource::Local, system, None).await?;

			conn.prepare_cached(
				"
          INSERT INTO scores(play_id, score, creation_ptt, scoring_system)
          VALUES (?,?,?,?)
        ",
			)?
			.execute((
				id,
				scores.0[i].0,
				creation_ptt,
				ScoringSystem::SCORING_SYSTEM_DB_STRINGS[i],
			))?;
		}

		// }}}

		Ok(Play {
			id,
			created_at,
			scores,
			chart_id: chart.id,
			user_id: user.id,
			max_recall: self.max_recall,
			far_notes: self.far_notes,
		})
	}
	// }}}
}
// }}}
// {{{ Score data
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct ScoreCollection([Score; ScoringSystem::SCORING_SYSTEMS.len()]);

impl ScoreCollection {
	pub fn from_standard_score(score: Score, chart: &Chart) -> Self {
		ScoreCollection(array::from_fn(|i| {
			score.convert_to(ScoringSystem::SCORING_SYSTEMS[i], chart)
		}))
	}
}
// }}}
// {{{ Play
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Play {
	pub id: u32,
	#[allow(unused)]
	pub chart_id: u32,
	pub user_id: u32,
	pub created_at: chrono::NaiveDateTime,

	// Score details
	pub max_recall: Option<u32>,
	pub far_notes: Option<u32>,
	pub scores: ScoreCollection,
}

/// Timestamps and other similar values break golden testing.
/// This function can be used to replace such values with [Default::default]
/// while testing.
#[inline]
fn default_while_testing<D: Default>(v: D) -> D {
	if cfg!(test) {
		D::default()
	} else {
		v
	}
}

impl Play {
	// {{{ Row parsing
	#[inline]
	pub fn from_sql(chart: &Chart, row: &Row) -> Result<Self, rusqlite::Error> {
		Ok(Play {
			id: row.get("id")?,
			chart_id: row.get("chart_id")?,
			user_id: row.get("user_id")?,
			max_recall: row.get("max_recall")?,
			far_notes: row.get("far_notes")?,
			scores: ScoreCollection::from_standard_score(Score(row.get("score")?), chart),
			created_at: default_while_testing(row.get("created_at")?),
		})
	}
	// }}}
	// {{{ Query the underlying score
	#[inline]
	pub fn score(&self, system: ScoringSystem) -> Score {
		self.scores.0[system.to_index()]
	}

	#[inline]
	pub fn play_rating(&self, system: ScoringSystem, chart_constant: u32) -> Rational32 {
		self.score(system).play_rating(chart_constant)
	}

	#[inline]
	pub fn play_rating_f32(&self, system: ScoringSystem, chart_constant: u32) -> f32 {
		rating_as_float(self.score(system).play_rating(chart_constant))
	}
	// }}}
	// {{{ Play => distribution
	pub fn distribution(&self, note_count: u32) -> Option<(u32, u32, u32, u32)> {
		if let Some(fars) = self.far_notes {
			let (_, shinies, units) = self.score(ScoringSystem::Standard).analyse(note_count);
			let (pures, rem) = units.checked_sub(fars)?.div_rem_euclid(&2);
			if rem == 1 {
				println!("The impossible happened: got an invalid amount of far notes!");
				return None;
			}

			let lost = note_count.checked_sub(fars + pures)?;
			let non_max_pures = pures.checked_sub(shinies)?;
			Some((shinies, non_max_pures, fars, lost))
		} else {
			None
		}
	}
	// }}}
	// {{{ Play => status
	#[inline]
	pub fn status(&self, scoring_system: ScoringSystem, chart: &Chart) -> Option<String> {
		let score = self.score(scoring_system).0;
		if score >= 10_000_000 {
			if score > chart.note_count + 10_000_000 {
				return None;
			}

			let non_max_pures = (chart.note_count + 10_000_000).checked_sub(score)?;
			if non_max_pures == 0 {
				Some("MPM".to_string())
			} else {
				Some(format!("PM (-{})", non_max_pures))
			}
		} else if let Some(distribution) = self.distribution(chart.note_count) {
			// if no lost notes...
			if distribution.3 == 0 {
				Some(format!("FR (-{}/-{})", distribution.1, distribution.2))
			} else {
				Some(format!(
					"C (-{}/-{}/-{})",
					distribution.1, distribution.2, distribution.3
				))
			}
		} else {
			None
		}
	}

	#[inline]
	pub fn short_status(&self, scoring_system: ScoringSystem, chart: &Chart) -> Option<char> {
		let score = self.score(scoring_system).0;
		if score >= 10_000_000 {
			let non_max_pures = (chart.note_count + 10_000_000).checked_sub(score)?;
			if non_max_pures == 0 {
				Some('M')
			} else {
				Some('P')
			}
		} else if let Some((_, _, _, 0)) = self.distribution(chart.note_count) {
			Some('F')
		} else {
			Some('C')
		}
	}
	// }}}
	// {{{ Play to embed
	/// Creates a discord embed for this play.
	///
	/// The `index` variable is only used to create distinct filenames.
	pub fn to_embed(
		&self,
		ctx: &UserContext,
		user: &User,
		song: &Song,
		chart: &Chart,
		index: usize,
		author: Option<&poise::serenity_prelude::User>,
	) -> Result<(CreateEmbed, Option<CreateAttachment>), Error> {
		// {{{ Get previously best score
		let prev_play = ctx
			.db
			.get()?
			.prepare_cached(
				"
          SELECT 
            p.id, p.chart_id, p.user_id, p.created_at,
            p.max_recall, p.far_notes, s.score
          FROM plays p
          JOIN scores s ON s.play_id = p.id
          WHERE s.scoring_system='standard'
          AND p.user_id=?
          AND p.chart_id=?
          AND p.created_at<?
          ORDER BY s.score DESC
          LIMIT 1
        ",
			)?
			.query_row((user.id, chart.id, self.created_at), |row| {
				Self::from_sql(chart, row)
			})
			.ok();

		let prev_score = prev_play.as_ref().map(|p| p.score(ScoringSystem::Standard));
		let prev_zeta_score = prev_play.as_ref().map(|p| p.score(ScoringSystem::EX));
		// }}}

		let attachement_name = format!(
			"{:?}-{:?}-{:?}.png",
			song.id,
			self.score(ScoringSystem::Standard).0,
			index
		);
		let icon_attachement = chart
			.cached_jacket
			.map(|jacket| CreateAttachment::bytes(jacket.raw, &attachement_name));

		let mut embed = CreateEmbed::default()
			.title(format!(
				"{} [{:?} {}]",
				&song.title, chart.difficulty, chart.level
			))
			.field(
				"Score",
				self.score(ScoringSystem::Standard)
					.display_with_diff(prev_score)?,
				true,
			)
			.field(
				"Rating",
				self.score(ScoringSystem::Standard)
					.display_play_rating(prev_score, chart)?,
				true,
			)
			.field(
				"Grade",
				format!("{}", self.score(ScoringSystem::Standard).grade()),
				true,
			)
			.field(
				"ξ-Score",
				self.score(ScoringSystem::EX)
					.display_with_diff(prev_zeta_score)?,
				true,
			)
			// {{{ ξ-Rating
			.field(
				"ξ-Rating",
				self.score(ScoringSystem::EX)
					.display_play_rating(prev_zeta_score, chart)?,
				true,
			)
			// }}}
			.field(
				"ξ-Grade",
				format!("{}", self.score(ScoringSystem::EX).grade()),
				true,
			)
			.field(
				"Status",
				self.status(ScoringSystem::Standard, chart)
					.unwrap_or("-".to_string()),
				true,
			)
			.field(
				"Max recall",
				if let Some(max_recall) = self.max_recall {
					format!("{}", max_recall)
				} else {
					"-".to_string()
				},
				true,
			);

		if self.id != 0 {
			embed = embed.field("ID", format!("{}", self.id), true);
		}

		if icon_attachement.is_some() {
			embed = embed.thumbnail(format!("attachment://{}", &attachement_name));
		}

		if let Some(user) = author {
			let mut embed_author = CreateEmbedAuthor::new(&user.name);
			if let Some(url) = user.avatar_url() {
				embed_author = embed_author.icon_url(url);
			}

			embed = embed
				.timestamp(Timestamp::from_millis(
					self.created_at.and_utc().timestamp_millis(),
				)?)
				.author(embed_author);
		}

		Ok((embed, icon_attachement))
	}
	// }}}
}
// }}}
// {{{ General functions
pub type PlayCollection<'a> = Vec<(Play, &'a Song, &'a Chart)>;

pub async fn get_best_plays<'a>(
	ctx: &'a UserContext,
	user: &User,
	source: DataSource,
	scoring_system: ScoringSystem,
	min_amount: usize,
	max_amount: usize,
	before: Option<NaiveDateTime>,
) -> Result<PlayCollection<'a>, TaggedError> {
	// {{{ DB data fetching
	let conn = ctx.db.get()?;
	let mut plays = match source {
		DataSource::Local => {
			// {{{ Fetch plays from db
			conn.prepare_cached(
				"
        SELECT 
          p.id, p.chart_id, p.user_id, p.created_at,
          p.max_recall, p.far_notes, s.score,
          MAX(cs.score) as _cscore 
          -- ^ This is only here to make sqlite pick the correct row for the bare columns
        FROM plays p
        JOIN scores s ON s.play_id = p.id
        JOIN scores cs ON cs.play_id = p.id
        WHERE s.scoring_system='standard'
        AND cs.scoring_system=?
        AND p.user_id=?
        AND p.created_at<=?
        GROUP BY p.chart_id
      ",
			)?
			.query_and_then(
				(
					ScoringSystem::SCORING_SYSTEM_DB_STRINGS[scoring_system.to_index()],
					user.id,
					before.unwrap_or_else(|| Utc::now().naive_utc()),
				),
				|row| {
					let (song, chart) = ctx.song_cache.lookup_chart(row.get("chart_id")?)?;
					let play = Play::from_sql(chart, row)?;
					Ok((play, song, chart))
				},
			)?
			.collect::<Result<Vec<_>, Error>>()?
			// }}}
		}
		DataSource::Server => {
			// {{{ Fetch data remotely
			crate::private_server::best(ctx, user, crate::private_server::BestOptions::default())
				.await?
				.into_iter()
				.map(|play| {
					let (song, chart) = ctx.song_cache.lookup_chart(play.chart_id)?;
					Ok((play, song, chart))
				})
				.collect::<Result<Vec<_>, Error>>()?
			// }}}
		}
	};
	// }}}

	if plays.len() < min_amount {
		return Err(anyhow!(
			"Not enough plays found ({} out of a minimum of {min_amount})",
			plays.len()
		)
		.tag(crate::context::ErrorKind::User));
	}

	// {{{ B30 computation
	plays.sort_by_key(|(play, _, chart)| -play.play_rating(scoring_system, chart.chart_constant));
	plays.truncate(max_amount);
	// }}}

	Ok(plays)
}

/// Compute the current ptt of a given user.
///
/// This is similar to directly calling [get_best_plays] and then passing the
/// result into [compute_b30_ptt], except any user errors (i.e.: not enough
/// plays available) get turned into [None] values.
pub async fn try_compute_ptt(
	ctx: &UserContext,
	user: &User,
	source: DataSource,
	system: ScoringSystem,
	before: Option<NaiveDateTime>,
) -> Result<Option<i32>, Error> {
	match get_best_plays(ctx, user, source, system, 30, 30, before).await {
		Err(err) => match err.kind {
			ErrorKind::User => Ok(None),
			ErrorKind::Internal => Err(err.error),
		},
		Ok(plays) => Ok(Some(rating_as_fixed(compute_b30_ptt(system, &plays)))),
	}
}

#[inline]
pub fn compute_b30_ptt(scoring_system: ScoringSystem, plays: &PlayCollection<'_>) -> Rational32 {
	plays
		.iter()
		.map(|(play, _, chart)| play.play_rating(scoring_system, chart.chart_constant))
		.sum::<Rational32>()
		.checked_div(&Rational32::from_integer(plays.len() as i32))
		.unwrap_or(Rational32::zero())
}
// }}}
// {{{ Maintenance functions
pub async fn generate_missing_scores(ctx: &UserContext) -> Result<(), Error> {
	let conn = ctx.db.get()?;
	let mut query = conn.prepare_cached(
		"
      SELECT 
        p.id, p.chart_id, p.user_id, p.created_at,
        p.max_recall, p.far_notes, s.score
      FROM plays p
      JOIN scores s ON s.play_id = p.id
      WHERE s.scoring_system='standard'
      ORDER BY p.created_at ASC
    ",
	)?;

	let plays = query.query_and_then((), |row| -> Result<_, Error> {
		let (_, chart) = ctx.song_cache.lookup_chart(row.get("chart_id")?)?;
		let play = Play::from_sql(chart, row)?;
		Ok(play)
	})?;

	let mut i = 0;

	for play in plays {
		let play = play?;
		for system in ScoringSystem::SCORING_SYSTEMS {
			let i = system.to_index();
			let creation_ptt = try_compute_ptt(
				ctx,
				&User {
					id: play.user_id,
					..Default::default()
				},
				DataSource::Local,
				system,
				Some(play.created_at),
			)
			.await?;

			let raw_score = play.scores.0[i].0;

			conn.prepare_cached(
				"
	          INSERT INTO scores(play_id, score, creation_ptt, scoring_system)
	          VALUES ($1, $2, $3, $4)
            ON CONFLICT(play_id, scoring_system)
              DO UPDATE SET
                score=$2, creation_ptt=$3
              WHERE play_id = $1
              AND scoring_system = $4
	      ",
			)?
			.execute((
				play.id,
				raw_score,
				creation_ptt,
				ScoringSystem::SCORING_SYSTEM_DB_STRINGS[i],
			))?;
		}

		i += 1;
		println!("Processed {i} plays");
	}
	Ok(())
}
// }}}
// {{{ Play + chart + song triplet
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlayWithDetails {
	pub play: Play,
	pub song: Song,
	pub chart: Chart,
}
// }}}