use std::path::{Path,PathBuf};
use std::io::{Cursor,Read,Seek};
use std::collections::HashSet;
use clap::{Args,Subcommand};
use anyhow::Result as AResult;
use rbx_dom_weak::Instance;
use strafesnet_deferred_loader::deferred_loader::LoadFailureMode;
use rbxassetid::RobloxAssetId;
use tokio::io::AsyncReadExt;

// disallow non-static lifetimes
fn static_ustr(s:&'static str)->rbx_dom_weak::Ustr{
	rbx_dom_weak::ustr(s)
}

const DOWNLOAD_LIMIT:usize=16;

#[derive(Subcommand)]
pub enum Commands{
	RobloxToSNF(RobloxToSNFSubcommand),
	DownloadAssets(DownloadAssetsSubcommand),
}

#[derive(Args)]
pub struct RobloxToSNFSubcommand {
	#[arg(long)]
	output_folder:PathBuf,
	#[arg(required=true)]
	input_files:Vec<PathBuf>,
}
#[derive(Args)]
pub struct DownloadAssetsSubcommand{
	#[arg(required=true)]
	roblox_files:Vec<PathBuf>,
	#[arg(long,group="cookie",required=true)]
	cookie_literal:Option<String>,
	#[arg(long,group="cookie",required=true)]
	cookie_envvar:Option<String>,
	#[arg(long,group="cookie",required=true)]
	cookie_file:Option<PathBuf>,
}

impl Commands{
	pub async fn run(self)->AResult<()>{
		match self{
			Commands::RobloxToSNF(subcommand)=>roblox_to_snf(subcommand.input_files,subcommand.output_folder).await,
			Commands::DownloadAssets(subcommand)=>download_assets(
				subcommand.roblox_files,
				cookie_from_args(
					subcommand.cookie_literal,
					subcommand.cookie_envvar,
					subcommand.cookie_file,
				).await?,
			).await,
		}
	}
}

async fn cookie_from_args(literal:Option<String>,environment:Option<String>,file:Option<PathBuf>)->AResult<rbx_asset::cookie::Cookie>{
	let cookie=match (literal,environment,file){
		(Some(cookie_literal),None,None)=>cookie_literal,
		(None,Some(cookie_environment),None)=>std::env::var(cookie_environment)?,
		(None,None,Some(cookie_file))=>tokio::fs::read_to_string(cookie_file).await?,
		_=>Err(anyhow::Error::msg("Illegal cookie argument triple"))?,
	};
	Ok(rbx_asset::cookie::Cookie::new(cookie))
}

#[expect(dead_code)]
#[derive(Debug)]
enum LoadDomError{
	IO(std::io::Error),
	Binary(rbx_binary::DecodeError),
	Xml(rbx_xml::DecodeError),
	UnknownFormat,
}
fn load_dom<R:Read+Seek>(mut input:R)->Result<rbx_dom_weak::WeakDom,LoadDomError>{
	let mut first_8=[0u8;8];
	input.read_exact(&mut first_8).map_err(LoadDomError::IO)?;
	input.rewind().map_err(LoadDomError::IO)?;
	match &first_8{
		b"<roblox!"=>rbx_binary::from_reader(input).map_err(LoadDomError::Binary),
		b"<roblox "=>rbx_xml::from_reader(input,rbx_xml::DecodeOptions::default()).map_err(LoadDomError::Xml),
		_=>Err(LoadDomError::UnknownFormat),
	}
}

/* The ones I'm interested in:
Beam.Texture
Decal.Texture
FileMesh.MeshId
FileMesh.TextureId
MaterialVariant.ColorMap
MaterialVariant.MetalnessMap
MaterialVariant.NormalMap
MaterialVariant.RoughnessMap
MeshPart.MeshId
MeshPart.TextureID
ParticleEmitter.Texture
Sky.MoonTextureId
Sky.SkyboxBk
Sky.SkyboxDn
Sky.SkyboxFt
Sky.SkyboxLf
Sky.SkyboxRt
Sky.SkyboxUp
Sky.SunTextureId
SurfaceAppearance.ColorMap
SurfaceAppearance.MetalnessMap
SurfaceAppearance.NormalMap
SurfaceAppearance.RoughnessMap
SurfaceAppearance.TexturePack
*/
/* These properties now use Content
BaseWrap.CageMeshContent
Decal.TextureContent
ImageButton.ImageContent
ImageLabel.ImageContent
MeshPart.MeshContent
MeshPart.TextureContent
SurfaceAppearance.ColorMapContent
SurfaceAppearance.MetalnessMapContent
SurfaceAppearance.NormalMapContent
SurfaceAppearance.RoughnessMapContent
WrapLayer.ReferenceMeshContent
*/

fn accumulate_content(content_list:&mut HashSet<RobloxAssetId>,object:&Instance,property:&'static str){
	let Some(rbx_dom_weak::types::Variant::Content(content))=object.properties.get(&static_ustr(property))else{
		println!("property={} does not exist for class={}",property,object.class.as_str());
		return;
	};
	let rbx_dom_weak::types::ContentType::Uri(uri)=content.value()else{
		println!("ContentType is not Uri");
		return;
	};
	let Ok(asset_id)=uri.parse()else{
		println!("Content failed to parse into AssetID: {:?}",content);
		return;
	};
	content_list.insert(asset_id);
}
fn accumulate_content_id(content_list:&mut HashSet<RobloxAssetId>,object:&Instance,property:&'static str){
	let Some(rbx_dom_weak::types::Variant::ContentId(content))=object.properties.get(&static_ustr(property))else{
		println!("property={} does not exist for class={}",property,object.class.as_str());
		return;
	};
	let Ok(asset_id)=content.as_str().parse()else{
		println!("Content failed to parse into AssetID: {:?}",content);
		return;
	};
	content_list.insert(asset_id);
}
async fn read_entire_file(path:impl AsRef<Path>)->Result<Cursor<Vec<u8>>,std::io::Error>{
	let mut file=tokio::fs::File::open(path).await?;
	let mut data=Vec::new();
	file.read_to_end(&mut data).await?;
	Ok(Cursor::new(data))
}
#[derive(Default)]
struct UniqueAssets{
	meshes:HashSet<RobloxAssetId>,
	unions:HashSet<RobloxAssetId>,
	textures:HashSet<RobloxAssetId>,
}
impl UniqueAssets{
	fn collect(&mut self,object:&Instance){
		match object.class.as_str(){
			"Beam"=>accumulate_content_id(&mut self.textures,object,"Texture"),
			"Decal"=>accumulate_content(&mut self.textures,object,"TextureContent"),
			"Texture"=>accumulate_content(&mut self.textures,object,"TextureContent"),
			"FileMesh"=>accumulate_content_id(&mut self.textures,object,"TextureId"),
			"MeshPart"=>{
				accumulate_content(&mut self.textures,object,"TextureContent");
				accumulate_content(&mut self.meshes,object,"MeshContent");
			},
			"SpecialMesh"=>accumulate_content_id(&mut self.meshes,object,"MeshId"),
			"ParticleEmitter"=>accumulate_content_id(&mut self.textures,object,"Texture"),
			"Sky"=>{
				accumulate_content_id(&mut self.textures,object,"MoonTextureId");
				accumulate_content_id(&mut self.textures,object,"SkyboxBk");
				accumulate_content_id(&mut self.textures,object,"SkyboxDn");
				accumulate_content_id(&mut self.textures,object,"SkyboxFt");
				accumulate_content_id(&mut self.textures,object,"SkyboxLf");
				accumulate_content_id(&mut self.textures,object,"SkyboxRt");
				accumulate_content_id(&mut self.textures,object,"SkyboxUp");
				accumulate_content_id(&mut self.textures,object,"SunTextureId");
			},
			"UnionOperation"=>accumulate_content_id(&mut self.unions,object,"AssetId"),
			_=>(),
		}
	}
}

#[expect(dead_code)]
#[derive(Debug)]
enum UniqueAssetError{
	IO(std::io::Error),
	LoadDom(LoadDomError),
}
async fn unique_assets(path:&Path)->Result<UniqueAssets,UniqueAssetError>{
	// read entire file
	let mut assets=UniqueAssets::default();
	let data=read_entire_file(path).await.map_err(UniqueAssetError::IO)?;
	let dom=load_dom(data).map_err(UniqueAssetError::LoadDom)?;
	for object in dom.into_raw().1.into_values(){
		assets.collect(&object);
	}
	Ok(assets)
}
enum DownloadType{
	Texture(RobloxAssetId),
	Mesh(RobloxAssetId),
	Union(RobloxAssetId),
}
impl DownloadType{
	fn path(&self)->PathBuf{
		match self{
			DownloadType::Texture(RobloxAssetId(asset_id))=>format!("downloaded_textures/{asset_id}").into(),
			DownloadType::Mesh(RobloxAssetId(asset_id))=>format!("meshes/{asset_id}").into(),
			DownloadType::Union(RobloxAssetId(asset_id))=>format!("unions/{asset_id}").into(),
		}
	}
	fn asset_id(&self)->u64{
		match self{
			&DownloadType::Texture(RobloxAssetId(asset_id))=>asset_id,
			&DownloadType::Mesh(RobloxAssetId(asset_id))=>asset_id,
			&DownloadType::Union(RobloxAssetId(asset_id))=>asset_id,
		}
	}
}
enum DownloadResult{
	Cached(PathBuf),
	Data(Vec<u8>),
	Failed,
}
#[derive(Default,Debug)]
struct Stats{
	total_assets:u32,
	cached_assets:u32,
	downloaded_assets:u32,
	failed_downloads:u32,
	timed_out_downloads:u32,
}
async fn download_retry(stats:&mut Stats,context:&rbx_asset::cookie::Context,download_instruction:DownloadType)->Result<DownloadResult,std::io::Error>{
	stats.total_assets+=1;
	// check if file exists on disk
	let path=download_instruction.path();
	if tokio::fs::try_exists(path.as_path()).await?{
		stats.cached_assets+=1;
		return Ok(DownloadResult::Cached(path));
	}
	let asset_id=download_instruction.asset_id();
	// if not, download file
	let mut retry=0;
	const BACKOFF_MUL:f32=1.3956124250860895286;//exp(1/3)
	let mut backoff=1000f32;
	loop{
		let asset_result=context.get_asset(rbx_asset::cookie::GetAssetRequest{
			asset_id,
			version:None,
		}).await;
		match asset_result{
			Ok(asset_result)=>{
				stats.downloaded_assets+=1;
				let data=asset_result.to_vec()?;
				tokio::fs::write(path,&data).await?;
				break Ok(DownloadResult::Data(data));
			},
			Err(rbx_asset::cookie::GetError::Response(rbx_asset::types::ResponseError::Details{status_code,url_and_body}))=>{
				if status_code.as_u16()==429{
					if retry==12{
						println!("Giving up asset download {asset_id}");
						stats.timed_out_downloads+=1;
						break Ok(DownloadResult::Failed);
					}
					println!("Hit roblox rate limit, waiting {:.0}ms...",backoff);
					tokio::time::sleep(std::time::Duration::from_millis(backoff as u64)).await;
					backoff*=BACKOFF_MUL;
					retry+=1;
				}else{
					stats.failed_downloads+=1;
					println!("weird status_code error: status_code={status_code} url={} body={}",url_and_body.url,url_and_body.body);
					break Ok(DownloadResult::Failed);
				}
			},
			Err(e)=>{
				stats.failed_downloads+=1;
				println!("sadly error: {e}");
				break Ok(DownloadResult::Failed);
			},
		}
	}
}
#[derive(Debug,thiserror::Error)]
enum ConvertTextureError{
	#[error("Io error {0:?}")]
	Io(#[from]std::io::Error),
	#[error("Image error {0:?}")]
	Image(#[from]image::ImageError),
	#[error("DDS create error {0:?}")]
	DDS(#[from]image_dds::CreateDdsError),
	#[error("DDS write error {0:?}")]
	DDSWrite(#[from]image_dds::ddsfile::Error),
}
async fn convert_texture(RobloxAssetId(asset_id):RobloxAssetId,download_result:DownloadResult)->Result<(),ConvertTextureError>{
	let data=match download_result{
		DownloadResult::Cached(path)=>tokio::fs::read(path).await?,
		DownloadResult::Data(data)=>data,
		DownloadResult::Failed=>return Ok(()),
	};
	// image::ImageFormat::Png
	// image::ImageFormat::Jpeg
	let image=image::load_from_memory(&data)?.to_rgba8();

	// pick format
	let format=if image.width()%4!=0||image.height()%4!=0{
		image_dds::ImageFormat::Rgba8UnormSrgb
	}else{
		image_dds::ImageFormat::BC7RgbaUnormSrgb
	};

	//this fails if the image dimensions are not a multiple of 4
	let dds=image_dds::dds_from_image(
		&image,
		format,
		image_dds::Quality::Slow,
		image_dds::Mipmaps::GeneratedAutomatic,
	)?;

	let file_name=format!("textures/{asset_id}.dds");
	let mut file=std::fs::File::create(file_name)?;
	dds.write(&mut file)?;
	Ok(())
}
async fn download_assets(paths:Vec<PathBuf>,cookie:rbx_asset::cookie::Cookie)->AResult<()>{
	tokio::try_join!(
		tokio::fs::create_dir_all("downloaded_textures"),
		tokio::fs::create_dir_all("textures"),
		tokio::fs::create_dir_all("meshes"),
		tokio::fs::create_dir_all("unions"),
	)?;
	// use mpsc
	let thread_limit=std::thread::available_parallelism()?.get();
	let (send_assets,mut recv_assets)=tokio::sync::mpsc::channel(DOWNLOAD_LIMIT);
	let (send_texture,mut recv_texture)=tokio::sync::mpsc::channel(thread_limit);
	// map decode dispatcher
	// read files multithreaded
	// produce UniqueAssetsResult per file
	tokio::spawn(async move{
		// move send so it gets dropped when all maps have been decoded
		// closing the channel
		let mut it=paths.into_iter();
		static SEM:tokio::sync::Semaphore=tokio::sync::Semaphore::const_new(0);
		SEM.add_permits(thread_limit);
		while let (Ok(permit),Some(path))=(SEM.acquire().await,it.next()){
			let send=send_assets.clone();
			tokio::spawn(async move{
				let result=unique_assets(path.as_path()).await;
				_=send.send(result).await;
				drop(permit);
			});
		}
	});
	// download manager
	// insert into global unique assets guy
	// add to download queue if the asset is globally unique and does not already exist on disk
	let mut stats=Stats::default();
	let context=rbx_asset::cookie::Context::new(cookie);
	let mut globally_unique_assets=UniqueAssets::default();
	// pop a job = retry_queue.pop_front() or ingest(recv.recv().await)
	// SLOW MODE:
	// acquire all permits
	// drop all permits
	// pop one job
	// if it succeeds go into fast mode
	// FAST MODE:
	// acquire one permit
	// pop a job
	let download_thread=tokio::spawn(async move{
		while let Some(result)=recv_assets.recv().await{
			let unique_assets=match result{
				Ok(unique_assets)=>unique_assets,
				Err(e)=>{
					println!("error: {e:?}");
					continue;
				},
			};
			for texture_id in unique_assets.textures{
				if globally_unique_assets.textures.insert(texture_id){
					let data=download_retry(&mut stats,&context,DownloadType::Texture(texture_id)).await?;
					send_texture.send((texture_id,data)).await?;
				}
			}
			for mesh_id in unique_assets.meshes{
				if globally_unique_assets.meshes.insert(mesh_id){
					download_retry(&mut stats,&context,DownloadType::Mesh(mesh_id)).await?;
				}
			}
			for union_id in unique_assets.unions{
				if globally_unique_assets.unions.insert(union_id){
					download_retry(&mut stats,&context,DownloadType::Union(union_id)).await?;
				}
			}
		}
		dbg!(stats);
		Ok::<(),anyhow::Error>(())
	});
	static SEM:tokio::sync::Semaphore=tokio::sync::Semaphore::const_new(0);
	SEM.add_permits(thread_limit);
	while let (Ok(permit),Some((asset_id,download_result)))=(SEM.acquire().await,recv_texture.recv().await){
		tokio::spawn(async move{
			let result=convert_texture(asset_id,download_result).await;
			drop(permit);
			result.unwrap();
		});
	}
	download_thread.await??;
	_=SEM.acquire_many(thread_limit as u32).await.unwrap();
	Ok(())
}

#[derive(Debug)]
#[expect(dead_code)]
enum ConvertError{
	IO(std::io::Error),
	SNFMap(strafesnet_snf::map::Error),
	RobloxRead(strafesnet_rbx_loader::ReadError),
	RobloxLoad(strafesnet_rbx_loader::LoadError),
}
impl std::fmt::Display for ConvertError{
	fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{
		write!(f,"{self:?}")
	}
}
impl std::error::Error for ConvertError{}

struct Errors{
	script_errors:Vec<strafesnet_rbx_loader::RunnerError>,
	convert_errors:strafesnet_rbx_loader::RecoverableErrors,
}

fn convert_to_snf(path:&Path,output_folder:PathBuf)->Result<Errors,ConvertError>{
	let entire_file=std::fs::read(path).map_err(ConvertError::IO)?;

	let model=strafesnet_rbx_loader::read(
		entire_file.as_slice()
	).map_err(ConvertError::RobloxRead)?;

	let mut place=strafesnet_rbx_loader::Place::from(model);
	let script_errors=place.run_scripts().unwrap_or_else(|e|vec![e]);

	let (map,convert_errors)=place.to_snf(LoadFailureMode::DefaultToNone).map_err(ConvertError::RobloxLoad)?;

	let mut dest=output_folder;
	dest.push(path.file_stem().unwrap());
	dest.set_extension("snfm");
	let file=std::fs::File::create(dest).map_err(ConvertError::IO)?;

	strafesnet_snf::map::write_map(file,map).map_err(ConvertError::SNFMap)?;

	Ok(Errors{
		script_errors,
		convert_errors,
	})
}

async fn roblox_to_snf(paths:Vec<PathBuf>,output_folder:PathBuf)->AResult<()>{
	let start=std::time::Instant::now();

	let thread_limit=std::thread::available_parallelism()?.get();
	let mut it=paths.into_iter();
	static SEM:tokio::sync::Semaphore=tokio::sync::Semaphore::const_new(0);
	// This is wrong! Calling roblox_to_snf multiple times keeps adding permits
	SEM.add_permits(thread_limit);

	while let (Ok(permit),Some(path))=(SEM.acquire().await,it.next()){
		let output_folder=output_folder.clone();
		tokio::task::spawn_blocking(move||{
			let result=convert_to_snf(path.as_path(),output_folder);
			drop(permit);
			match result{
				Ok(errors)=>{
					for error in errors.script_errors{
						println!("Script error: {error}");
					}
					let error_count=errors.convert_errors.count();
					if error_count!=0{
						println!("Error count: {error_count}");
						println!("Errors: {}",errors.convert_errors);
					}
				},
				Err(e)=>println!("Convert error: {e:?}"),
			}
		});
	}
	_=SEM.acquire_many(thread_limit as u32).await.unwrap();

	println!("elapsed={:?}", start.elapsed());
	Ok(())
}
