pvoutput-client 0.0.1

Client for the PVOutput.org API
Documentation
mod patterns;

use jiff::civil::Time;

use crate::{
	Client,
	parser::patterns::INVALID_TIME,
	types::{
		error::{ClientError, DataError, ExtendedDataField, Result},
		params::{GetOutputParams, GetStatisticsParams, GetSystemParams},
		response::{
			AggregatedOutput, ConsumptionStatistics, DailyStatus, FavouriteSystem, FullSystem, Output, OutputExport,
			Statistics, System, Tariffs,
		},
	},
};

pub fn daily_status(response: &str) -> Result<DailyStatus> {
	let semicolons = response.chars().filter(|c| *c == ';').count();
	let response = response.replace(';', "\n") + "\n";
	let mut reader = Client::reader(&response);

	// zeroth object: status
	let mut status: DailyStatus = reader.deserialize().next().ok_or(ClientError::MissingField)??;

	if semicolons > 0 {
		// /!\ JANK ALERT /!\
		if response.lines().nth(1).is_some_and(|l| l.chars().filter(|c| *c == ',').count() == 3) {
			// four entries - this is probably consumption data
			status.consumption = Some(reader.deserialize().next().ok_or(ClientError::MissingField)??);
		} else {
			// temperature data, one would assume
			status.temperatures = Some(reader.deserialize().next().ok_or(ClientError::MissingField)??);
		}

		if semicolons > 1 {
			// if there are two fields, the second one will surely be temperature. surely.
			status.temperatures = Some(reader.deserialize().next().ok_or(ClientError::MissingField)??);
		}
	}

	Ok(status)
}

// perfectly normal function
pub fn statistics(response: &str, params: &GetStatisticsParams) -> Result<Statistics> {
	type Params = GetStatisticsParams;
	let mut reader = Client::reader(response);

	Ok(match params {
		Params { system_id: Some(_), .. }
		| Params {
			consumption: false,
			credit_and_debit: false,
			..
		} => {
			// we built jank city!
			reader.deserialize().next().ok_or(ClientError::MissingField)??
		}
		Params {
			consumption: true,
			credit_and_debit: true,
			..
		} => {
			// we built jank city on parse and load!
			let (mut stats, consumption, credit, debit): (Statistics, ConsumptionStatistics, f32, f32) =
				reader.deserialize().next().ok_or(ClientError::MissingField)??;
			stats.consumption = Some(consumption);
			stats.credit = Some(credit);
			stats.debit = Some(debit);

			stats
		}
		Params { consumption: true, .. } => {
			// we built jank city!
			let (mut stats, consumption): (Statistics, ConsumptionStatistics) =
				reader.deserialize().next().ok_or(ClientError::MissingField)??;
			stats.consumption = Some(consumption);

			stats
		}
		Params {
			credit_and_debit: true, ..
		} => {
			// we built jank city on parse and looooad...
			let (mut stats, credit, debit): (Statistics, f32, f32) =
				reader.deserialize().next().ok_or(ClientError::MissingField)??;
			stats.credit = Some(credit);
			stats.debit = Some(debit);

			stats
		}
	})
}

pub fn system(response: &str, params: &GetSystemParams) -> Result<FullSystem> {
	let response = response.replace(';', "\n") + "\n";
	let mut reader = Client::reader(&response);

	// zeroth object: system
	let (subsystem, mut system) =
		reader.deserialize::<(System, FullSystem)>().next().ok_or(ClientError::MissingField)??;
	system.system = subsystem;

	// first object: tariffs
	// this object is always present.
	// the API returns an empty value if the parameter isn't set, which would be OK, but the `csv` parser skips empty
	// lines!
	// if `params.tariff` is false, and we try to deserialise the next object anyway, we'll end up parsing the
	// donation count as a tariff, which (obviously) fails.
	if params.tariffs
		&& let Some(tariffs) = reader.deserialize::<Tariffs>().next()
	{
		system.tariffs = tariffs?;
	}

	if params.teams {
		// only present if requested
		if let Some(teams) = reader.deserialize().next() {
			system.teams = teams?;
		}
	}

	// second object: donations
	system.donations = reader.deserialize().next().ok_or(ClientError::MissingField)??;

	// the remaining objects are optional
	if params.extended_data
		&& let Some(extended) = reader.deserialize().next()
	{
		system.extended_data = extended?;
	}

	if params.monthly_estimates
		&& let Some(monthly_estimates) = reader.deserialize().next()
	{
		system.monthly_estimates = monthly_estimates?;
	}

	Ok(system)
}

pub fn outputs(response: &str, params: &GetOutputParams) -> Result<Vec<Output>> {
	#[derive(serde::Deserialize)]
	struct NanU32(#[serde(deserialize_with = "crate::types::response::opt_nan_u32")] Option<u32>);

	let response = response.replace(';', "\n") + "\n";
	let mut reader = Client::reader(&response);

	let mut outputs = Vec::with_capacity(response.lines().count());

	loop {
		outputs.push(match (params.time_of_export, params.insolation) {
			(true, true) => match reader.deserialize::<(Output, OutputExport, NanU32)>().next() {
				Some(result) => {
					let (mut output, export, insolation) = result?;
					output.export = Some(export);
					output.insolation = insolation.0;
					output
				}
				None => break,
			},
			(false, true) => match reader.deserialize::<(Output, NanU32)>().next() {
				Some(result) => {
					let (mut output, insolation) = result?;
					output.insolation = insolation.0;
					output
				}
				None => break,
			},
			(true, false) => match reader.deserialize::<(Output, OutputExport)>().next() {
				Some(result) => {
					let (mut output, export) = result?;
					output.export = Some(export);
					output
				}
				None => break,
			},
			_ => match reader.deserialize::<Output>().next() {
				Some(output) => output?,
				None => break,
			},
		});
	}

	Ok(outputs)
}

pub fn aggregate_outputs(response: &str, export: bool) -> Result<Vec<AggregatedOutput>> {
	let response = response.replace(';', "\n") + "\n";
	let mut reader = Client::reader(&response);

	let mut outputs = Vec::with_capacity(response.lines().count());

	loop {
		outputs.push(if export {
			match reader.deserialize::<(AggregatedOutput, OutputExport)>().next() {
				Some(result) => {
					let (mut output, export) = result?;
					output.export = Some(export);
					output
				}
				None => break,
			}
		} else {
			match reader.deserialize::<AggregatedOutput>().next() {
				Some(output) => output?,
				None => break,
			}
		});
	}

	Ok(outputs)
}

pub fn favourites(response: &str) -> Result<Vec<FavouriteSystem>> {
	let mut reader = Client::reader(response);

	Ok(
		reader
			.deserialize::<(u32, System, u32)>()
			.map(|r| r.map(|(id, system, interval)| FavouriteSystem::new(system, id, interval)))
			.collect::<core::result::Result<_, _>>()?,
	)
}

pub async fn error(response: reqwest::Response) -> ClientError {
	let status = response.status();
	let response = response.text().await;
	response.map_or_else(
		|_| ClientError::UnrecognisedStatusCode(status, None),
		|response| {
			// sometimes 400 errors start with `Bad request 400: `, and sometimes they start with `Bad Request 400: `.
			// notice the capital R in `Request`.
			match response.trim().trim_start_matches("Bad request 400: ").trim_start_matches("Bad Request 400: ") {
				// general errors
				"Unauthorized 401: Invalid System ID" => ClientError::InvalidSystemId,
				"Unauthorized 401: Invalid API Key" => ClientError::InvalidApiKey,
				"Unauthorized 401: Disabled API Key" => ClientError::DisabledApiKey,
				"Forbidden 403: Read only key" => ClientError::ReadOnlyKey,
				"Unauthorized 401: Missing, invalid or inactive api key information (X-Pvoutput-Apikey)" => {
					ClientError::BadApiKey
				}
				"Forbidden 403: Exceeded number requests per hour" => ClientError::RateLimitExceeded,
				"Forbidden 403: Donation mode" => ClientError::DonationModeRequired,

				// `getstatus.jsp`
				"Unauthorized 401: Inaccessible System ID" => ClientError::InaccessibleSystemId,

				// `addoutput.jsp`, `addstatus.jsp`, `addbatchstatus.jsp`
				"No outputs added" | "Missing parameter [data]" | "No statuses added or updated" | "Invalid data format []" => {
					DataError::NoData.into()
				}

				// `addstatus.jsp`
				"Missing energy and power values" => DataError::MissingDateOrTime.into(),
				"Moon Powered" => DataError::MoonPowered.into(),
				"Invalid net and cumulative" => DataError::BothCumulativeAndNet.into(),
				"Missing net power value" => DataError::NoEnergyData.into(),

				// `addbatchstatus.jsp`
				e if e.starts_with("Missing required data") || e.starts_with("Missing parameter") => {
					DataError::MissingFields.into()
				}

				// `postsystem.jsp`
				"System name is too long" => DataError::SystemNameTooLong.into(),

				// `deletestatus.jsp`
				"Status not found" => ClientError::NotFound,

				// various
				e if e.starts_with("No") && e.ends_with("found") => ClientError::NotFound,
				"Date missing" | "Date is required" => DataError::MissingDateOrTime.into(),

				// other
				e => error_regex(e).map_or_else(|| ClientError::UnrecognisedStatusCode(status, Some(response)), Into::into),
			}
		},
	)
}

#[allow(clippy::too_many_lines, clippy::cognitive_complexity)]
pub fn error_regex(error: &str) -> Option<DataError> {
	// The Automechanical Megalomaniacal Regex-O-Matic (pat. pend.)!
	Some(if let Some(Ok(date)) = patterns::DATE_TOO_OLD.captures(error).map(|c| c[1].parse()) {
		DataError::DateTooOld(date)
	} else if let Some(Ok(date)) = patterns::DATE_TOO_NEW.captures(error).map(|c| c[1].parse()) {
		DataError::DateTooNew(date)
	} else if let Some(date) = patterns::INVALID_DATE
		.captures(error)
		.map(|c| c.get(1).map_or_else(|| c[2].to_string(), |g| g.as_str().to_string()))
	{
		DataError::InvalidDate(date)
	} else if let Some((which, Ok(value), Ok(system_size), Ok(date))) = patterns::GENERATION_OR_EXPORT_TOO_HIGH
		.captures(error)
		.map(|c| (c[1].to_string(), c[2].parse(), c[3].parse(), c[4].parse()))
	{
		if which == "Generation" {
			DataError::GenerationTooHigh {
				generation: value,
				system_size,
				date,
			}
		} else {
			debug_assert_eq!(which, "Export");
			DataError::ExportTooHigh {
				export: value,
				system_size,
				date,
			}
		}
	} else if let Some((Ok(export), Ok(generation))) =
		patterns::EXPORT_EXCEEDS_GENERATION.captures(error).map(|c| (c[1].parse(), c[2].parse()))
	{
		DataError::ExportExceedsGeneration { export, generation }
	} else if let Some((Ok(consumption), Ok(date))) =
		patterns::CONSUMPTION_TOO_HIGH.captures(error).map(|c| (c[1].parse(), c[2].parse()))
	{
		DataError::ConsumptionTooHigh(consumption, date)
	} else if let Some((Ok(peak_power), Ok(system_size), Ok(date))) =
		patterns::PEAK_POWER_TOO_HIGH.captures(error).map(|c| (c[1].parse(), c[2].parse(), c[3].parse()))
	{
		DataError::PeakPowerTooHigh {
			peak_power,
			system_size,
			date,
		}
	} else if let Some((which, Ok(date))) =
		patterns::TEMPERATURE_MISSING.captures(error).map(|c| (c[1].to_string(), c[2].parse()))
	{
		if which == "Max" {
			DataError::MinTemperatureWithoutMax(date)
		} else {
			debug_assert_eq!(which, "Min");
			DataError::MaxTemperatureWithoutMin(date)
		}
	} else if let Some(time) = INVALID_TIME
		.captures(error)
		.map(|c| c.get(1).map_or_else(|| c[2].to_string(), |g| g.as_str().to_string()))
	{
		DataError::InvalidTime(time)
	} else if let Some((which, Ok(value), Ok(system_size))) = patterns::PRODUCTION_TOO_HIGH
		.captures(error)
		.map(|c| (c[1].to_string(), c[2].parse(), c[3].parse()))
	{
		if which == "Power" {
			DataError::ExcessivePowerGeneration {
				power: value,
				system_size,
			}
		} else {
			debug_assert_eq!(which, "Energy");
			DataError::ExcessiveEnergyGeneration {
				energy: value,
				system_size,
			}
		}
	} else if let Some((Ok(value), Ok(hour), Ok(minute))) = patterns::ENERGY_TOO_HIGH_FOR_TIME
		.captures(error)
		.map(|c| (c[1].parse(), c[2].parse(), c[3].parse()))
	{
		DataError::EnergyTooHighForTime(value, Time::new(hour, minute, 0, 0).expect("time should be valid"))
	} else if let Some(data) = patterns::INVALID_DATA_FORMAT.captures(error).map(|c| c[1].to_string()) {
		DataError::InvalidDataFormat(data)
	} else if let Some((Ok(v), which)) =
		patterns::MISSING_EXTENDED_DATA_FIELD.captures(error).map(|c| (c[1].parse(), c[2].to_string()))
	{
		if which == "unit" {
			DataError::MissingExtendedDataUnit(v)
		} else {
			debug_assert_eq!(which, "label");
			DataError::MissingExtendedDataLabel(v)
		}
	} else if let Some((Ok(v), which)) =
		patterns::EXTENDED_DATA_FIELD_TOO_LONG.captures(error).map(|c| (c[1].parse(), c[2].to_string()))
	{
		if which == "unit" {
			DataError::ExtendedDataUnitTooLong(v)
		} else {
			debug_assert_eq!(which, "label");
			DataError::ExtendedDataLabelTooLong(v)
		}
	} else if let Some(Ok(v)) = patterns::INVALID_EXTENDED_DATA_GRAPH.captures(error).map(|c| c[1].parse()) {
		DataError::InvalidExtendedDataField(v, ExtendedDataField::Graph)
	} else if let Some(Ok(v)) = patterns::INVALID_EXTENDED_DATA_AXIS.captures(error).map(|c| c[1].parse()) {
		DataError::InvalidExtendedDataField(v, ExtendedDataField::Axis)
	} else if let Some(Ok(v)) = patterns::INVALID_EXTENDED_DATA_COLOUR.captures(error).map(|c| c[1].parse()) {
		DataError::InvalidExtendedDataField(v, ExtendedDataField::Colour)
	} else {
		return None;
	})
}