use openapi_contract::api;
use serde::{Deserialize, Serialize};
use sha2::Digest;

use super::client::CloudClient;
use crate::contract::{BillingCurrent, Success, SyncProviders, SyncSettings, Team, UserProfile};
use crate::domain::models::SkillRecord;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SyncResult {
    pub created: Vec<SyncedRule>,
    pub updated: Vec<SyncedRule>,
    pub deleted: Vec<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TeamSyncResult {
    pub visible_count: i32,
    pub synced: SyncResult,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SyncedRule {
    pub id: String,
    pub name: String,
    pub r#type: String,
    pub description: String,
    pub version: String,
    pub engines: Vec<String>,
    pub tags: Vec<String>,
    pub trigger: Option<String>,
    pub check_prompt: Option<String>,
    pub content: String,
    pub updated_at: String,
    pub created_at: String,
    /// Glob list (e.g. `["**/*.rs"]`) the CLI's cascade uses to drop rules
    /// whose patterns don't match the current file. Empty / missing = universal rule.
    #[serde(default)]
    pub file_patterns: Vec<String>,
    /// Input-channel provenance (manual | conversation | `pr_review` |
    /// extracted). Defaults to None when the cloud doesn't emit it, so
    /// `apply_sync_result` falls back to `cloud`.
    #[serde(default)]
    pub origin: Option<String>,
    /// GitHub-style `owner/repo` provenance for the extractions that drafted
    /// this rule. Mirrors cloud's `rules_cloud.source_repo`.
    #[serde(default, rename = "sourceRepo")]
    pub source_repo: Option<String>,
}

impl SyncResult {
    pub const fn created_count(&self) -> usize {
        self.created.len()
    }
    pub const fn updated_count(&self) -> usize {
        self.updated.len()
    }
    pub const fn deleted_count(&self) -> usize {
        self.deleted.len()
    }
}

pub async fn sync_skills(
    client: &CloudClient,
    skills: &[SkillRecord],
) -> Result<Option<SyncResult>, crate::CoreError> {
    sync_skills_filtered(client, skills, &[]).await
}

/// Like `sync_skills` but filters out local-only ids before hashing so
/// pending candidates are not recreated as active cloud rules.
pub async fn sync_skills_filtered(
    client: &CloudClient,
    skills: &[SkillRecord],
    exclude_ids: &[String],
) -> Result<Option<SyncResult>, crate::CoreError> {
    let exclude: std::collections::HashSet<&str> = exclude_ids.iter().map(String::as_str).collect();
    let ids_for_scope: Vec<String> = skills
        .iter()
        .filter(|s| !exclude.contains(s.id.as_str()))
        .map(|s| s.id.clone())
        .collect();
    let scope_by_id = load_sync_hash_scope(&ids_for_scope).await;
    let local_hashes: std::collections::HashMap<String, String> = skills
        .iter()
        .filter(|s| !exclude.contains(s.id.as_str()))
        .map(|skill| {
            (
                skill.id.clone(),
                skill_content_hash(skill, scope_by_id.get(&skill.id)),
            )
        })
        .collect();
    let payload = serde_json::json!({ "localHashes": local_hashes });

    let result: serde_json::Value = client
        .fetch_api_json(api!(POST "/rules/sync", body = &payload), "rules_sync")
        .await
        .map_err(|e| {
            crate::CoreError::Internal(format!(
                "rules sync failed: {e}; run `difflore doctor --report` for cloud diagnostics"
            ))
        })?;

    let created = result
        .get("created")
        .and_then(|v| v.as_array())
        .cloned()
        .unwrap_or_default();
    let updated = result
        .get("updated")
        .and_then(|v| v.as_array())
        .cloned()
        .unwrap_or_default();
    let deleted: Vec<String> = result
        .get("deleted")
        .and_then(|v| v.as_array())
        .map(|arr| {
            arr.iter()
                .filter_map(|v| v.as_str().map(String::from))
                .collect()
        })
        .unwrap_or_default();

    // Fail the whole sync (before any local DB mutation) if the cloud sent a
    // malformed rule — never partially apply a junk / empty-id rule.
    let created = created
        .iter()
        .map(map_synced_rule_value)
        .collect::<Result<Vec<_>, _>>()
        .map_err(|e| {
            crate::CoreError::Internal(format!("rules sync: malformed `created` rule: {e}"))
        })?;
    let updated = updated
        .iter()
        .map(map_synced_rule_value)
        .collect::<Result<Vec<_>, _>>()
        .map_err(|e| {
            crate::CoreError::Internal(format!("rules sync: malformed `updated` rule: {e}"))
        })?;
    if deleted.iter().any(|id| id.trim().is_empty()) {
        return Err(crate::CoreError::Internal(
            "rules sync: `deleted` contained an empty rule id".to_owned(),
        ));
    }

    Ok(Some(SyncResult {
        created,
        updated,
        deleted,
    }))
}

fn map_synced_rule_value(val: &serde_json::Value) -> crate::Result<SyncedRule> {
    // `id` and `content` are REQUIRED — a missing/empty value would create or
    // overwrite a local rule keyed on an empty id (corrupting the store). Cloud
    // schema drift must fail the whole sync, never silently apply a junk rule.
    let required = |key: &str| -> crate::Result<String> {
        match val.get(key).and_then(|v| v.as_str()) {
            Some(s) if !s.trim().is_empty() => Ok(s.to_owned()),
            _ => Err(crate::CoreError::Internal(format!(
                "missing or empty required field `{key}`"
            ))),
        }
    };
    Ok(SyncedRule {
        id: required("id")?,
        name: val
            .get("name")
            .and_then(|v| v.as_str())
            .unwrap_or_default()
            .to_owned(),
        r#type: val
            .get("type")
            .and_then(|v| v.as_str())
            .unwrap_or("review_standard")
            .to_owned(),
        description: val
            .get("description")
            .and_then(|v| v.as_str())
            .unwrap_or_default()
            .to_owned(),
        version: val
            .get("version")
            .and_then(|v| v.as_str())
            .unwrap_or("1.0.0")
            .to_owned(),
        engines: val
            .get("engines")
            .and_then(|v| v.as_array())
            .map(|arr| {
                arr.iter()
                    .filter_map(|v| v.as_str().map(String::from))
                    .collect()
            })
            .unwrap_or_default(),
        tags: val
            .get("tags")
            .and_then(|v| v.as_array())
            .map(|arr| {
                arr.iter()
                    .filter_map(|v| v.as_str().map(String::from))
                    .collect()
            })
            .unwrap_or_default(),
        trigger: val
            .get("trigger")
            .and_then(|v| v.as_str())
            .map(String::from),
        check_prompt: val
            .get("checkPrompt")
            .and_then(|v| v.as_str())
            .map(String::from),
        content: required("content")?,
        updated_at: val
            .get("updatedAt")
            .and_then(|v| v.as_str())
            .unwrap_or_default()
            .to_owned(),
        created_at: val
            .get("createdAt")
            .and_then(|v| v.as_str())
            .unwrap_or_default()
            .to_owned(),
        file_patterns: val
            .get("filePatterns")
            .and_then(|v| v.as_array())
            .map(|arr| {
                arr.iter()
                    .filter_map(|v| v.as_str().map(String::from))
                    .collect()
            })
            .unwrap_or_default(),
        origin: val.get("origin").and_then(|v| v.as_str()).map(String::from),
        source_repo: val
            .get("sourceRepo")
            .and_then(|v| v.as_str())
            .map(String::from),
    })
}

fn map_team_synced_rule_value(val: &serde_json::Value) -> crate::Result<SyncedRule> {
    let mut normalized = val.clone();
    let content_is_missing = normalized
        .get("content")
        .and_then(|v| v.as_str())
        .is_none_or(|s| s.trim().is_empty());
    if content_is_missing {
        let description = normalized
            .get("description")
            .and_then(|v| v.as_str())
            .unwrap_or_default()
            .to_owned();
        normalized["content"] = serde_json::Value::String(description);
    }

    let mut rule = map_synced_rule_value(&normalized)?;
    if rule.origin.is_none() {
        rule.origin = Some("team".to_owned());
    }
    Ok(rule)
}

pub async fn sync_team_skills(client: &CloudClient) -> Result<TeamSyncResult, crate::CoreError> {
    let skills_json: Vec<serde_json::Value> = api!(GET "/rules/team").fetch(client).await?;
    let visible_count = i32::try_from(skills_json.len()).unwrap_or(i32::MAX);
    let created = skills_json
        .iter()
        .map(map_team_synced_rule_value)
        .collect::<Result<Vec<_>, _>>()
        .map_err(|e| crate::CoreError::Internal(format!("team rules sync: malformed rule: {e}")))?;
    Ok(TeamSyncResult {
        visible_count,
        synced: SyncResult {
            created,
            updated: vec![],
            deleted: vec![],
        },
    })
}

pub async fn sync_settings(
    client: &CloudClient,
    settings: &serde_json::Value,
) -> Result<(), crate::CoreError> {
    let payload = serde_json::json!({ "settings": settings });
    let _: Success = api!(PUT "/sync/settings", body = &payload)
        .fetch(client)
        .await?;
    Ok(())
}

/// Mask an API key for cross-device sync. The cloud never stores the secret —
/// only an opaque hint (e.g. last 4 chars) so users can recognize which key
/// they previously used on another device.
pub fn mask_api_key(key: &str) -> String {
    let trimmed = key.trim();
    if trimmed.len() <= 4 {
        "•".repeat(trimmed.len())
    } else {
        let visible = &trimmed[trimmed.len().saturating_sub(4)..];
        format!("••••{visible}")
    }
}

/// Build a structured provider sync payload from local provider records.
/// Used by `sync_providers`; exposed for callers that want to inspect what
/// will be sent before pushing.
pub fn build_provider_sync_entries(
    providers: &[crate::domain::models::ProviderRecord],
) -> Vec<serde_json::Value> {
    providers
        .iter()
        .map(|p| {
            let mut obj = serde_json::Map::new();
            obj.insert("name".into(), serde_json::Value::String(p.name.clone()));
            obj.insert(
                "baseUrl".into(),
                serde_json::Value::String(p.base_url.clone()),
            );
            if let Some(key) = p.api_key.as_deref() {
                obj.insert(
                    "maskedKey".into(),
                    serde_json::Value::String(mask_api_key(key)),
                );
            }
            if !p.model_mapping.is_empty() {
                obj.insert(
                    "modelMapping".into(),
                    serde_json::to_value(&p.model_mapping).unwrap_or(serde_json::Value::Null),
                );
            }
            obj.insert(
                "updatedAt".into(),
                serde_json::Value::String(p.updated_at.clone()),
            );
            serde_json::Value::Object(obj)
        })
        .collect()
}

pub async fn sync_providers(
    client: &CloudClient,
    providers: &[serde_json::Value],
) -> Result<(), crate::CoreError> {
    let payload = serde_json::json!({ "providers": providers });
    let _: Success = api!(PUT "/sync/providers", body = &payload)
        .fetch(client)
        .await?;
    Ok(())
}

/// Fetch cloud-side settings blob. Returns (`settings_value`, `updated_at`) or
/// None if the cloud has no settings stored yet for this user.
pub async fn pull_settings(
    client: &CloudClient,
) -> Result<Option<(serde_json::Value, Option<String>)>, crate::CoreError> {
    let result: SyncSettings = api!(GET "/sync/settings").fetch(client).await?;
    let val = serde_json::to_value(&result).unwrap_or_default();
    let settings = val
        .get("settings")
        .cloned()
        .unwrap_or(serde_json::json!({}));
    let updated_at = val
        .get("updatedAt")
        .and_then(|v| v.as_str())
        .map(String::from);
    if settings.is_null() || settings.as_object().is_none_or(serde_json::Map::is_empty) {
        Ok(None)
    } else {
        Ok(Some((settings, updated_at)))
    }
}

/// Fetch cloud-side providers as a structured array. Returns the parsed JSON
/// array and optional `updated_at`, or None if not set. Cloud holds masked keys
/// only — callers must NOT trust `maskedKey` as a usable secret.
pub async fn pull_providers(
    client: &CloudClient,
) -> Result<Option<(serde_json::Value, Option<String>)>, crate::CoreError> {
    let result: SyncProviders = api!(GET "/sync/providers").fetch(client).await?;
    let val = serde_json::to_value(&result).unwrap_or_default();
    let providers = val.get("providers").cloned();
    let updated_at = val
        .get("updatedAt")
        .and_then(|v| v.as_str())
        .map(String::from);
    Ok(normalize_provider_payload(providers).map(|providers| (providers, updated_at)))
}

fn normalize_provider_payload(providers: Option<serde_json::Value>) -> Option<serde_json::Value> {
    match providers {
        Some(arr @ serde_json::Value::Array(_)) => Some(arr),
        None | Some(_) => None,
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CloudStatus {
    pub logged_in: bool,
    pub email: Option<String>,
    pub plan: Option<String>,
    pub team_id: Option<String>,
    pub team_name: Option<String>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CloudTier {
    Free,
    Team,
    TeamPlus,
}

impl CloudTier {
    pub const fn is_team(self) -> bool {
        matches!(self, Self::Team | Self::TeamPlus)
    }

    pub const fn default_label(self) -> &'static str {
        match self {
            Self::Free => "Cloud Free",
            Self::Team => "Cloud Team",
            Self::TeamPlus => "Cloud Team Plus",
        }
    }
}

pub fn cloud_tier_from_status(status: &CloudStatus) -> CloudTier {
    if !status.logged_in {
        return CloudTier::Free;
    }

    let plan = status
        .plan
        .as_deref()
        .unwrap_or_default()
        .to_ascii_lowercase()
        .replace('-', "_");
    match plan.as_str() {
        "team_plus" | "enterprise" => CloudTier::TeamPlus,
        "team" | "pro" | "business" => CloudTier::Team,
        "free" | "self_host" | "oss" => CloudTier::Free,
        _ if has_team_identity(status) => CloudTier::Team,
        _ => CloudTier::Free,
    }
}

pub fn cloud_plan_label_from_status(status: &CloudStatus) -> String {
    if let Some(team) = status.team_name.as_deref().map(str::trim)
        && !team.is_empty()
    {
        return team.to_owned();
    }

    cloud_tier_from_status(status).default_label().to_owned()
}

fn has_team_identity(status: &CloudStatus) -> bool {
    status
        .team_id
        .as_deref()
        .is_some_and(|team| !team.trim().is_empty())
        || status
            .team_name
            .as_deref()
            .is_some_and(|team| !team.trim().is_empty())
}

/// Fetch the user's cloud status (profile + billing plan + active team).
/// Safe to call when not logged in — returns a `logged_in: false` response.
pub async fn fetch_cloud_status(client: &CloudClient) -> CloudStatus {
    if !client.is_logged_in() {
        return CloudStatus {
            logged_in: false,
            email: None,
            plan: None,
            team_id: None,
            team_name: None,
        };
    }

    let mut status_client = client.clone();
    let mut profile_result: Result<UserProfile, _> = api!(GET "/auth/profile").fetch(client).await;
    if profile_result.is_err() && CloudClient::refresh_saved_token().await.is_some() {
        status_client = CloudClient::create().await;
        profile_result = api!(GET "/auth/profile").fetch(&status_client).await;
    }
    let Ok(profile) = profile_result else {
        return CloudStatus {
            logged_in: false,
            email: None,
            plan: None,
            team_id: None,
            team_name: None,
        };
    };

    let email = serde_json::to_value(&profile)
        .ok()
        .and_then(|v| v.get("email").and_then(|e| e.as_str()).map(String::from));

    let billing_result: Result<BillingCurrent, _> =
        api!(GET "/billing/current").fetch(&status_client).await;
    let plan = billing_result
        .ok()
        .and_then(|b| serde_json::to_value(&b).ok())
        .and_then(|v| v.get("planId").and_then(|p| p.as_str()).map(String::from));

    let team_result: Result<Option<Team>, _> = api!(GET "/teams/my").fetch(&status_client).await;
    let team_value = team_result
        .ok()
        .flatten()
        .and_then(|t| serde_json::to_value(&t).ok());
    let team_id = team_value.as_ref().and_then(|v| {
        v.get("id")
            .or_else(|| v.get("teamId"))
            .and_then(|id| id.as_str())
            .map(String::from)
    });
    let team_name = team_value
        .as_ref()
        .and_then(|v| v.get("name").and_then(|n| n.as_str()).map(String::from));

    CloudStatus {
        logged_in: true,
        email,
        plan,
        team_id,
        team_name,
    }
}

#[derive(Debug, Clone, Default)]
struct SyncHashScope {
    file_patterns: Vec<String>,
    source_repo: Option<String>,
}

async fn load_sync_hash_scope(ids: &[String]) -> std::collections::HashMap<String, SyncHashScope> {
    let mut out = std::collections::HashMap::new();
    if ids.is_empty() {
        return out;
    }
    let Ok(db) = crate::infra::db::init_db().await else {
        return out;
    };
    let Ok(ids_json) = serde_json::to_string(ids) else {
        return out;
    };
    let Ok(rows) = sqlx::query_as::<_, (String, Option<String>, Option<String>)>(
        "SELECT id, file_patterns, source_repo
           FROM skills WHERE id IN (SELECT value FROM json_each(?1))",
    )
    .bind(ids_json)
    .fetch_all(&db)
    .await
    else {
        return out;
    };
    for (id, file_patterns_raw, source_repo) in rows {
        out.insert(
            id,
            SyncHashScope {
                file_patterns: parse_file_patterns_for_hash(file_patterns_raw.as_deref()),
                source_repo,
            },
        );
    }
    out
}

fn skill_content_hash(skill: &SkillRecord, scope: Option<&SyncHashScope>) -> String {
    let content = skill_content_for_hash(skill);

    let payload = rule_sync_hash_payload_json(skill, scope, &content);
    let digest = sha2::Sha256::digest(payload.as_bytes());
    use std::fmt::Write as _;
    digest
        .iter()
        .fold(String::with_capacity(digest.len() * 2), |mut acc, b| {
            let _ = write!(acc, "{b:02x}");
            acc
        })
}

#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct RuleSyncHashPayload<'a> {
    content: &'a str,
    file_patterns: Vec<String>,
    source_repo: Option<String>,
    tags: Vec<String>,
    version: &'a str,
}

fn rule_sync_hash_payload_json(
    skill: &SkillRecord,
    scope: Option<&SyncHashScope>,
    content: &str,
) -> String {
    let version = clean_string(Some(&skill.version)).unwrap_or_default();
    serde_json::to_string(&RuleSyncHashPayload {
        content,
        file_patterns: clean_string_array(
            scope
                .map(|scope| scope.file_patterns.as_slice())
                .unwrap_or_default()
                .iter(),
        ),
        source_repo: clean_string(scope.and_then(|scope| scope.source_repo.as_deref())),
        tags: clean_string_array(skill.tags.iter()),
        version: &version,
    })
    .unwrap_or_else(|_| {
        format!(
            r#"{{"content":{},"filePatterns":[],"sourceRepo":null,"tags":[],"version":""}}"#,
            serde_json::to_string(content).unwrap_or_else(|_| "\"\"".to_owned())
        )
    })
}

fn parse_file_patterns_for_hash(raw: Option<&str>) -> Vec<String> {
    raw.and_then(|value| serde_json::from_str::<Vec<String>>(value).ok())
        .unwrap_or_default()
}

fn clean_string(value: Option<&str>) -> Option<String> {
    value
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned)
}

fn clean_string_array<'a>(values: impl Iterator<Item = &'a String>) -> Vec<String> {
    let mut out = Vec::new();
    for value in values {
        let Some(cleaned) = clean_string(Some(value)) else {
            continue;
        };
        if out.contains(&cleaned) {
            continue;
        }
        out.push(cleaned);
    }
    out
}

fn skill_content_for_hash(skill: &SkillRecord) -> String {
    skill
        .check_prompt
        .clone()
        .or_else(|| {
            if skill.description.trim().is_empty() {
                None
            } else {
                Some(skill.description.clone())
            }
        })
        .unwrap_or_default()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::domain::models::ProviderRecord;
    use std::collections::HashMap;

    #[test]
    fn mask_short_keys_returns_only_bullets() {
        assert_eq!(mask_api_key(""), "");
        assert_eq!(mask_api_key("ab"), "••");
        assert_eq!(mask_api_key("abcd"), "••••");
    }

    #[test]
    fn mask_long_keys_keeps_last_four() {
        assert_eq!(mask_api_key("sk-abcdef1234"), "••••1234");
        assert_eq!(mask_api_key("  spaced-key-9876  "), "••••9876");
    }

    fn make_provider(name: &str, key: Option<&str>) -> ProviderRecord {
        let mut mapping = HashMap::new();
        mapping.insert("review".into(), "claude-3".into());
        ProviderRecord {
            id: format!("{name}-id"),
            name: name.into(),
            base_url: format!("https://{name}.example.com"),
            api_key: key.map(String::from),
            model_mapping: mapping,
            is_active: true,
            created_at: "2026-04-10T00:00:00Z".into(),
            updated_at: "2026-04-10T00:00:00Z".into(),
        }
    }

    #[test]
    fn build_entries_masks_keys_and_omits_when_absent() {
        let providers = vec![
            make_provider("anthropic", Some("sk-ant-1234567890abcd")),
            make_provider("local", None),
        ];
        let entries = build_provider_sync_entries(&providers);
        assert_eq!(entries.len(), 2);

        let first = entries[0].as_object().unwrap();
        assert_eq!(first.get("name").unwrap().as_str(), Some("anthropic"));
        assert_eq!(
            first.get("baseUrl").unwrap().as_str(),
            Some("https://anthropic.example.com"),
        );
        assert_eq!(first.get("maskedKey").unwrap().as_str(), Some("••••abcd"));
        assert!(first.get("modelMapping").is_some());
        assert_eq!(
            first.get("updatedAt").unwrap().as_str(),
            Some("2026-04-10T00:00:00Z"),
        );

        let second = entries[1].as_object().unwrap();
        assert!(
            second.get("maskedKey").is_none(),
            "absent key should not emit maskedKey"
        );
    }

    #[test]
    fn build_entries_skips_empty_model_mapping() {
        let provider = ProviderRecord {
            id: "x".into(),
            name: "x".into(),
            base_url: "https://x".into(),
            api_key: None,
            model_mapping: HashMap::new(),
            is_active: false,
            created_at: "t".into(),
            updated_at: "t".into(),
        };
        let entries = build_provider_sync_entries(&[provider]);
        assert!(entries[0].get("modelMapping").is_none());
    }

    #[test]
    fn provider_pull_payload_is_canonical_array_only() {
        let array = serde_json::json!([{ "name": "codex" }]);
        assert_eq!(
            normalize_provider_payload(Some(array.clone())).as_ref(),
            Some(&array)
        );

        let stringified = serde_json::json!(r#"[{"name":"old"}]"#);
        assert!(
            normalize_provider_payload(Some(stringified)).is_none(),
            "stringified provider JSON must fail closed"
        );
        assert!(normalize_provider_payload(Some(serde_json::json!({}))).is_none());
    }

    fn cloud_status(
        logged_in: bool,
        plan: Option<&str>,
        team_id: Option<&str>,
        team_name: Option<&str>,
    ) -> CloudStatus {
        CloudStatus {
            logged_in,
            email: None,
            plan: plan.map(String::from),
            team_id: team_id.map(String::from),
            team_name: team_name.map(String::from),
        }
    }

    #[test]
    fn cloud_tier_mapping_is_shared_for_cli_surfaces() {
        assert_eq!(
            cloud_tier_from_status(&cloud_status(false, Some("team"), None, None)),
            CloudTier::Free
        );
        assert_eq!(
            cloud_tier_from_status(&cloud_status(true, Some("free"), None, None)),
            CloudTier::Free
        );
        assert_eq!(
            cloud_tier_from_status(&cloud_status(true, Some("pro"), None, None)),
            CloudTier::Team
        );
        assert_eq!(
            cloud_tier_from_status(&cloud_status(true, Some("team-plus"), None, None)),
            CloudTier::TeamPlus
        );
        assert_eq!(
            cloud_tier_from_status(&cloud_status(true, None, Some("team_123"), None)),
            CloudTier::Team
        );
    }

    #[test]
    fn cloud_plan_label_prefers_team_name() {
        let status = cloud_status(true, Some("team"), Some("team_123"), Some("Acme"));
        assert_eq!(cloud_plan_label_from_status(&status), "Acme");

        let fallback = cloud_status(true, Some("enterprise"), None, None);
        assert_eq!(cloud_plan_label_from_status(&fallback), "Cloud Team Plus");
    }

    #[test]
    fn sync_rule_mapping_uses_canonical_source_repo_field_only() {
        let val = serde_json::json!({
            "id": "rule-1",
            "name": "Rule",
            "content": "Body",
            "source_repo": "acme/retired",
            "sourceRepo": "acme/canonical"
        });
        let mapped = map_synced_rule_value(&val).expect("valid rule");
        assert_eq!(mapped.source_repo.as_deref(), Some("acme/canonical"));

        let retired_only = serde_json::json!({
            "id": "rule-2",
            "name": "Rule",
            "content": "Body",
            "source_repo": "acme/retired"
        });
        let mapped = map_synced_rule_value(&retired_only).expect("valid rule");
        assert_eq!(mapped.source_repo, None);
    }

    #[test]
    fn sync_rule_mapping_rejects_missing_required_id_or_content() {
        // Missing id → rejected (would otherwise create an empty-id local rule).
        let no_id = serde_json::json!({ "name": "R", "content": "Body" });
        assert!(map_synced_rule_value(&no_id).is_err());
        // Empty/whitespace content → rejected.
        let empty_content = serde_json::json!({ "id": "r", "name": "R", "content": "  " });
        assert!(map_synced_rule_value(&empty_content).is_err());
        // Both present → accepted.
        let ok = serde_json::json!({ "id": "r", "name": "R", "content": "Body" });
        assert!(map_synced_rule_value(&ok).is_ok());
    }

    #[test]
    fn team_sync_rule_mapping_prefers_content_over_description() {
        let val = serde_json::json!({
            "id": "team-rule-1",
            "name": "Rule",
            "description": "Summary",
            "content": "Full body",
            "origin": "conversation"
        });
        let mapped = map_team_synced_rule_value(&val).expect("valid team rule");
        assert_eq!(mapped.description, "Summary");
        assert_eq!(mapped.content, "Full body");
        assert_eq!(mapped.origin.as_deref(), Some("conversation"));
    }

    #[test]
    fn team_sync_rule_mapping_falls_back_to_description_for_old_clouds() {
        let val = serde_json::json!({
            "id": "team-rule-2",
            "name": "Rule",
            "description": "Summary"
        });
        let mapped = map_team_synced_rule_value(&val).expect("valid legacy team rule");
        assert_eq!(mapped.content, "Summary");
        assert_eq!(mapped.origin.as_deref(), Some("team"));
    }

    #[test]
    fn skill_content_hash_uses_db_fields_without_skill_file() {
        let skill = SkillRecord {
            id: "missing-cloud-rule".into(),
            name: "Missing cloud rule".into(),
            source: "cloud".into(),
            directory: "missing-cloud-rule".into(),
            version: "1.0.0".into(),
            description: "description fallback".into(),
            r#type: "review_standard".into(),
            engines: vec![],
            tags: vec![],
            trigger: None,
            check_prompt: Some("prefer check prompt for hashing".into()),
            repo_owner: None,
            repo_name: None,
            repo_branch: None,
            readme_url: None,
            enabled_for_codex: true,
            enabled_for_claude: true,
            enabled_for_gemini: true,
            enabled_for_cursor: true,
            installed_at: "2026-05-11T00:00:00Z".into(),
            updated_at: "2026-05-11T00:00:00Z".into(),
            enforcement: None,
            origin: "pr_review".into(),
        };

        let expected_payload = r#"{"content":"prefer check prompt for hashing","filePatterns":[],"sourceRepo":null,"tags":[],"version":"1.0.0"}"#;
        assert_eq!(
            rule_sync_hash_payload_json(&skill, None, "prefer check prompt for hashing"),
            expected_payload
        );
        let expected = {
            let digest = sha2::Sha256::digest(expected_payload.as_bytes());
            use std::fmt::Write as _;
            digest
                .iter()
                .fold(String::with_capacity(digest.len() * 2), |mut acc, b| {
                    let _ = write!(acc, "{b:02x}");
                    acc
                })
        };

        assert_eq!(skill_content_hash(&skill, None), expected);
    }

    #[test]
    fn skill_content_hash_changes_when_scope_metadata_changes() {
        let skill = SkillRecord {
            id: "scoped-cloud-rule".into(),
            name: "Scoped cloud rule".into(),
            source: "cloud".into(),
            directory: "missing-cloud-rule".into(),
            version: "1.0.0".into(),
            description: "same body".into(),
            r#type: "review_standard".into(),
            engines: vec![],
            tags: vec!["origin:session_mined".into()],
            trigger: None,
            check_prompt: Some("same body".into()),
            repo_owner: None,
            repo_name: None,
            repo_branch: None,
            readme_url: None,
            enabled_for_codex: true,
            enabled_for_claude: true,
            enabled_for_gemini: true,
            enabled_for_cursor: true,
            installed_at: "2026-05-11T00:00:00Z".into(),
            updated_at: "2026-05-11T00:00:00Z".into(),
            enforcement: None,
            origin: "pr_review".into(),
        };
        let rust_scope = SyncHashScope {
            file_patterns: vec!["src/**/*.rs".into()],
            source_repo: Some("acme/widgets".into()),
        };
        let ts_scope = SyncHashScope {
            file_patterns: vec!["packages/**/*.ts".into()],
            source_repo: Some("acme/widgets".into()),
        };

        assert_ne!(
            skill_content_hash(&skill, Some(&rust_scope)),
            skill_content_hash(&skill, Some(&ts_scope))
        );
    }
}
