//! vCard 3.0 (RFC 2426) serializer.
//!
//! Output is **deterministic**: properties are emitted in a fixed order
//! and entries within a multi-valued family are sorted, so the resulting
//! ETag is stable across reads.
//!
//! See `docs/vcard-mapping.md` for the schema → property map. The full
//! Apple-grouped `X-ABLabel` trick for `TYPE_CUSTOM` is intentionally
//! deferred to Phase 4 polish; v1 emits a plain `TYPE=<label>` instead,
//! which EDS, KAddressBook and GNOME Contacts all surface correctly.

use std::collections::HashMap;

use base64::Engine;
use base64::engine::general_purpose;
use chrono::TimeZone;

use crate::model::data_item::DataRow;
use crate::model::mimetype;
use crate::model::raw_contact::RawContact;

/// Inputs to one vCard's worth of serialization.
pub struct VCardInput<'a>
{
	pub uid:         &'a str,
	pub raw_contact: &'a RawContact,
	pub data_rows:   &'a [ DataRow ],
}

/// Build the byte body of a vCard 3.0 document. CRLF-terminated, folded at
/// 75 octets, UTF-8.
pub fn serialize( input: VCardInput<'_> ) -> Vec<u8>
{
	let mut buf = String::new();
	emit_unfolded( &mut buf, &input );
	fold( &buf ).into_bytes()
}

fn emit_unfolded( out: &mut String, input: &VCardInput<'_> )
{
	let by_mime = group_by_mimetype( input.data_rows );

	push_line( out, "BEGIN:VCARD" );
	push_line( out, "VERSION:3.0" );
	push_line( out, "PRODID:-//Liberux//contacts-server 0.1//EN" );
	push_line( out, &format!( "UID:urn:uuid:{}", input.uid ) );

	emit_name_block( out, by_mime.get( mimetype::NAME ), input.raw_contact );

	for row in flat( &by_mime, mimetype::NICKNAME ) { emit_nickname( out, row ); }
	for row in flat( &by_mime, mimetype::ORGANIZATION ) { emit_organization( out, row ); }
	for row in flat( &by_mime, mimetype::EMAIL_V2 ) { emit_email( out, row ); }
	for row in flat( &by_mime, mimetype::PHONE_V2 ) { emit_phone( out, row ); }
	for row in flat( &by_mime, mimetype::POSTAL_V2 ) { emit_postal( out, row ); }
	for row in flat( &by_mime, mimetype::IM ) { emit_im( out, row ); }
	for row in flat( &by_mime, mimetype::WEBSITE ) { emit_website( out, row ); }
	for row in flat( &by_mime, mimetype::EVENT ) { emit_event( out, row ); }
	for row in flat( &by_mime, mimetype::NOTE ) { emit_note( out, row ); }

	// Photos: emit at most one — vCard 3.0 PHOTO is single-valued in
	// practice. If the contact has several photo rows we pick the highest
	// priority (super_primary, primary, lowest id) per the sort below.
	if let Some( rows ) = by_mime.get( mimetype::PHOTO )
	{
		if let Some( row ) = rows.first() { emit_photo( out, row ); }
	}

	push_line( out, &format!( "REV:{}", format_rev( input.raw_contact.updated_at ) ) );
	push_line( out, "END:VCARD" );
}

// -- per-mimetype emitters ----------------------------------------------------

fn emit_name_block( out: &mut String, rows: Option<&Vec<&DataRow>>, raw: &RawContact )
{
	if let Some( vec ) = rows
	{
		if let Some( row ) = vec.first()
		{
			let display     = field( row, 1 );
			let given       = field( row, 2 );
			let family      = field( row, 3 );
			let prefix      = field( row, 4 );
			let middle      = field( row, 5 );
			let suffix      = field( row, 6 );

			let fn_value = if !display.is_empty()
			{
				display.to_string()
			} else {
				let synth = format!( "{} {}", given, family );
				let trimmed = synth.trim();
				if !trimmed.is_empty() { trimmed.to_owned() }
				else { fallback_fn( raw ) }
			};

			push_line( out, &format!( "FN:{}", escape_text( &fn_value ) ) );
			push_line( out, &format!(
				"N:{};{};{};{};{}",
				escape_text( family ),
				escape_text( given ),
				escape_text( middle ),
				escape_text( prefix ),
				escape_text( suffix ),
			) );

			let phon_given  = field( row, 7 );
			let phon_middle = field( row, 8 );
			let phon_family = field( row, 9 );
			if !phon_family.is_empty() { push_line( out, &format!( "X-PHONETIC-LAST-NAME:{}",   escape_text( phon_family ) ) ); }
			if !phon_given.is_empty()  { push_line( out, &format!( "X-PHONETIC-FIRST-NAME:{}",  escape_text( phon_given ) ) ); }
			if !phon_middle.is_empty() { push_line( out, &format!( "X-PHONETIC-MIDDLE-NAME:{}", escape_text( phon_middle ) ) ); }
			return;
		}
	}

	let fn_value = fallback_fn( raw );
	push_line( out, &format!( "FN:{}", escape_text( &fn_value ) ) );
	push_line( out, "N:;;;;" );
}

fn emit_nickname( out: &mut String, row: &DataRow )
{
	let name = field( row, 1 );
	if name.is_empty() { return; }
	push_line( out, &format!( "NICKNAME:{}", escape_text( name ) ) );

	if let Some( code ) = field_int( row, 2 )
	{
		push_line( out, &format!( "X-LIBERUX-NICKNAME-TYPE:{}", code ) );
	}
}

fn emit_organization( out: &mut String, row: &DataRow )
{
	let company    = field( row, 1 );
	let title      = field( row, 4 );
	let department = field( row, 5 );
	let role       = field( row, 6 );
	let office     = field( row, 9 );

	if !company.is_empty() || !department.is_empty()
	{
		push_line( out, &format!(
			"ORG:{};{}",
			escape_text( company ),
			escape_text( department ),
		) );
	}
	if !title.is_empty()  { push_line( out, &format!( "TITLE:{}", escape_text( title ) ) ); }
	if !role.is_empty()   { push_line( out, &format!( "ROLE:{}",  escape_text( role ) ) ); }
	if !office.is_empty()
	{
		push_line( out, &format!( "X-LIBERUX-OFFICE-LOCATION:{}", escape_text( office ) ) );
	}
}

fn emit_email( out: &mut String, row: &DataRow )
{
	let address = field( row, 1 );
	if address.is_empty() { return; }
	let type_code = field_int( row, 2 ).unwrap_or( 3 );
	let label     = field( row, 3 );

	let mut types = email_types( type_code );
	if row.is_super_primary { types.push( "PREF" ); }

	let params = compose_type_param( &types, type_code, label );
	push_line( out, &format!( "EMAIL{}:{}", params, escape_text( address ) ) );
}

fn email_types( code: i32 ) -> Vec<&'static str>
{
	match code
	{
		1 => vec![ "HOME", "INTERNET" ],
		2 => vec![ "WORK", "INTERNET" ],
		3 => vec![ "INTERNET" ],
		4 => vec![ "INTERNET", "X-MOBILE" ],
		_ => vec![ "INTERNET" ], // CUSTOM (0) and any unknown value
	}
}

fn emit_phone( out: &mut String, row: &DataRow )
{
	let number = field( row, 1 );
	if number.is_empty() { return; }
	let type_code = field_int( row, 2 ).unwrap_or( 7 );
	let label     = field( row, 3 );
	let normalized = field( row, 4 );

	let mut types = phone_types( type_code );
	if row.is_super_primary { types.push( "PREF" ); }

	let params = compose_type_param( &types, type_code, label );
	push_line( out, &format!( "TEL{}:{}", params, escape_text( number ) ) );

	if !normalized.is_empty()
	{
		push_line( out, &format!( "X-LIBERUX-NORMALIZED-NUMBER:{}", escape_text( normalized ) ) );
	}
	// Round-trip the original Android type code so an importer can rebuild
	// the exact data2 even when we can't map it cleanly to a vCard TYPE.
	push_line( out, &format!( "X-LIBERUX-PHONE-V2-TYPE-CODE:{}", type_code ) );
}

fn phone_types( code: i32 ) -> Vec<&'static str>
{
	match code
	{
		1  => vec![ "HOME", "VOICE" ],
		2  => vec![ "CELL", "VOICE" ],
		3  => vec![ "WORK", "VOICE" ],
		4  => vec![ "WORK", "FAX" ],
		5  => vec![ "HOME", "FAX" ],
		6  => vec![ "PAGER" ],
		7  => vec![ "VOICE" ],
		9  => vec![ "VOICE", "X-CALLBACK" ],
		10 => vec![ "CAR",   "VOICE" ],
		11 => vec![ "WORK",  "VOICE", "X-COMPANY-MAIN" ],
		12 => vec![ "ISDN" ],
		13 => vec![ "VOICE", "PREF" ],
		14 => vec![ "FAX" ],
		15 => vec![ "VOICE", "X-RADIO" ],
		17 => vec![ "VOICE", "X-TTY" ],
		18 => vec![ "WORK",  "CELL" ],
		19 => vec![ "WORK",  "PAGER" ],
		20 => vec![ "VOICE", "X-ASSISTANT" ],
		21 => vec![ "MSG",   "X-MMS" ],
		_  => vec![ "VOICE" ], // CUSTOM (0) and unknown
	}
}

fn emit_postal( out: &mut String, row: &DataRow )
{
	let formatted = field( row, 1 );
	let type_code = field_int( row, 2 ).unwrap_or( 3 );
	let label     = field( row, 3 );

	let street    = field( row, 4 );
	let pobox     = field( row, 5 );
	let city      = field( row, 7 );
	let region    = field( row, 8 );
	let postcode  = field( row, 9 );
	let country   = field( row, 10 );

	if [ formatted, street, pobox, city, region, postcode, country ].iter().all( |s| s.is_empty() )
	{
		return;
	}

	let types = postal_types( type_code );
	let params = compose_type_param( &types, type_code, label );

	push_line( out, &format!(
		"ADR{}:{};;{};{};{};{};{}",
		params,
		escape_text( pobox ),
		escape_text( street ),
		escape_text( city ),
		escape_text( region ),
		escape_text( postcode ),
		escape_text( country ),
	) );

	if !formatted.is_empty()
	{
		let label_params = compose_type_param( &types, type_code, label );
		push_line( out, &format!( "LABEL{}:{}", label_params, escape_text( formatted ) ) );
	}
}

fn postal_types( code: i32 ) -> Vec<&'static str>
{
	match code
	{
		1 => vec![ "HOME" ],
		2 => vec![ "WORK" ],
		_ => vec![],
	}
}

fn emit_im( out: &mut String, row: &DataRow )
{
	let handle = field( row, 1 );
	if handle.is_empty() { return; }
	let protocol = field_int( row, 5 ).unwrap_or( -1 );
	let custom_proto = field( row, 6 );
	let ( service, scheme ) = im_service( protocol, custom_proto );

	let value = format!( "{}:{}", scheme, handle );
	push_line( out, &format!(
		"IMPP;X-SERVICE-TYPE={}:{}",
		escape_param( service ),
		escape_text( &value ),
	) );
	push_line( out, &format!( "X-LIBERUX-IM-PROTOCOL:{}", protocol ) );
}

fn im_service( code: i32, custom: &str ) -> ( &str, &str )
{
	match code
	{
		0 => ( "AIM",         "aim" ),
		1 => ( "MSN",         "msnim" ),
		2 => ( "Yahoo",       "ymsgr" ),
		3 => ( "Skype",       "skype" ),
		4 => ( "QQ",          "x-apple" ),
		5 => ( "GoogleTalk",  "xmpp" ),
		6 => ( "ICQ",         "aim" ),
		7 => ( "Jabber",      "xmpp" ),
		8 => ( "NetMeeting",  "x-apple" ),
		_ =>
		{
			// CUSTOM: use the user-supplied protocol name on both sides.
			let name = if custom.is_empty() { "Other" } else { custom };
			( name, "x-apple" )
		}
	}
}

fn emit_website( out: &mut String, row: &DataRow )
{
	let url = field( row, 1 );
	if url.is_empty() { return; }
	let type_code = field_int( row, 2 ).unwrap_or( 7 );
	let label     = field( row, 3 );

	let types = website_types( type_code );
	let params = compose_type_param( &types, type_code, label );
	push_line( out, &format!( "URL{}:{}", params, escape_text( url ) ) );
}

fn website_types( code: i32 ) -> Vec<&'static str>
{
	match code
	{
		1 => vec![],                 // HOMEPAGE — no TYPE param
		2 => vec![ "X-BLOG" ],
		3 => vec![ "X-PROFILE" ],
		4 => vec![ "HOME" ],
		5 => vec![ "WORK" ],
		6 => vec![ "X-FTP" ],
		_ => vec![],                 // OTHER (7), CUSTOM (0), unknown
	}
}

fn emit_event( out: &mut String, row: &DataRow )
{
	let date = field( row, 1 );
	if date.is_empty() { return; }
	let type_code = field_int( row, 2 ).unwrap_or( 2 );
	let label     = field( row, 3 );

	let formatted = format_event_date( date );
	match type_code
	{
		3 =>
		{
			// Birthday. Use Apple's X-APPLE-OMIT-YEAR=1604 convention if
			// the year is unknown — `format_event_date` already substituted
			// 1604 in that case.
			if date.starts_with( "--" )
			{
				push_line( out, &format!( "BDAY;X-APPLE-OMIT-YEAR=1604:{}", formatted ) );
			} else {
				push_line( out, &format!( "BDAY:{}", formatted ) );
			}
		}
		1 => push_line( out, &format!( "X-ANNIVERSARY:{}", formatted ) ),
		0 =>
		{
			let lab = if label.is_empty() { "CUSTOM" } else { label };
			push_line( out, &format!(
				"X-LIBERUX-EVENT;TYPE={}:{}",
				escape_param( lab ),
				formatted,
			) );
		}
		_ => push_line( out, &format!( "X-LIBERUX-EVENT;TYPE=OTHER:{}", formatted ) ),
	}
}

fn emit_note( out: &mut String, row: &DataRow )
{
	let body = field( row, 1 );
	if body.is_empty() { return; }
	push_line( out, &format!( "NOTE:{}", escape_text( body ) ) );
}

fn emit_photo( out: &mut String, row: &DataRow )
{
	let Some( bytes ) = row.blob.as_deref() else { return };
	if bytes.is_empty() { return; }

	let kind = match detect_image_kind( bytes )
	{
		Some( k ) => format!( ";TYPE={}", k ),
		None      => String::new(),
	};
	let encoded = general_purpose::STANDARD.encode( bytes );
	// vCard 3.0 spec uses `ENCODING=b` (single letter); the long form
	// `BASE64` is also accepted by major clients but `b` is canonical.
	push_line( out, &format!( "PHOTO;ENCODING=b{}:{}", kind, encoded ) );
}

fn detect_image_kind( bytes: &[ u8 ] ) -> Option<&'static str>
{
	if bytes.starts_with( &[ 0xFF, 0xD8, 0xFF ] )
	{
		Some( "JPEG" )
	} else if bytes.starts_with( &[ 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A ] ) {
		Some( "PNG" )
	} else {
		None
	}
}

// -- helpers ------------------------------------------------------------------

fn group_by_mimetype<'a>( rows: &'a [ DataRow ] ) -> HashMap<&'a str, Vec<&'a DataRow>>
{
	let mut map: HashMap<&'a str, Vec<&'a DataRow>> = HashMap::new();
	for row in rows
	{
		map.entry( row.mimetype.as_str() ).or_default().push( row );
	}
	for v in map.values_mut()
	{
		v.sort_by( |a, b|
		{
			b.is_super_primary.cmp( &a.is_super_primary )
				.then( b.is_primary.cmp( &a.is_primary ) )
				.then( a.id.cmp( &b.id ) )
		} );
	}
	map
}

fn flat<'a>( map: &'a HashMap<&'a str, Vec<&'a DataRow>>, mt: &str ) -> Vec<&'a DataRow>
{
	map.get( mt ).cloned().unwrap_or_default()
}

fn field<'a>( row: &'a DataRow, n: usize ) -> &'a str
{
	row.fields.get( n - 1 ).and_then( |o| o.as_deref() ).unwrap_or( "" )
}

fn field_int( row: &DataRow, n: usize ) -> Option<i32>
{
	field( row, n ).parse::<i32>().ok()
}

fn fallback_fn( raw: &RawContact ) -> String
{
	raw.display_name.as_deref()
		.filter( |s| !s.is_empty() )
		.unwrap_or( "Unknown" )
		.to_owned()
}

fn compose_type_param( types: &[ &str ], type_code: i32, label: &str ) -> String
{
	if types.is_empty() && ( type_code != 0 || label.is_empty() )
	{
		return String::new();
	}
	let mut all: Vec<String> = types.iter().map( |s| ( *s ).to_owned() ).collect();
	if type_code == 0 && !label.is_empty()
	{
		all.push( escape_param( label ) );
	}
	if all.is_empty()
	{
		String::new()
	} else {
		format!( ";TYPE={}", all.join( "," ) )
	}
}

fn escape_text( s: &str ) -> String
{
	let mut out = String::with_capacity( s.len() );
	for ch in s.chars()
	{
		match ch
		{
			'\\' => out.push_str( "\\\\" ),
			','  => out.push_str( "\\," ),
			';'  => out.push_str( "\\;" ),
			'\n' => out.push_str( "\\n" ),
			'\r' => {} // drop bare CR; the \n escape covers line breaks
			other => out.push( other ),
		}
	}
	out
}

/// Escape a parameter VALUE per RFC 2426: if it contains `,` `:` `;`,
/// surround with double quotes (and drop any embedded quotes — vCard 3.0
/// has no quote-escape).
fn escape_param( s: &str ) -> String
{
	if s.chars().any( |c| matches!( c, ',' | ':' | ';' ) )
	{
		format!( "\"{}\"", s.replace( '"', "" ) )
	} else {
		s.to_owned()
	}
}

fn push_line( out: &mut String, line: &str )
{
	out.push_str( line );
	out.push_str( "\r\n" );
}

fn format_rev( ms: i64 ) -> String
{
	let secs = ms.div_euclid( 1000 );
	match chrono::Utc.timestamp_opt( secs, 0 )
	{
		chrono::LocalResult::Single( dt ) => dt.format( "%Y%m%dT%H%M%SZ" ).to_string(),
		_                                 => "19700101T000000Z".to_owned(),
	}
}

fn format_event_date( raw: &str ) -> String
{
	// AOSP allows `--MM-DD` for unknown year. Apple Contacts conventionally
	// substitutes year 1604, paired with `X-APPLE-OMIT-YEAR=1604` on the
	// containing property.
	if let Some( md ) = raw.strip_prefix( "--" )
	{
		format!( "1604-{}", md )
	} else {
		raw.to_owned()
	}
}

/// Fold every CRLF-terminated logical line so no physical line exceeds
/// 75 octets. UTF-8 multibyte sequences are kept intact (we never split a
/// continuation byte from its lead byte). Continuation lines start with a
/// single space, per RFC 2426.
fn fold( text: &str ) -> String
{
	let mut out = String::with_capacity( text.len() + text.len() / 70 );
	for line in text.split( "\r\n" )
	{
		if line.is_empty()
		{
			continue;
		}
		let bytes = line.as_bytes();
		if bytes.len() <= 75
		{
			out.push_str( line );
			out.push_str( "\r\n" );
			continue;
		}

		let mut start = 0usize;
		let mut first = true;
		while start < bytes.len()
		{
			let max = if first { 75 } else { 74 };
			let mut end = ( start + max ).min( bytes.len() );
			while end > start && end < bytes.len() && ( bytes[ end ] & 0xC0 ) == 0x80
			{
				end -= 1;
			}
			if end == start
			{
				// Pathological case — character wider than the budget.
				// Walk forward to a valid char boundary so we make progress.
				end = ( start + max + 4 ).min( bytes.len() );
				while end < bytes.len() && ( bytes[ end ] & 0xC0 ) == 0x80
				{
					end += 1;
				}
			}
			if !first { out.push( ' ' ); }
			out.push_str( &line[ start..end ] );
			out.push_str( "\r\n" );
			start = end;
			first = false;
		}
	}
	out
}

#[ cfg( test ) ]
mod tests
{
	use super::*;
	use crate::model::data_item::TEXT_FIELDS;

	fn raw_contact() -> RawContact
	{
		RawContact
		{
			id: 1,
			contact_id: 1,
			account_name: Some( "liberux-local".into() ),
			account_type: Some( "org.liberux.local".into() ),
			display_name: Some( "Ada Lovelace".into() ),
			deleted: false,
			dirty: false,
			version: 1,
			sourceid: None,
			created_at: 0,
			updated_at: 1_700_000_000_000,
		}
	}

	fn data_row( mt: &str, fields: &[ ( usize, &str ) ] ) -> DataRow
	{
		let mut arr: [ Option<String>; TEXT_FIELDS ] = Default::default();
		for ( idx, value ) in fields
		{
			arr[ idx - 1 ] = Some( ( *value ).to_owned() );
		}
		DataRow
		{
			id: 1,
			raw_contact_id: 1,
			mimetype: mt.to_owned(),
			fields: arr,
			blob: None,
			is_primary: false,
			is_super_primary: false,
			data_version: 1,
			sync: Default::default(),
			created_at: 0,
			updated_at: 0,
		}
	}

	#[ test ]
	fn minimal_vcard_has_required_properties()
	{
		let raw = raw_contact();
		let rows: Vec<DataRow> = vec![];
		let body = serialize( VCardInput
		{
			uid: "00000000-0000-4000-8000-000000000001",
			raw_contact: &raw,
			data_rows: &rows,
		} );
		let s = std::str::from_utf8( &body ).unwrap();
		assert!( s.starts_with( "BEGIN:VCARD\r\n" ), "{}", s );
		assert!( s.contains( "VERSION:3.0\r\n" ) );
		assert!( s.contains( "PRODID:-//Liberux//contacts-server 0.1//EN\r\n" ) );
		assert!( s.contains( "UID:urn:uuid:00000000-0000-4000-8000-000000000001\r\n" ) );
		assert!( s.contains( "FN:Ada Lovelace\r\n" ) );
		assert!( s.contains( "REV:" ) );
		assert!( s.ends_with( "END:VCARD\r\n" ) );
	}

	#[ test ]
	fn name_phone_email_round_trip()
	{
		let raw = raw_contact();
		let rows = vec![
			data_row( mimetype::NAME, &[
				( 1, "Ada Lovelace" ),
				( 2, "Ada" ),
				( 3, "Lovelace" ),
			] ),
			data_row( mimetype::PHONE_V2, &[
				( 1, "+44 20 7946 0958" ),
				( 2, "2" ),
				( 4, "+442079460958" ),
			] ),
			data_row( mimetype::EMAIL_V2, &[
				( 1, "ada@lovelace.example" ),
				( 2, "1" ),
			] ),
		];
		let body = serialize( VCardInput
		{
			uid: "1",
			raw_contact: &raw,
			data_rows: &rows,
		} );
		let s = std::str::from_utf8( &body ).unwrap();
		assert!( s.contains( "N:Lovelace;Ada;;;\r\n" ), "{}", s );
		assert!( s.contains( "TEL;TYPE=CELL,VOICE:+44 20 7946 0958\r\n" ), "{}", s );
		assert!( s.contains( "X-LIBERUX-NORMALIZED-NUMBER:+442079460958\r\n" ), "{}", s );
		assert!( s.contains( "EMAIL;TYPE=HOME,INTERNET:ada@lovelace.example\r\n" ), "{}", s );
	}

	#[ test ]
	fn deterministic_for_stable_etag()
	{
		let raw = raw_contact();
		let rows = vec![
			data_row( mimetype::PHONE_V2, &[ ( 1, "111" ), ( 2, "1" ) ] ),
			data_row( mimetype::EMAIL_V2, &[ ( 1, "a@b.c" ), ( 2, "1" ) ] ),
		];
		let a = serialize( VCardInput { uid: "x", raw_contact: &raw, data_rows: &rows } );
		let b = serialize( VCardInput { uid: "x", raw_contact: &raw, data_rows: &rows } );
		assert_eq!( a, b );
	}

	#[ test ]
	fn line_folding_keeps_under_75_octets()
	{
		// Build a long NOTE.
		let raw = raw_contact();
		let long_note: String = "A".repeat( 200 );
		let row = data_row( mimetype::NOTE, &[ ( 1, long_note.as_str() ) ] );
		let body = serialize( VCardInput
		{
			uid: "y",
			raw_contact: &raw,
			data_rows: std::slice::from_ref( &row ),
		} );
		let s = std::str::from_utf8( &body ).unwrap();
		for physical in s.split( "\r\n" )
		{
			assert!( physical.len() <= 75, "physical line over 75 octets: {:?}", physical );
		}
	}
}
