/*
 * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
 *
 * SPDX-License-Identifier: Apache-2.0 OR MIT
 */

use serde_json::{Map, Value, json};

use crate::exchange_graph::error::GraphError;

#[derive(Debug, Clone)]
pub struct ConvertedContact {
    pub uid: String,
    pub data: Value,
}

pub fn convert_contact(graph_contact: &Value) -> Result<ConvertedContact, GraphError> {
    let graph_id = graph_contact
        .get("id")
        .and_then(Value::as_str)
        .ok_or_else(|| GraphError::Malformed("contact has no id".to_owned()))?;
    let uid = synthetic_uid(graph_id);

    let mut card = Map::new();
    card.insert("@type".to_owned(), Value::from("Card"));
    card.insert("version".to_owned(), Value::from("1.0"));
    card.insert("uid".to_owned(), Value::from(uid.clone()));
    card.insert("kind".to_owned(), Value::from("individual"));

    let mut name_object = Map::new();
    name_object.insert("@type".to_owned(), Value::from("Name"));
    if let Some(display) = graph_contact.get("displayName").and_then(Value::as_str)
        && !display.is_empty()
    {
        name_object.insert("full".to_owned(), Value::from(display.to_owned()));
    }
    let name_slots = [
        ("title", graph_contact.get("title").and_then(Value::as_str)),
        (
            "given",
            graph_contact.get("givenName").and_then(Value::as_str),
        ),
        (
            "given2",
            graph_contact.get("middleName").and_then(Value::as_str),
        ),
        (
            "surname",
            graph_contact.get("surname").and_then(Value::as_str),
        ),
        (
            "generation",
            graph_contact.get("generation").and_then(Value::as_str),
        ),
    ];
    let mut components: Vec<Value> = Vec::new();
    let mut slot_index: std::collections::HashMap<&'static str, usize> =
        std::collections::HashMap::new();
    for (kind, value) in name_slots {
        let Some(v) = value else { continue };
        if v.is_empty() {
            continue;
        }
        slot_index.insert(kind, components.len());
        components.push(json!({"@type": "NameComponent", "kind": kind, "value": v}));
    }
    if !components.is_empty() {
        name_object.insert("components".to_owned(), Value::Array(components));
    }
    if name_object.contains_key("full") || name_object.contains_key("components") {
        card.insert("name".to_owned(), Value::Object(name_object));
    }

    if let Some(nick) = graph_contact.get("nickName").and_then(Value::as_str)
        && !nick.is_empty()
    {
        let mut nicks = Map::new();
        nicks.insert(
            "nick-1".to_owned(),
            json!({"@type": "Nickname", "name": nick}),
        );
        card.insert("nicknames".to_owned(), Value::Object(nicks));
    }

    let mut emails = Map::new();
    if let Some(addrs) = graph_contact
        .get("emailAddresses")
        .and_then(Value::as_array)
    {
        for (i, e) in addrs.iter().enumerate() {
            let Some(addr) = e.get("address").and_then(Value::as_str) else {
                continue;
            };
            if addr.is_empty() {
                continue;
            }
            let key = format!("email-{}", i + 1);
            let mut entry = Map::new();
            entry.insert("@type".to_owned(), Value::from("EmailAddress"));
            entry.insert("address".to_owned(), Value::from(addr.to_owned()));
            if i == 0 {
                entry.insert("pref".to_owned(), Value::from(1));
            }
            emails.insert(key, Value::Object(entry));
        }
    }
    if !emails.is_empty() {
        card.insert("emails".to_owned(), Value::Object(emails));
    }

    let mut phones = Map::new();
    push_phones(
        &mut phones,
        graph_contact
            .get("businessPhones")
            .and_then(Value::as_array),
        "work",
    );
    push_phones(
        &mut phones,
        graph_contact.get("homePhones").and_then(Value::as_array),
        "private",
    );
    if let Some(mobile) = graph_contact.get("mobilePhone").and_then(Value::as_str)
        && !mobile.is_empty()
    {
        let key = format!("phone-{}", phones.len() + 1);
        phones.insert(
            key,
            json!({
                "@type": "Phone",
                "number": mobile,
                "features": {"mobile": true, "voice": true}
            }),
        );
    }
    if !phones.is_empty() {
        card.insert("phones".to_owned(), Value::Object(phones));
    }

    let mut addresses = Map::new();
    push_address(
        &mut addresses,
        graph_contact.get("homeAddress"),
        Some("private"),
    );
    push_address(
        &mut addresses,
        graph_contact.get("businessAddress"),
        Some("work"),
    );
    push_address(&mut addresses, graph_contact.get("otherAddress"), None);
    if !addresses.is_empty() {
        card.insert("addresses".to_owned(), Value::Object(addresses));
    }

    let mut organizations = Map::new();
    let has_company = graph_contact
        .get("companyName")
        .and_then(Value::as_str)
        .is_some_and(|s| !s.is_empty());
    let has_dept = graph_contact
        .get("department")
        .and_then(Value::as_str)
        .is_some_and(|s| !s.is_empty());
    let has_office = graph_contact
        .get("officeLocation")
        .and_then(Value::as_str)
        .is_some_and(|s| !s.is_empty());
    let has_title = graph_contact
        .get("jobTitle")
        .and_then(Value::as_str)
        .is_some_and(|s| !s.is_empty());
    if has_company || has_dept || has_office {
        let mut org = Map::new();
        org.insert("@type".to_owned(), Value::from("Organization"));
        if let Some(name) = graph_contact.get("companyName").and_then(Value::as_str)
            && !name.is_empty()
        {
            org.insert("name".to_owned(), Value::from(name.to_owned()));
        }
        let mut units: Vec<Value> = Vec::new();
        if let Some(dept) = graph_contact.get("department").and_then(Value::as_str)
            && !dept.is_empty()
        {
            units.push(json!({"@type": "OrgUnit", "name": dept}));
        }
        if let Some(office) = graph_contact.get("officeLocation").and_then(Value::as_str)
            && !office.is_empty()
        {
            units.push(json!({"@type": "OrgUnit", "name": office}));
        }
        if !units.is_empty() {
            org.insert("units".to_owned(), Value::Array(units));
        }
        organizations.insert("org-1".to_owned(), Value::Object(org));
    }
    if has_title
        && let Some(title) = graph_contact.get("jobTitle").and_then(Value::as_str)
        && !title.is_empty()
    {
        let mut titles = Map::new();
        let mut title_obj = Map::new();
        title_obj.insert("@type".to_owned(), Value::from("Title"));
        title_obj.insert("name".to_owned(), Value::from(title.to_owned()));
        if organizations.contains_key("org-1") {
            title_obj.insert("organizationId".to_owned(), Value::from("org-1"));
        }
        titles.insert("title-1".to_owned(), Value::Object(title_obj));
        card.insert("titles".to_owned(), Value::Object(titles));
    }
    let has_org_one = organizations.contains_key("org-1");
    if !organizations.is_empty() {
        card.insert("organizations".to_owned(), Value::Object(organizations));
    }

    if let Some(notes) = graph_contact.get("personalNotes").and_then(Value::as_str)
        && !notes.is_empty()
    {
        let mut map = Map::new();
        map.insert("note-1".to_owned(), json!({"@type": "Note", "note": notes}));
        card.insert("notes".to_owned(), Value::Object(map));
    }

    let mut localizations = Map::new();
    let mut ja = Map::new();
    if let Some(name) = graph_contact.get("yomiCompanyName").and_then(Value::as_str)
        && !name.is_empty()
        && has_org_one
    {
        ja.insert(
            "organizations/org-1/name".to_owned(),
            Value::from(name.to_owned()),
        );
    }
    if let Some(name) = graph_contact.get("yomiGivenName").and_then(Value::as_str)
        && !name.is_empty()
        && let Some(idx) = slot_index.get("given")
    {
        ja.insert(
            format!("name/components/{idx}/value"),
            Value::from(name.to_owned()),
        );
    }
    if let Some(name) = graph_contact.get("yomiSurname").and_then(Value::as_str)
        && !name.is_empty()
        && let Some(idx) = slot_index.get("surname")
    {
        ja.insert(
            format!("name/components/{idx}/value"),
            Value::from(name.to_owned()),
        );
    }
    if !ja.is_empty() {
        localizations.insert("ja".to_owned(), Value::Object(ja));
    }
    if !localizations.is_empty() {
        card.insert("localizations".to_owned(), Value::Object(localizations));
    }

    if let Some(birthday) = graph_contact.get("birthday").and_then(Value::as_str)
        && let Some(date) = parse_partial_date(birthday)
    {
        let mut anniversaries = Map::new();
        anniversaries.insert(
            "anniversary-1".to_owned(),
            json!({
                "@type": "Anniversary",
                "kind": "birth",
                "date": date,
            }),
        );
        card.insert("anniversaries".to_owned(), Value::Object(anniversaries));
    }

    if let Some(link) = graph_contact
        .get("businessHomePage")
        .and_then(Value::as_str)
        && !link.is_empty()
    {
        let mut links = Map::new();
        links.insert(
            "link-1".to_owned(),
            json!({"@type": "Link", "uri": link, "contexts": {"work": true}}),
        );
        card.insert("links".to_owned(), Value::Object(links));
    }

    if let Some(created) = graph_contact.get("createdDateTime").and_then(Value::as_str) {
        card.insert(
            "created".to_owned(),
            Value::from(normalise_utc_datetime(created)),
        );
    }
    if let Some(updated) = graph_contact
        .get("lastModifiedDateTime")
        .and_then(Value::as_str)
    {
        card.insert(
            "updated".to_owned(),
            Value::from(normalise_utc_datetime(updated)),
        );
    }

    Ok(ConvertedContact {
        uid,
        data: Value::Object(card),
    })
}

pub fn synthetic_uid(graph_id: &str) -> String {
    let hash = blake3::hash(graph_id.as_bytes());
    format!("vandelay-graph-{}", hash.to_hex())
}

fn normalise_utc_datetime(raw: &str) -> String {
    let trailing_z = raw.ends_with('Z');
    let trimmed = raw.trim_end_matches('Z');
    let base = match trimmed.find('.') {
        Some(dot) => &trimmed[..dot],
        None => trimmed,
    };
    if trailing_z {
        format!("{base}Z")
    } else {
        base.to_owned()
    }
}

fn parse_partial_date(raw: &str) -> Option<Value> {
    let date_part = raw.split('T').next().unwrap_or(raw);
    let mut iter = date_part.split('-');
    let year: u64 = iter.next()?.parse().ok()?;
    let mut obj = Map::new();
    obj.insert("@type".to_owned(), Value::from("PartialDate"));
    obj.insert("year".to_owned(), Value::from(year));
    if let Some(month_str) = iter.next()
        && let Ok(month) = month_str.parse::<u64>()
        && (1..=12).contains(&month)
    {
        obj.insert("month".to_owned(), Value::from(month));
        if let Some(day_str) = iter.next()
            && let Ok(day) = day_str.parse::<u64>()
            && (1..=31).contains(&day)
        {
            obj.insert("day".to_owned(), Value::from(day));
        }
    }
    Some(Value::Object(obj))
}

fn push_phones(target: &mut Map<String, Value>, list: Option<&Vec<Value>>, context: &str) {
    let Some(list) = list else { return };
    for n in list.iter().filter_map(Value::as_str) {
        if n.is_empty() {
            continue;
        }
        let key = format!("phone-{}", target.len() + 1);
        target.insert(
            key,
            json!({
                "@type": "Phone",
                "number": n,
                "contexts": {context: true}
            }),
        );
    }
}

fn push_address(target: &mut Map<String, Value>, source: Option<&Value>, context: Option<&str>) {
    let Some(source) = source else { return };
    let Some(map) = source.as_object() else {
        return;
    };
    let street = map
        .get("street")
        .and_then(Value::as_str)
        .filter(|s| !s.is_empty());
    let component_fields: Vec<(&str, &str)> = [
        ("city", "locality"),
        ("state", "region"),
        ("postalCode", "postcode"),
        ("countryOrRegion", "country"),
    ]
    .into_iter()
    .filter_map(|(graph_key, jsc_kind)| {
        map.get(graph_key)
            .and_then(Value::as_str)
            .filter(|s| !s.is_empty())
            .map(|s| (jsc_kind, s))
    })
    .collect();
    if street.is_none() && component_fields.is_empty() {
        return;
    }
    let mut address = Map::new();
    address.insert("@type".to_owned(), Value::from("Address"));
    if let Some(context) = context {
        address.insert("contexts".to_owned(), json!({context: true}));
    }
    if let Some(s) = street {
        address.insert("full".to_owned(), Value::from(s.to_owned()));
    }
    if !component_fields.is_empty() {
        let components: Vec<Value> = component_fields
            .iter()
            .map(|(kind, value)| json!({"@type": "AddressComponent", "kind": kind, "value": value}))
            .collect();
        address.insert("components".to_owned(), Value::Array(components));
    }
    let key = format!("addr-{}", target.len() + 1);
    target.insert(key, Value::Object(address));
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn minimal_contact_yields_card_with_synthetic_uid() {
        let c = json!({"id": "GRAPH-1", "displayName": "Alice"});
        let conv = convert_contact(&c).unwrap();
        assert!(conv.uid.starts_with("vandelay-graph-"));
        assert_eq!(conv.data["@type"], "Card");
        assert_eq!(conv.data["kind"], "individual");
        assert_eq!(conv.data["name"]["full"], "Alice");
    }

    #[test]
    fn synthetic_uid_is_stable_per_graph_id() {
        let a = synthetic_uid("AAA");
        let b = synthetic_uid("AAA");
        let c = synthetic_uid("BBB");
        assert_eq!(a, b);
        assert_ne!(a, c);
    }

    #[test]
    fn emails_map_with_first_as_pref() {
        let c = json!({
            "id": "X",
            "emailAddresses": [
                {"address": "a@x.com", "name": "Alice"},
                {"address": "b@x.com", "name": "Alice B"}
            ]
        });
        let conv = convert_contact(&c).unwrap();
        let emails = conv.data["emails"].as_object().unwrap();
        assert_eq!(emails["email-1"]["address"], "a@x.com");
        assert_eq!(emails["email-1"]["pref"], 1);
        assert_eq!(emails["email-2"]["address"], "b@x.com");
        assert!(emails["email-2"].get("pref").is_none());
    }

    #[test]
    fn phones_carry_context_per_source_field() {
        let c = json!({
            "id": "X",
            "businessPhones": ["+1-555-2000"],
            "homePhones": ["+1-555-1000"],
            "mobilePhone": "+1-555-3000"
        });
        let conv = convert_contact(&c).unwrap();
        let phones = conv.data["phones"].as_object().unwrap();
        assert_eq!(phones.len(), 3);
        let has_work = phones
            .values()
            .any(|v| v["contexts"]["work"] == Value::Bool(true));
        let has_private = phones
            .values()
            .any(|v| v["contexts"]["private"] == Value::Bool(true));
        let has_mobile = phones
            .values()
            .any(|v| v["features"]["mobile"] == Value::Bool(true));
        assert!(has_work && has_private && has_mobile);
    }

    #[test]
    fn addresses_map_three_contexts() {
        let c = json!({
            "id": "X",
            "homeAddress": {"city": "Home City"},
            "businessAddress": {"city": "Work City"},
            "otherAddress": {"city": "Other City"}
        });
        let conv = convert_contact(&c).unwrap();
        let addresses = conv.data["addresses"].as_object().unwrap();
        assert_eq!(addresses.len(), 3);
        let has_other_context = addresses
            .values()
            .any(|a| a.get("contexts").and_then(|c| c.get("other")).is_some());
        assert!(!has_other_context);
        let without_context = addresses
            .values()
            .filter(|a| a.get("contexts").is_none())
            .count();
        assert_eq!(without_context, 1);
    }

    #[test]
    fn org_and_title_pair_into_separate_jscontact_objects() {
        let c = json!({
            "id": "X",
            "companyName": "Acme",
            "department": "Engineering",
            "officeLocation": "Building 9",
            "jobTitle": "Architect"
        });
        let conv = convert_contact(&c).unwrap();
        let orgs = conv.data["organizations"].as_object().unwrap();
        assert!(orgs.contains_key("org-1"));
        assert_eq!(orgs["org-1"]["name"], "Acme");
        let titles = conv.data["titles"].as_object().unwrap();
        assert_eq!(titles["title-1"]["name"], "Architect");
        assert_eq!(titles["title-1"]["organizationId"], "org-1");
    }

    #[test]
    fn yomi_fields_populate_japanese_localization() {
        let c = json!({
            "id": "X",
            "displayName": "Yamada Taro",
            "givenName": "Taro",
            "surname": "Yamada",
            "companyName": "Acme",
            "yomiCompanyName": "アクメ",
            "yomiGivenName": "タロウ",
            "yomiSurname": "ヤマダ"
        });
        let conv = convert_contact(&c).unwrap();
        let ja = conv.data["localizations"]["ja"].as_object().unwrap();
        assert_eq!(ja["organizations/org-1/name"], "アクメ");
    }

    #[test]
    fn birthday_emits_partialdate_object() {
        let c = json!({"id": "X", "birthday": "1980-04-12T00:00:00Z"});
        let conv = convert_contact(&c).unwrap();
        let ann = conv.data["anniversaries"].as_object().unwrap();
        let date = &ann["anniversary-1"]["date"];
        assert_eq!(date["@type"], "PartialDate");
        assert_eq!(date["year"], 1980);
        assert_eq!(date["month"], 4);
        assert_eq!(date["day"], 12);
        assert_eq!(ann["anniversary-1"]["kind"], "birth");
    }

    #[test]
    fn mobile_phone_uses_features_not_contexts() {
        let c = json!({"id": "X", "mobilePhone": "+1-555-3000"});
        let conv = convert_contact(&c).unwrap();
        let phones = conv.data["phones"].as_object().unwrap();
        let phone = phones.values().next().unwrap();
        assert_eq!(phone["features"]["mobile"], true);
        assert!(phone.get("contexts").is_none());
    }

    #[test]
    fn generation_maps_to_generation_kind() {
        let c = json!({"id": "X", "generation": "Jr."});
        let conv = convert_contact(&c).unwrap();
        let comps = conv.data["name"]["components"].as_array().unwrap();
        assert!(
            comps
                .iter()
                .any(|c| c["kind"] == "generation" && c["value"] == "Jr.")
        );
    }

    #[test]
    fn only_jobtitle_does_not_emit_empty_organization() {
        let c = json!({"id": "X", "jobTitle": "Architect"});
        let conv = convert_contact(&c).unwrap();
        assert!(conv.data.get("organizations").is_none());
        let titles = conv.data["titles"].as_object().unwrap();
        assert_eq!(titles["title-1"]["name"], "Architect");
        assert!(titles["title-1"].get("organizationId").is_none());
    }

    #[test]
    fn related_contacts_are_dropped_without_resolvable_uids() {
        let c = json!({
            "id": "X",
            "manager": "alice@x.com",
            "spouseName": "Bob",
            "children": ["Kid A", "Kid B"]
        });
        let conv = convert_contact(&c).unwrap();
        assert!(conv.data.get("relatedTo").is_none());
    }

    #[test]
    fn nameless_contact_omits_name_object() {
        let c = json!({"id": "X", "businessPhones": ["+1"]});
        let conv = convert_contact(&c).unwrap();
        assert!(conv.data.get("name").is_none());
    }

    #[test]
    fn timestamps_strip_fractional_seconds() {
        let c = json!({
            "id": "X",
            "displayName": "Alice",
            "createdDateTime": "2026-05-01T08:00:00.0000000Z",
            "lastModifiedDateTime": "2026-05-02T08:00:00.123Z"
        });
        let conv = convert_contact(&c).unwrap();
        assert_eq!(conv.data["created"], "2026-05-01T08:00:00Z");
        assert_eq!(conv.data["updated"], "2026-05-02T08:00:00Z");
    }
}
