//! HTTP Basic authentication for the CardDAV endpoint.
//!
//! v1 stores the token in a 0600 file under `$XDG_CONFIG_HOME`. Phase 5
//! will move primary storage to Secret Service with the file as fallback;
//! the on-disk format does not change, so the migration is transparent.

use std::fs;
use std::io::Write;
use std::os::unix::fs::{ OpenOptionsExt, PermissionsExt };
use std::path::Path;

use anyhow::{ Context, Result };
use base64::Engine;
use base64::engine::general_purpose;
use hyper::header::HeaderValue;
use rand::RngCore;

/// Fixed username advertised in the CardDAV `WWW-Authenticate` realm.
pub const USERNAME: &str = "liberux";

/// Realm string for `WWW-Authenticate`.
pub const REALM: &str = "liberux-contacts";

/// Random 32-byte token, encoded as URL-safe base64 without padding.
#[ derive( Debug, Clone ) ]
pub struct Token
{
	plaintext: String,
}

impl Token
{
	/// Read the token from `path`, generating a fresh one if the file does
	/// not exist (or is unreadable). On generation, the file is written
	/// atomically with mode 0600.
	pub fn load_or_generate( path: &Path ) -> Result<Self>
	{
		if let Ok( raw ) = fs::read_to_string( path )
		{
			let trimmed = raw.trim();
			if !trimmed.is_empty()
			{
				return Ok( Self { plaintext: trimmed.to_owned() } );
			}
		}
		let token = Self::fresh();
		token.persist( path )?;
		Ok( token )
	}

	pub fn fresh() -> Self
	{
		let mut bytes = [ 0u8; 32 ];
		rand::thread_rng().fill_bytes( &mut bytes );
		Self
		{
			plaintext: general_purpose::URL_SAFE_NO_PAD.encode( bytes ),
		}
	}

	pub fn as_str( &self ) -> &str
	{
		&self.plaintext
	}

	fn persist( &self, path: &Path ) -> Result<()>
	{
		if let Some( parent ) = path.parent()
		{
			fs::create_dir_all( parent )
				.with_context( || format!( "creating {}", parent.display() ) )?;
		}

		let tmp = path.with_extension( "token.tmp" );
		{
			let mut f = fs::OpenOptions::new()
				.create( true )
				.write( true )
				.truncate( true )
				.mode( 0o600 )
				.open( &tmp )
				.with_context( || format!( "creating {}", tmp.display() ) )?;
			f.write_all( self.plaintext.as_bytes() )?;
			f.write_all( b"\n" )?;
			f.sync_all()?;
		}
		// Atomic rename: ensures readers never observe a partial token.
		fs::rename( &tmp, path )
			.with_context( || format!( "renaming {} -> {}", tmp.display(), path.display() ) )?;
		// Re-assert mode in case the umask altered it on creation.
		fs::set_permissions( path, fs::Permissions::from_mode( 0o600 ) )?;
		Ok( () )
	}
}

/// Outcome of authenticating an incoming request.
pub enum AuthOutcome
{
	Authorized,
	MissingOrInvalid,
}

/// Verify the `Authorization: Basic ...` header against the stored token.
///
/// Returns `Authorized` only on an exact match against `liberux:<token>`.
/// Anything else (missing header, wrong scheme, wrong username, wrong
/// password, malformed base64) returns `MissingOrInvalid` — the caller
/// translates that into a 401 with `WWW-Authenticate`.
pub fn authenticate( header: Option<&HeaderValue>, token: &Token ) -> AuthOutcome
{
	let Some( header ) = header else { return AuthOutcome::MissingOrInvalid };
	let Ok( raw ) = header.to_str()                else { return AuthOutcome::MissingOrInvalid };

	let payload = match raw.strip_prefix( "Basic " )
	{
		Some( p ) => p.trim(),
		None      => return AuthOutcome::MissingOrInvalid,
	};
	let Ok( decoded ) = general_purpose::STANDARD.decode( payload ) else
	{
		return AuthOutcome::MissingOrInvalid;
	};
	let Ok( text ) = std::str::from_utf8( &decoded ) else
	{
		return AuthOutcome::MissingOrInvalid;
	};

	let ( user, pass ) = match text.split_once( ':' )
	{
		Some( pair ) => pair,
		None         => return AuthOutcome::MissingOrInvalid,
	};

	if user == USERNAME && constant_time_eq( pass.as_bytes(), token.as_str().as_bytes() )
	{
		AuthOutcome::Authorized
	} else {
		AuthOutcome::MissingOrInvalid
	}
}

/// Constant-time byte comparison so we don't leak the token via timing.
fn constant_time_eq( a: &[ u8 ], b: &[ u8 ] ) -> bool
{
	if a.len() != b.len()
	{
		return false;
	}
	let mut diff: u8 = 0;
	for ( x, y ) in a.iter().zip( b.iter() )
	{
		diff |= x ^ y;
	}
	diff == 0
}

#[ cfg( test ) ]
mod tests
{
	use super::*;
	use std::os::unix::fs::PermissionsExt;
	use tempfile::tempdir;

	#[ test ]
	fn token_is_persisted_and_round_trips()
	{
		let dir = tempdir().unwrap();
		let path = dir.path().join( "token" );

		let first = Token::load_or_generate( &path ).unwrap();
		let second = Token::load_or_generate( &path ).unwrap();
		assert_eq!( first.as_str(), second.as_str() );

		// File mode is 0600.
		let perm = fs::metadata( &path ).unwrap().permissions().mode() & 0o777;
		assert_eq!( perm, 0o600 );
	}

	#[ test ]
	fn authenticate_accepts_correct_pair()
	{
		let token = Token::fresh();
		let creds = format!( "{}:{}", USERNAME, token.as_str() );
		let header_value = format!( "Basic {}", general_purpose::STANDARD.encode( &creds ) );
		let h: HeaderValue = header_value.parse().unwrap();
		assert!( matches!( authenticate( Some( &h ), &token ), AuthOutcome::Authorized ) );
	}

	#[ test ]
	fn authenticate_rejects_wrong_pass()
	{
		let token = Token::fresh();
		let header_value = format!(
			"Basic {}",
			general_purpose::STANDARD.encode( format!( "{}:wrong", USERNAME ) ),
		);
		let h: HeaderValue = header_value.parse().unwrap();
		assert!( matches!( authenticate( Some( &h ), &token ), AuthOutcome::MissingOrInvalid ) );
	}

	#[ test ]
	fn authenticate_rejects_wrong_user()
	{
		let token = Token::fresh();
		let header_value = format!(
			"Basic {}",
			general_purpose::STANDARD.encode( format!( "root:{}", token.as_str() ) ),
		);
		let h: HeaderValue = header_value.parse().unwrap();
		assert!( matches!( authenticate( Some( &h ), &token ), AuthOutcome::MissingOrInvalid ) );
	}
}
