//! atproto-static-web   A simple web viewer for AT Proto.
//! Copyright (C) 2025  AverageHelper
//!
//! This program is free software: you can redistribute it and/or modify
//! it under the terms of the GNU General Public License as published by
//! the Free Software Foundation, either version 3 of the License, or
//! (at your option) any later version.
//!
//! This program is distributed in the hope that it will be useful,
//! but WITHOUT ANY WARRANTY; without even the implied warranty of
//! MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
//! GNU General Public License for more details.
//!
//! You should have received a copy of the GNU General Public License
//! along with this program.  If not, see <https://www.gnu.org/licenses/>.

use super::validations::{LimitedString, LimitedVec};
use crate::{
	atproto::{AtUri, Authority, DecentralizedIdentifier, Handle, RecordKey},
	env::Env,
	xrpc::generic::ErrorRes,
};
use log::debug;
use reqwest::Client;
use serde::Deserialize;
use std::str::FromStr;
use url::Url;

pub async fn get_post_thread(
	client: &Client,
	env: &Env,
	profile: &str,
	post: &str,
) -> Result<ThreadViewPost, GetPostThreadError> {
	let authority: Authority = profile.parse()?;
	let record = post.parse()?;
	let post_uri = AtUri::from_post(authority, record);
	get_post_with_uri(client, env, &post_uri).await
}

pub async fn get_post_with_uri(
	client: &Client,
	env: &Env,
	uri: &AtUri,
) -> Result<ThreadViewPost, GetPostThreadError> {
	// https://docs.bsky.app/docs/api/app-bsky-feed-get-post-thread
	let mut url = env
		.pds_url()
		.join("/xrpc/app.bsky.feed.getPostThread")
		.expect("valid url");

	url.query_pairs_mut()
		.append_pair("uri", &uri.to_string())
		.append_pair("parentHeight", "1") // TODO: Change this back to 80 once we've got a good UI for it
		.append_pair("depth", "0") // TODO: Change this back to 6 once we're sure about how we parse bsky's lexicon for replies
		.finish();
	debug!("GET {url}");

	let req = client.get(url).build().expect("valid request");
	let res = client.execute(req).await?;
	let status = res.status();
	let text = res.text().await?;

	// Handle upstream errors
	if !status.is_success() {
		debug!("↗ {status}: {text}");

		if let Ok(res) = serde_json::from_str::<ErrorRes>(&text) {
			// If the body gives us an error payload we recognize, handle it appropriately.
			return Err(GetPostThreadError::Upstream(res.status(), res.message));
		} else {
			// If the error payload isn't known to us, treat the whole body as the message.
			return Err(GetPostThreadError::Upstream(status, text));
		}
	}

	let data: ThreadData = match serde_json::from_str(&text) {
		Ok(d) => d,
		Err(err) => {
			debug!("{text}");
			return Err(err.into());
		}
	};
	Ok(data.thread)
}

#[derive(Debug)]
pub enum GetPostThreadError {
	Deserialize(serde_json::Error),
	InvalidAuthority(<Authority as FromStr>::Err),
	InvalidRecordKey(<RecordKey as FromStr>::Err),
	Network(reqwest::Error),
	Upstream(reqwest::StatusCode, String),
}

impl core::fmt::Display for GetPostThreadError {
	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
		match self {
			Self::Deserialize(_) => write!(f, "could not parse the upstream server's response"),
			Self::InvalidAuthority(_) => {
				write!(f, "the upstream server gave us an invalid DID or handle")
			}
			Self::InvalidRecordKey(_) => {
				write!(f, "the upstream server gave us an invalid record ID")
			}
			Self::Network(_) => write!(f, "network error"),
			Self::Upstream(_, _) => write!(f, "the upstream server returned an error"),
		}
	}
}

impl core::error::Error for GetPostThreadError {}

impl From<<Authority as FromStr>::Err> for GetPostThreadError {
	fn from(value: <Authority as FromStr>::Err) -> Self {
		Self::InvalidAuthority(value)
	}
}

impl From<<RecordKey as FromStr>::Err> for GetPostThreadError {
	fn from(value: <RecordKey as FromStr>::Err) -> Self {
		Self::InvalidRecordKey(value)
	}
}

impl From<serde_json::Error> for GetPostThreadError {
	fn from(value: serde_json::Error) -> Self {
		Self::Deserialize(value)
	}
}

impl From<reqwest::Error> for GetPostThreadError {
	fn from(value: reqwest::Error) -> Self {
		Self::Network(value)
	}
}

#[derive(Deserialize)]
pub struct ThreadData {
	thread: ThreadViewPost,
}

/// [`app.bsky.feed.threadViewPost`](https://github.com/bluesky-social/atproto/blob/757fa34458b6ad20ea2f1a5c6726e326c8b8eae2/lexicons/app/bsky/feed/defs.json#L110)
#[derive(Deserialize)]
pub struct ThreadViewPost {
	pub post: PostView,
	pub parent: Option<Post>,
	#[serde(default)]
	pub replies: Vec<Post>,
}

#[derive(Deserialize)]
#[serde(untagged)]
pub enum Post {
	/// [`app.bsky.feed.threadViewPost`](https://github.com/bluesky-social/atproto/blob/757fa34458b6ad20ea2f1a5c6726e326c8b8eae2/lexicons/app/bsky/feed/defs.json#L110)
	ThreadView(Box<ThreadViewPost>),

	/// [`app.bsky.feed.notFoundPost`](https://github.com/bluesky-social/atproto/blob/757fa34458b6ad20ea2f1a5c6726e326c8b8eae2/lexicons/app/bsky/feed/defs.json#L129)
	NotFound {
		#[serde(rename = "notFound")]
		_not_found: bool,
	},

	/// [`app.bsky.feed.notFoundPost`](https://github.com/bluesky-social/atproto/blob/757fa34458b6ad20ea2f1a5c6726e326c8b8eae2/lexicons/app/bsky/feed/defs.json#L137)
	Blocked {
		#[serde(rename = "blocked")]
		_blocked: bool,
	},
}

/// [`app.bsky.feed.postView`](https://github.com/bluesky-social/atproto/blob/757fa34458b6ad20ea2f1a5c6726e326c8b8eae2/lexicons/app/bsky/feed/defs.json#L5)
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PostView {
	pub uri: AtUri,
	pub author: ProfileViewBasic,
	pub record: PostRecord,
	pub reply_count: Option<u64>, // FIXME: These aren't clamped in the definition, so we should maybe handle negatives somehow
	pub repost_count: Option<u64>,
	pub like_count: Option<u64>,
	#[serde(rename = "quoteCount")]
	pub _quote_count: Option<u64>,
	pub embed: Option<EmbedView>,
}

/// [`app.bsky.embed.*`](https://github.com/bluesky-social/atproto/tree/757fa34458b6ad20ea2f1a5c6726e326c8b8eae2/lexicons/app/bsky/embed)
#[derive(Deserialize)]
#[serde(untagged)]
pub enum EmbedView {
	/// [`app.bsky.embed.images#view`](https://github.com/bluesky-social/atproto/blob/757fa34458b6ad20ea2f1a5c6726e326c8b8eae2/lexicons/app/bsky/embed/images.json#L36)
	Images { images: LimitedVec<4, ViewImage> },

	/// [`app.bsky.embed.video#view`](https://github.com/bluesky-social/atproto/blob/757fa34458b6ad20ea2f1a5c6726e326c8b8eae2/lexicons/app/bsky/embed/video.json#L48)
	Video {
		playlist: Url,
		thumbnail: Option<Url>,
		alt: LimitedString<10_000, 1_000>,
	},

	/// [`app.bsky.embed.external#view`](https://github.com/bluesky-social/atproto/blob/757fa34458b6ad20ea2f1a5c6726e326c8b8eae2/lexicons/app/bsky/embed/external.json#L30)
	External { external: ViewExternal },
	// TODO: handle record#view and recordWithMedia#view cases
}

/// [`app.bsky.embed.images#view`](https://github.com/bluesky-social/atproto/blob/757fa34458b6ad20ea2f1a5c6726e326c8b8eae2/lexicons/app/bsky/embed/images.json#L47)
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ViewImage {
	/// Alt text description of the image, for accessibility.
	pub alt: String,

	/// Fully-qualified URL where a large version of the image can be fetched. May or may not be the exact original blob. For example, CDN location provided by the App View.
	pub fullsize: Url,

	/// Fully-qualified URL where a thumbnail of the image can be fetched. For example, CDN location provided by the App View.
	pub thumb: Url,
}

/// [`app.bsky.embed.external#view`](https://github.com/bluesky-social/atproto/blob/757fa34458b6ad20ea2f1a5c6726e326c8b8eae2/lexicons/app/bsky/embed/external.json#L40)
#[derive(Deserialize)]
pub struct ViewExternal {
	pub uri: Url,
	pub title: String,
	pub description: String,
	pub thumb: Option<Url>,
}

/// [`app.bsky.feed.post`](https://github.com/bluesky-social/atproto/blob/main/lexicons/app/bsky/feed/post.json)
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PostRecord {
	/// The primary post content. May be an empty string, if there are embeds.
	#[serde(default)]
	pub text: LimitedString<3000, 300>,

	/// Annotations of text (mentions, URLs, hashtags, etc)
	#[serde(default)]
	pub facets: Vec<Facet>,

	/// Indicates human language of post primary text content.
	#[serde(default)]
	pub langs: Vec<oxilangtag::LanguageTag<String>>,

	/// Client-declared timestamp when this post was originally created.
	pub created_at: chrono::DateTime<chrono::Utc>,
}

/// [`app.bsky.richtext.facet`](https://github.com/bluesky-social/atproto/blob/main/lexicons/app/bsky/richtext/facet.json)
#[derive(Deserialize)]
pub struct Facet {
	pub index: ByteSlice,
	pub features: Vec<Feature>,
}

/// Specifies the sub-string range a facet feature applies to. Start index is inclusive, end
/// index is exclusive. Indices are zero-indexed, counting bytes of the UTF-8 encoded text.
///
/// See https://github.com/bluesky-social/atproto/blob/main/lexicons/app/bsky/richtext/facet.json
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ByteSlice {
	pub byte_start: u64,
	pub byte_end: u64,
}

/// See https://github.com/bluesky-social/atproto/blob/main/lexicons/app/bsky/richtext/facet.json
#[derive(Deserialize)]
#[serde(untagged)]
pub enum Feature {
	/// Facet feature for mention of another account. The text is usually a handle, including a '@' prefix, but the facet reference is a DID.
	Mention { did: DecentralizedIdentifier },

	/// Facet feature for a URL. The text URL may have been simplified or truncated, but the facet reference should be a complete URL.
	Link { uri: Url },

	/// Facet feature for a hashtag. The text usually includes a '#' prefix, but the facet reference should not (except in the case of 'double hash tags').
	Tag { tag: LimitedString<640, 64> },
}

/// [`app.bsky.actor.profileViewBasic`](https://github.com/bluesky-social/atproto/blob/757fa34458b6ad20ea2f1a5c6726e326c8b8eae2/lexicons/app/bsky/actor/defs.json#L5)
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProfileViewBasic {
	pub did: DecentralizedIdentifier,
	pub handle: Handle,
	pub avatar: Option<Url>,

	#[serde(rename = "createdAt")]
	pub _created_at: Option<chrono::DateTime<chrono::Utc>>,

	pub display_name: Option<LimitedString<640, 64>>,
}
