Skip to main content

hinge_rs/
client.rs

1use crate::enums::EducationAttainedProfile;
2use crate::errors::HingeError;
3use crate::logging::{log_request, log_response};
4use crate::models::{
5    AccountInfo, AnswerContentPayload, AnswerEvaluateRequest, AuthSettings, ConnectionContentItem,
6    ConnectionDetailApi, ConnectionItem, ConnectionsResponse, CreatePromptPollRequest,
7    CreatePromptPollResponse, CreateRate, CreateRateContent, CreateRateContentPrompt,
8    CreateVideoPromptRequest, CreateVideoPromptResponse, ExportChatInput, ExportChatResult,
9    ExportStatus, ExportedMediaFile, HingeAuthToken, LikeLimit, LikeResponse, LikesV2Response,
10    LoginTokens, MatchNoteResponse, NotificationSettings, PhotoAsset, PhotoAssetInput, Preferences,
11    PreferencesResponse, ProfileContentFull, ProfileUpdate, Prompt, PromptsResponse,
12    PublicUserProfile, RateInput, RateRespondRequest, RateRespondResponse, RecommendationSubject,
13    RecommendationsResponse, SelfContentResponse, SelfProfileResponse, SendbirdAuthToken,
14    SendbirdChannelHandle, SendbirdGroupChannel, SendbirdMessage, SkipInput, StandoutsResponse,
15    UserSettings, UserTrait,
16};
17use crate::prompts_manager::HingePromptsManager;
18use crate::settings::Settings;
19use crate::storage::{SecretStore, Storage};
20use chrono::{DateTime, Local, Utc};
21use futures_util::{SinkExt, StreamExt};
22use reqwest::{Client as Http, StatusCode};
23use serde_json::json;
24use std::cmp::min;
25use std::collections::{HashMap, HashSet};
26use std::fmt::Write as FmtWrite;
27use std::fs;
28use std::path::Path;
29use std::path::PathBuf;
30use std::time::{Duration, Instant};
31use tokio::time::sleep;
32use tokio_tungstenite::tungstenite::Message;
33use uuid::Uuid;
34
35pub const DEFAULT_PUBLIC_IDS_BATCH_SIZE: usize = 75;
36
37#[derive(Clone, Debug)]
38pub struct RecsFetchConfig {
39    pub multi_fetch_count: usize,
40    pub request_delay_ms: u64,
41    pub rate_limit_retries: usize,
42    pub rate_limit_backoff_ms: u64,
43}
44
45impl Default for RecsFetchConfig {
46    fn default() -> Self {
47        Self {
48            multi_fetch_count: 3,
49            request_delay_ms: 1_500,
50            rate_limit_retries: 3,
51            rate_limit_backoff_ms: 4_000,
52        }
53    }
54}
55
56/// Convert ProfileUpdate to API format with numeric enum values
57fn profile_update_to_api_json(update: &ProfileUpdate) -> serde_json::Value {
58    use crate::enums::ApiEnum;
59
60    let mut obj = serde_json::Map::new();
61
62    // Convert each field, using the enum's to_api_value() when needed
63    if let Some(ref children) = update.children {
64        obj.insert(
65            "children".to_string(),
66            json!({
67                "value": children.value.to_api_value(),
68                "visible": children.visible
69            }),
70        );
71    }
72
73    if let Some(ref dating) = update.dating_intention {
74        obj.insert(
75            "datingIntention".to_string(),
76            json!({
77                "value": dating.value.to_api_value(),
78                "visible": dating.visible
79            }),
80        );
81    }
82
83    if let Some(ref drinking) = update.drinking {
84        obj.insert(
85            "drinking".to_string(),
86            json!({
87                "value": drinking.value.to_api_value(),
88                "visible": drinking.visible
89            }),
90        );
91    }
92
93    if let Some(ref drugs) = update.drugs {
94        obj.insert(
95            "drugs".to_string(),
96            json!({
97                "value": drugs.value.to_api_value(),
98                "visible": drugs.visible
99            }),
100        );
101    }
102
103    if let Some(ref marijuana) = update.marijuana {
104        obj.insert(
105            "marijuana".to_string(),
106            json!({
107                "value": marijuana.value.to_api_value(),
108                "visible": marijuana.visible
109            }),
110        );
111    }
112
113    if let Some(ref smoking) = update.smoking {
114        obj.insert(
115            "smoking".to_string(),
116            json!({
117                "value": smoking.value.to_api_value(),
118                "visible": smoking.visible
119            }),
120        );
121    }
122
123    if let Some(ref politics) = update.politics {
124        obj.insert(
125            "politics".to_string(),
126            json!({
127                "value": politics.value.to_api_value(),
128                "visible": politics.visible
129            }),
130        );
131    }
132
133    if let Some(ref religions) = update.religions {
134        let values: Vec<i8> = religions.value.iter().map(|e| e.to_api_value()).collect();
135        obj.insert(
136            "religions".to_string(),
137            json!({
138                "value": values,
139                "visible": religions.visible
140            }),
141        );
142    }
143
144    if let Some(ref ethnicities) = update.ethnicities {
145        let values: Vec<i8> = ethnicities.value.iter().map(|e| e.to_api_value()).collect();
146        obj.insert(
147            "ethnicities".to_string(),
148            json!({
149                "value": values,
150                "visible": ethnicities.visible
151            }),
152        );
153    }
154
155    if let Some(ref education) = update.education_attained {
156        obj.insert(
157            "educationAttained".to_string(),
158            json!(education.to_api_value()),
159        );
160    }
161
162    if let Some(ref relationships) = update.relationship_type_ids {
163        let values: Vec<i8> = relationships
164            .value
165            .iter()
166            .map(|e| e.to_api_value())
167            .collect();
168        obj.insert(
169            "relationshipTypeIds".to_string(),
170            json!({
171                "value": values,
172                "visible": relationships.visible
173            }),
174        );
175    }
176
177    if let Some(height) = update.height {
178        obj.insert("height".to_string(), json!(height));
179    }
180
181    if let Some(ref gender) = update.gender_id {
182        obj.insert("genderId".to_string(), json!(gender.to_api_value()));
183    }
184
185    if let Some(ref hometown) = update.hometown {
186        obj.insert(
187            "hometown".to_string(),
188            json!({
189                "value": hometown.value,
190                "visible": hometown.visible
191            }),
192        );
193    }
194
195    if let Some(ref languages) = update.languages_spoken {
196        obj.insert(
197            "languagesSpoken".to_string(),
198            json!({
199                "value": languages.value,
200                "visible": languages.visible
201            }),
202        );
203    }
204
205    if let Some(ref zodiac) = update.zodiac {
206        obj.insert(
207            "zodiac".to_string(),
208            json!({
209                "value": zodiac.value,
210                "visible": zodiac.visible
211            }),
212        );
213    }
214
215    serde_json::Value::Object(obj)
216}
217
218/// Convert Preferences to API format with numeric enum values
219fn preferences_to_api_json(prefs: &Preferences) -> serde_json::Value {
220    use crate::enums::ApiEnum;
221
222    json!({
223        "genderedAgeRanges": prefs.gendered_age_ranges,
224        "dealbreakers": prefs.dealbreakers,
225        "religions": prefs.religions.iter().map(|e| e.to_api_value()).collect::<Vec<_>>(),
226        "drinking": prefs.drinking.iter().map(|e| e.to_api_value()).collect::<Vec<_>>(),
227        "genderedHeightRanges": prefs.gendered_height_ranges,
228        "marijuana": prefs.marijuana.iter().map(|e| e.to_api_value()).collect::<Vec<_>>(),
229        "relationshipTypes": prefs.relationship_types.iter().map(|e| e.to_api_value()).collect::<Vec<_>>(),
230        "drugs": prefs.drugs.iter().map(|e| e.to_api_value()).collect::<Vec<_>>(),
231        "maxDistance": prefs.max_distance,
232        "children": prefs.children.iter().map(|e| e.to_api_value()).collect::<Vec<_>>(),
233        "ethnicities": prefs.ethnicities.iter().map(|e| e.to_api_value()).collect::<Vec<_>>(),
234        "smoking": prefs.smoking.iter().map(|e| e.to_api_value()).collect::<Vec<_>>(),
235        "educationAttained": prefs.education_attained.iter().map(|e| e.to_api_value()).collect::<Vec<_>>(),
236        "familyPlans": prefs.family_plans,
237        "datingIntentions": prefs.dating_intentions.iter().map(|e| e.to_api_value()).collect::<Vec<_>>(),
238        "politics": prefs.politics.iter().map(|e| e.to_api_value()).collect::<Vec<_>>(),
239        "genderPreferences": prefs.gender_preferences.iter().map(|e| e.to_api_value()).collect::<Vec<_>>()
240    })
241}
242
243const CHILDREN_LABELS: &[(i32, &str)] = &[
244    (-1, "Open to all"),
245    (0, "Prefer not to say"),
246    (1, "Don't have children"),
247    (2, "Have children"),
248];
249
250const DATING_LABELS: &[(i32, &str)] = &[
251    (-1, "Open to all"),
252    (0, "Unknown"),
253    (1, "Life partner"),
254    (2, "Long-term relationship"),
255    (3, "Long-term, open to short"),
256    (4, "Short-term, open to long"),
257    (5, "Short-term relationship"),
258    (6, "Figuring out their dating goals"),
259];
260
261const DRINKING_LABELS: &[(i32, &str)] = &[
262    (-1, "Open to all"),
263    (0, "Prefer not to say"),
264    (1, "Don't drink"),
265    (2, "Drink"),
266    (3, "Sometimes"),
267];
268
269const SMOKING_LABELS: &[(i32, &str)] = &[
270    (-1, "Open to all"),
271    (0, "Prefer not to say"),
272    (1, "Don't smoke"),
273    (2, "Smoke"),
274    (3, "Sometimes"),
275];
276
277const MARIJUANA_LABELS: &[(i32, &str)] = &[
278    (-1, "Open to all"),
279    (0, "Prefer not to say"),
280    (1, "Don't use marijuana"),
281    (2, "Use marijuana"),
282    (3, "Sometimes"),
283    (4, "No preference"),
284];
285
286const DRUG_LABELS: &[(i32, &str)] = &[
287    (-1, "Open to all"),
288    (0, "Prefer not to say"),
289    (1, "Don't use drugs"),
290    (2, "Use drugs"),
291    (3, "Sometimes"),
292];
293
294const RELATIONSHIP_TYPE_LABELS: &[(i32, &str)] = &[
295    (-1, "Open to all"),
296    (1, "Monogamy"),
297    (2, "Ethical non-monogamy"),
298    (3, "Open relationship"),
299    (4, "Polyamory"),
300    (5, "Open to exploring"),
301];
302
303fn label_from_map(map: &'static [(i32, &'static str)], code: Option<i32>) -> Option<&'static str> {
304    let key = code?;
305    map.iter().find(|(c, _)| *c == key).map(|(_, label)| *label)
306}
307
308fn labels_from_map(
309    map: &'static [(i32, &'static str)],
310    codes: &Option<Vec<i32>>,
311) -> Vec<&'static str> {
312    match codes {
313        Some(values) => values
314            .iter()
315            .filter_map(|code| map.iter().find(|(c, _)| c == code).map(|(_, label)| *label))
316            .collect(),
317        None => Vec::new(),
318    }
319}
320
321fn sanitize_component(input: &str) -> String {
322    let trimmed = input.trim();
323    let mut out = String::with_capacity(trimmed.len());
324    for ch in trimmed.chars() {
325        if matches!(ch, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') {
326            out.push('_');
327        } else {
328            out.push(ch);
329        }
330    }
331    if out.is_empty() { "export".into() } else { out }
332}
333
334fn parse_ts(value: &str) -> Option<i64> {
335    value.parse::<i64>().ok()
336}
337
338fn parse_json_with_path<T: serde::de::DeserializeOwned>(text: &str) -> Result<T, HingeError> {
339    let mut deserializer = serde_json::Deserializer::from_str(text);
340    serde_path_to_error::deserialize(&mut deserializer).map_err(|err| {
341        let path = err.path().to_string();
342        if path == "." {
343            HingeError::Serde(err.inner().to_string())
344        } else {
345            HingeError::Serde(format!("{} at {}", err.inner(), path))
346        }
347    })
348}
349
350fn parse_json_value_with_path<T: serde::de::DeserializeOwned>(
351    value: serde_json::Value,
352) -> Result<T, HingeError> {
353    parse_json_with_path(&value.to_string())
354}
355
356fn attachment_from_value(value: &serde_json::Value) -> Option<(String, String)> {
357    if !value.is_object() {
358        return None;
359    }
360    let url = value
361        .get("url")
362        .and_then(|v| v.as_str())
363        .or_else(|| value.get("secure_url").and_then(|v| v.as_str()))?;
364    let name = value
365        .get("name")
366        .and_then(|v| v.as_str())
367        .map(|s| s.to_string())
368        .unwrap_or_else(|| {
369            url.split('/')
370                .next_back()
371                .unwrap_or("attachment")
372                .to_string()
373        });
374    Some((url.to_string(), name))
375}
376
377fn education_attained_label(value: &EducationAttainedProfile) -> &'static str {
378    use EducationAttainedProfile::*;
379    match value {
380        PreferNotToSay => "Prefer not to say",
381        HighSchool => "High school",
382        TradeSchool => "Trade school",
383        InCollege => "In college",
384        Undergraduate => "Undergraduate degree",
385        InGradSchool => "In grad school",
386        Graduate => "Graduate degree",
387    }
388}
389
390#[derive(Clone)]
391pub struct HingeClient<S: Storage + Clone> {
392    http: Http,
393    pub settings: Settings,
394    pub storage: S,
395    secret_store: Option<std::sync::Arc<dyn SecretStore>>,
396    pub phone_number: String,
397    pub device_id: String,
398    pub install_id: String,
399    pub session_id: String,
400    pub installed: bool,
401    pub hinge_auth: Option<HingeAuthToken>,
402    pub sendbird_auth: Option<SendbirdAuthToken>,
403    pub sendbird_session_key: Option<String>,
404    // Sendbird WS state (single connection)
405    sendbird_ws_cmd_tx: Option<tokio::sync::mpsc::UnboundedSender<String>>, // READ, etc.
406    sendbird_ws_broadcast_tx: Option<tokio::sync::broadcast::Sender<String>>, // emits incoming frames
407    sendbird_ws_connected: bool,
408    sendbird_ws_pending_requests: std::sync::Arc<
409        tokio::sync::Mutex<
410            std::collections::HashMap<String, tokio::sync::oneshot::Sender<serde_json::Value>>,
411        >,
412    >,
413    pub recommendations: std::collections::HashMap<String, RecommendationSubject>,
414    // Persistence config
415    session_path: Option<String>,
416    cache_dir: Option<PathBuf>,
417    auto_persist: bool,
418    recs_fetch_config: RecsFetchConfig,
419    public_ids_batch_size: usize,
420    last_recs_v2_call: Option<Instant>,
421}
422
423impl<S: Storage + Clone> HingeClient<S> {
424    pub fn set_recs_fetch_config(&mut self, config: RecsFetchConfig) {
425        self.recs_fetch_config = config;
426    }
427
428    pub fn set_public_ids_batch_size(&mut self, batch_size: usize) {
429        self.public_ids_batch_size = batch_size.max(1);
430    }
431
432    pub async fn rendered_profile_text_for_user(
433        &mut self,
434        user_id: &str,
435    ) -> Result<String, HingeError> {
436        let uid = user_id.trim();
437        if uid.is_empty() {
438            return Ok(String::new());
439        }
440
441        let prompts_manager = match self.fetch_prompts_manager().await {
442            Ok(mgr) => Some(mgr),
443            Err(err) => {
444                log::warn!("Failed to prefetch prompts for rendered profile: {}", err);
445                None
446            }
447        };
448
449        let profile = self
450            .get_profiles(vec![uid.to_string()])
451            .await?
452            .into_iter()
453            .next();
454
455        let profile_content = self
456            .get_profile_content(vec![uid.to_string()])
457            .await?
458            .into_iter()
459            .next();
460
461        let text = render_profile(
462            profile.as_ref(),
463            profile_content.as_ref(),
464            prompts_manager.as_ref(),
465        );
466        Ok(text)
467    }
468    pub fn new(phone_number: impl Into<String>, storage: S, settings: Option<Settings>) -> Self {
469        let settings = settings.unwrap_or_default();
470        Self {
471            http: Http::new(),
472            settings,
473            storage,
474            secret_store: None,
475            phone_number: phone_number.into(),
476            device_id: Uuid::new_v4().to_string().to_uppercase(),
477            install_id: Uuid::new_v4().to_string().to_uppercase(),
478            session_id: Uuid::new_v4().to_string().to_uppercase(),
479            installed: false,
480            hinge_auth: None,
481            sendbird_auth: None,
482            sendbird_session_key: None,
483            sendbird_ws_cmd_tx: None,
484            sendbird_ws_broadcast_tx: None,
485            sendbird_ws_connected: false,
486            sendbird_ws_pending_requests: std::sync::Arc::new(tokio::sync::Mutex::new(
487                std::collections::HashMap::new(),
488            )),
489            recommendations: std::collections::HashMap::new(),
490            session_path: None,
491            cache_dir: None,
492            auto_persist: false,
493            recs_fetch_config: RecsFetchConfig::default(),
494            public_ids_batch_size: DEFAULT_PUBLIC_IDS_BATCH_SIZE,
495            last_recs_v2_call: None,
496        }
497    }
498
499    pub fn with_secret_store(mut self, store: std::sync::Arc<dyn SecretStore>) -> Self {
500        self.secret_store = Some(store);
501        self
502    }
503
504    // Helper method for making GET requests with logging
505    async fn http_get(&self, url: &str) -> Result<reqwest::Response, HingeError> {
506        let headers = self.default_headers()?;
507        log_request("GET", url, &headers, None);
508
509        let res = self.http.get(url).headers(headers.clone()).send().await?;
510
511        log::info!("GET {} -> {}", url, res.status());
512        Ok(res)
513    }
514
515    async fn http_get_bytes(&self, url: &str) -> Result<Vec<u8>, HingeError> {
516        log::info!("GET (bytes) {}", url);
517        let res = self.http.get(url).send().await?;
518        let status = res.status();
519        if !status.is_success() {
520            let text = res
521                .text()
522                .await
523                .unwrap_or_else(|_| "Failed to read response body".into());
524            return Err(HingeError::Http(format!("status {}: {}", status, text)));
525        }
526        res.bytes()
527            .await
528            .map(|b| b.to_vec())
529            .map_err(|e| HingeError::Http(format!("Failed to download media: {}", e)))
530    }
531
532    // Helper method for making POST requests with logging
533    async fn http_post(
534        &self,
535        url: &str,
536        body: &serde_json::Value,
537    ) -> Result<reqwest::Response, HingeError> {
538        let headers = self.default_headers()?;
539        log_request("POST", url, &headers, Some(body));
540
541        let res = self
542            .http
543            .post(url)
544            .headers(headers.clone())
545            .json(body)
546            .send()
547            .await?;
548
549        log::info!("POST {} -> {}", url, res.status());
550        Ok(res)
551    }
552
553    // Helper method for making PATCH requests with logging
554    async fn http_patch(
555        &self,
556        url: &str,
557        body: &serde_json::Value,
558    ) -> Result<reqwest::Response, HingeError> {
559        let headers = self.default_headers()?;
560        log_request("PATCH", url, &headers, Some(body));
561
562        let res = self
563            .http
564            .patch(url)
565            .headers(headers.clone())
566            .json(body)
567            .send()
568            .await?;
569
570        log::info!("PATCH {} -> {}", url, res.status());
571        Ok(res)
572    }
573
574    // Helper to parse response with logging
575    async fn parse_response<T: serde::de::DeserializeOwned>(
576        &self,
577        res: reqwest::Response,
578    ) -> Result<T, HingeError> {
579        let status = res.status();
580        let headers = res.headers().clone();
581
582        if !status.is_success() {
583            let text = res
584                .text()
585                .await
586                .unwrap_or_else(|_| "Failed to get response text".to_string());
587            log::error!("HTTP Error {}: {}", status, text);
588            return Err(HingeError::Http(format!("status {}: {}", status, text)));
589        }
590
591        let text = res.text().await?;
592        match parse_json_with_path::<T>(&text) {
593            Ok(data) => {
594                if let Ok(json_val) = serde_json::from_str::<serde_json::Value>(&text) {
595                    log_response(status, &headers, Some(&json_val));
596                }
597                Ok(data)
598            }
599            Err(e) => {
600                log::error!("Failed to parse response: {}", e);
601                log::error!("Response text: {}", text);
602                Err(e)
603            }
604        }
605    }
606
607    fn default_headers(&self) -> Result<reqwest::header::HeaderMap, HingeError> {
608        use reqwest::header::{HeaderMap, HeaderValue};
609        let mut h = HeaderMap::new();
610        h.insert("content-type", HeaderValue::from_static("application/json"));
611        h.insert("accept", HeaderValue::from_static("*/*"));
612        h.insert("accept-language", HeaderValue::from_static("en-GB"));
613        h.insert("connection", HeaderValue::from_static("keep-alive"));
614        h.insert(
615            "accept-encoding",
616            HeaderValue::from_static("gzip, deflate, br"),
617        );
618        h.insert(
619            "x-device-model-code",
620            HeaderValue::from_static("iPhone15,2"),
621        );
622        h.insert("x-device-model", HeaderValue::from_static("unknown"));
623        h.insert("x-device-region", HeaderValue::from_static("IN"));
624        // Required Hinge headers
625        h.insert(
626            "x-session-id",
627            HeaderValue::from_str(&self.session_id)
628                .map_err(|e| HingeError::Http(format!("Invalid session id header: {}", e)))?,
629        );
630        h.insert(
631            "x-device-id",
632            HeaderValue::from_str(&self.device_id)
633                .map_err(|e| HingeError::Http(format!("Invalid device id header: {}", e)))?,
634        );
635        h.insert(
636            "x-install-id",
637            HeaderValue::from_str(&self.install_id)
638                .map_err(|e| HingeError::Http(format!("Invalid install id header: {}", e)))?,
639        );
640        h.insert("x-device-platform", HeaderValue::from_static("iOS"));
641        h.insert(
642            "x-app-version",
643            HeaderValue::from_str(&self.settings.hinge_app_version)
644                .map_err(|e| HingeError::Http(format!("Invalid app version header: {}", e)))?,
645        );
646        h.insert(
647            "x-build-number",
648            HeaderValue::from_str(&self.settings.hinge_build_number)
649                .map_err(|e| HingeError::Http(format!("Invalid build number header: {}", e)))?,
650        );
651        h.insert(
652            "x-os-version",
653            HeaderValue::from_str(&self.settings.os_version)
654                .map_err(|e| HingeError::Http(format!("Invalid OS version header: {}", e)))?,
655        );
656        // Hardcoded Darwin kernel version for iOS 26.0 (iPhone 15,2)
657        let ua = format!(
658            "Hinge/{} CFNetwork/3859.100.1 Darwin/25.0.0",
659            self.settings.hinge_build_number
660        );
661        h.insert(
662            "user-agent",
663            HeaderValue::from_str(&ua)
664                .map_err(|e| HingeError::Http(format!("Invalid user agent header: {}", e)))?,
665        );
666        if let Some(token) = &self.hinge_auth {
667            h.insert(
668                "authorization",
669                HeaderValue::from_str(&format!("Bearer {}", token.token))
670                    .map_err(|e| HingeError::Http(format!("Invalid auth token header: {}", e)))?,
671            );
672        }
673        Ok(h)
674    }
675
676    fn sendbird_headers(&self) -> Result<reqwest::header::HeaderMap, HingeError> {
677        use reqwest::header::{HeaderMap, HeaderValue};
678        let mut h = HeaderMap::new();
679        h.insert("accept", HeaderValue::from_static("application/json"));
680        h.insert(
681            "accept-encoding",
682            HeaderValue::from_static("gzip, deflate, br"),
683        );
684        h.insert("connection", HeaderValue::from_static("Keep-Alive"));
685        h.insert(
686            "accept-language",
687            HeaderValue::from_static("en-IN,en;q=0.9"),
688        );
689        if let Some(session_key) = &self.sendbird_session_key {
690            h.insert(
691                "Session-Key",
692                HeaderValue::from_str(session_key)
693                    .map_err(|e| HingeError::Http(format!("Invalid session key: {}", e)))?,
694            );
695        }
696        // Timestamp header present in logs
697        let ts = chrono::Utc::now().timestamp_millis();
698        h.insert(
699            "Request-Sent-Timestamp",
700            HeaderValue::from_str(&ts.to_string())
701                .map_err(|e| HingeError::Http(format!("Invalid timestamp: {}", e)))?,
702        );
703        // SDK-identifying headers observed in logs
704        let sendbird_hdr = format!(
705            "iOS,{},{},{}",
706            self.settings.os_version,
707            self.settings.sendbird_sdk_version,
708            self.settings.sendbird_app_id
709        );
710        h.insert(
711            "SendBird",
712            HeaderValue::from_str(&sendbird_hdr)
713                .map_err(|e| HingeError::Http(format!("Invalid SendBird header: {}", e)))?,
714        );
715        h.insert(
716            "SB-User-Agent",
717            HeaderValue::from_str(&format!("iOS/c{}///", self.settings.sendbird_sdk_version))
718                .map_err(|e| HingeError::Http(format!("Invalid SB-User-Agent: {}", e)))?,
719        );
720        h.insert(
721            "SB-SDK-User-Agent",
722            HeaderValue::from_str(&format!(
723                "main_sdk_info=chat/ios/{}&device_os_platform=ios&os_version={}",
724                self.settings.sendbird_sdk_version, self.settings.os_version
725            ))
726            .map_err(|e| HingeError::Http(format!("Invalid SB-SDK-User-Agent: {}", e)))?,
727        );
728        h.insert("user-agent", HeaderValue::from_static("Jios/4.26.0"));
729        Ok(h)
730    }
731
732    async fn sendbird_get(&self, path_and_query: &str) -> Result<reqwest::Response, HingeError> {
733        let url = format!("{}/v3{}", self.settings.sendbird_api_url, path_and_query);
734        let headers = self.sendbird_headers()?;
735        log_request("GET", &url, &headers, None);
736        let res = self.http.get(url).headers(headers.clone()).send().await?;
737        log::info!("[sendbird] GET {} -> {}", path_and_query, res.status());
738        Ok(res)
739    }
740
741    #[allow(dead_code)]
742    async fn sendbird_post_json(
743        &self,
744        path_and_query: &str,
745        body: &serde_json::Value,
746    ) -> Result<reqwest::Response, HingeError> {
747        let url = format!("{}/v3{}", self.settings.sendbird_api_url, path_and_query);
748        let mut headers = self.sendbird_headers()?;
749        use reqwest::header::HeaderValue;
750        headers.insert("content-type", HeaderValue::from_static("application/json"));
751        log_request("POST", &url, &headers, Some(body));
752        let res = self
753            .http
754            .post(url)
755            .headers(headers.clone())
756            .json(body)
757            .send()
758            .await?;
759        log::info!("[sendbird] POST {} -> {}", path_and_query, res.status());
760        Ok(res)
761    }
762
763    async fn ensure_sendbird_session(&mut self) -> Result<(), HingeError> {
764        // If a WS is already connected, we're good
765        if self.sendbird_ws_connected {
766            return Ok(());
767        }
768
769        // Ensure we have Sendbird JWT from Hinge
770        if self.sendbird_auth.is_none() {
771            self.authenticate_with_sendbird().await?;
772        }
773
774        // Start and hold a single WS connection; capture LOGI and broadcast frames
775        let (cmd_tx, broadcast_tx) = self.start_sendbird_ws().await?;
776        self.sendbird_ws_cmd_tx = Some(cmd_tx);
777        self.sendbird_ws_broadcast_tx = Some(broadcast_tx);
778        self.sendbird_ws_connected = true;
779        Ok(())
780    }
781
782    async fn start_sendbird_ws(
783        &mut self,
784    ) -> Result<
785        (
786            tokio::sync::mpsc::UnboundedSender<String>,
787            tokio::sync::broadcast::Sender<String>,
788        ),
789        HingeError,
790    > {
791        let sb = self
792            .sendbird_auth
793            .as_ref()
794            .ok_or_else(|| HingeError::Auth("sendbird token missing".into()))?;
795        let ws_url = format!(
796            "{}/?p=iOS&sv={}&pv={}&uikit_config=0&use_local_cache=0&include_extra_data=premium_feature_list,file_upload_size_limit,emoji_hash,application_attributes,notifications,message_template,ai_agent&include_poll_details=1&user_id={}&ai={}&pmce=1&expiring_session=0&config_ts=0",
797            self.settings.sendbird_ws_url,
798            self.settings.sendbird_sdk_version,
799            self.settings.os_version,
800            self.hinge_auth
801                .as_ref()
802                .map(|t| t.identity_id.clone())
803                .unwrap_or_default(),
804            self.settings.sendbird_app_id
805        );
806        let ws_ts = chrono::Utc::now().timestamp_millis().to_string();
807        let host = ws_url
808            .trim_start_matches("wss://")
809            .trim_start_matches("ws://")
810            .split('/')
811            .next()
812            .unwrap_or("");
813        let ws_key = tokio_tungstenite::tungstenite::handshake::client::generate_key();
814        let mut builder = tokio_tungstenite::tungstenite::http::Request::builder().uri(&ws_url);
815        if let Some(sk) = &self.sendbird_session_key {
816            builder = builder.header("SENDBIRD-WS-AUTH", sk);
817        } else {
818            builder = builder.header("SENDBIRD-WS-TOKEN", sb.token.clone());
819        }
820        builder = builder
821            .header("Accept", "*/*")
822            .header("Accept-Encoding", "gzip, deflate")
823            .header("Sec-WebSocket-Extensions", "permessage-deflate")
824            .header("Sec-WebSocket-Key", &ws_key)
825            .header("Sec-WebSocket-Version", "13")
826            .header("Request-Sent-Timestamp", &ws_ts)
827            .header("Host", host)
828            .header("Origin", "")
829            .header("Accept-Language", "en-IN,en;q=0.9")
830            .header("Connection", "Upgrade")
831            .header("Upgrade", "websocket")
832            .header(
833                "User-Agent",
834                &format!(
835                    "Hinge/{} CFNetwork/3859.100.1 Darwin/25.0.0",
836                    self.settings.hinge_build_number
837                ),
838            );
839        // Log WS request (redacted)
840        {
841            let mut pairs: Vec<(String, String)> = Vec::new();
842            if let Some(sk) = &self.sendbird_session_key {
843                pairs.push(("SENDBIRD-WS-AUTH".into(), sk.clone()));
844            } else {
845                pairs.push(("SENDBIRD-WS-TOKEN".into(), sb.token.clone()));
846            }
847            pairs.push(("Accept".into(), "*/*".into()));
848            pairs.push(("Accept-Encoding".into(), "gzip, deflate".into()));
849            pairs.push((
850                "Sec-WebSocket-Extensions".into(),
851                "permessage-deflate".into(),
852            ));
853            pairs.push(("Accept-Language".into(), "en-IN,en;q=0.9".into()));
854            pairs.push(("Host".into(), host.to_string()));
855            pairs.push(("Origin".into(), "".into()));
856            pairs.push(("Sec-WebSocket-Key".into(), ws_key.clone()));
857            pairs.push(("Sec-WebSocket-Version".into(), "13".into()));
858            pairs.push(("Request-Sent-Timestamp".into(), ws_ts.clone()));
859            pairs.push(("Connection".into(), "Upgrade".into()));
860            pairs.push(("Upgrade".into(), "websocket".into()));
861            pairs.push((
862                "User-Agent".into(),
863                format!(
864                    "Hinge/{} CFNetwork/3859.100.1 Darwin/25.0.0",
865                    self.settings.hinge_build_number
866                ),
867            ));
868            log::info!("━━━━━━━━━━ WS REQUEST ━━━━━━━━━━");
869            log::info!("GET {}", ws_url);
870            log::debug!("Headers:\n{}", crate::logging::format_ws_headers(&pairs));
871            log::info!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
872        }
873
874        let req: tokio_tungstenite::tungstenite::http::Request<()> = builder
875            .body(())
876            .map_err(|e| HingeError::Http(e.to_string()))?;
877        let (ws, _resp) = tokio_tungstenite::connect_async(req)
878            .await
879            .map_err(|e| HingeError::Http(e.to_string()))?;
880        let (write_half, mut read_half) = ws.split();
881        let write_half = std::sync::Arc::new(tokio::sync::Mutex::new(write_half));
882
883        let (tx_cmd, mut rx_cmd) = tokio::sync::mpsc::unbounded_channel::<String>();
884        let (tx_broadcast, _rx_broadcast) = tokio::sync::broadcast::channel::<String>(1024);
885        let (sk_tx, sk_rx) = tokio::sync::oneshot::channel::<String>();
886
887        // Reader: capture LOGI to set Session-Key; forward frames; respond to Ping
888        {
889            let write_for_pong = write_half.clone();
890            let tx_broadcast_c = tx_broadcast.clone();
891            let pending_requests = self.sendbird_ws_pending_requests.clone();
892            tokio::spawn(async move {
893                let mut sk_tx_opt = Some(sk_tx);
894                while let Some(msg) = read_half.next().await {
895                    match msg {
896                        Ok(Message::Ping(_)) => {
897                            let mut w = write_for_pong.lock().await;
898                            let _ = w.send(Message::Pong(Vec::new().into())).await;
899                        }
900                        Ok(Message::Text(t)) => {
901                            let t = t.to_string();
902                            // Handle LOGI frame - extract session key
903                            if t.starts_with("LOGI")
904                                && let Some(start) = t.find('{')
905                                && let Ok(val) =
906                                    serde_json::from_str::<serde_json::Value>(&t[start..])
907                            {
908                                if let Some(k) = val.get("key").and_then(|v| v.as_str()) {
909                                    let _ = tx_broadcast_c.send(format!("__SESSION_KEY__:{}", k));
910                                    if let Some(tx) = sk_tx_opt.take() {
911                                        let _ = tx.send(k.to_string());
912                                    }
913                                }
914                                // Log important LOGI fields
915                                log::info!(
916                                    "[sendbird ws] LOGI received - user_id: {}, ping_interval: {}, pong_timeout: {}",
917                                    val.get("user_id")
918                                        .and_then(|v| v.as_str())
919                                        .unwrap_or("unknown"),
920                                    val.get("ping_interval")
921                                        .and_then(|v| v.as_i64())
922                                        .unwrap_or(0),
923                                    val.get("pong_timeout")
924                                        .and_then(|v| v.as_i64())
925                                        .unwrap_or(0)
926                                );
927                            }
928                            // Handle PING frame - respond with PONG
929                            else if t.starts_with("PING") {
930                                log::debug!("[sendbird ws] Received PING, sending PONG");
931                                if let Some(start) = t.find('{')
932                                    && let Ok(_val) =
933                                        serde_json::from_str::<serde_json::Value>(&t[start..])
934                                {
935                                    let pong_response = json!({
936                                        "sts": chrono::Utc::now().timestamp_millis(),
937                                        "ts": chrono::Utc::now().timestamp_millis()
938                                    });
939                                    let pong_msg = format!("PONG{}", pong_response);
940                                    let mut w = write_for_pong.lock().await;
941                                    let _ = w.send(Message::Text(pong_msg.into())).await;
942                                }
943                            }
944                            // Handle READ acknowledgments
945                            else if t.starts_with("READ") && t.contains("channel_id") {
946                                log::debug!("[sendbird ws] Received READ acknowledgment");
947                                // Check if this is a response to a pending request
948                                if let Some(start) = t.find('{')
949                                    && let Ok(val) =
950                                        serde_json::from_str::<serde_json::Value>(&t[start..])
951                                    && let Some(req_id) = val.get("req_id").and_then(|v| v.as_str())
952                                {
953                                    let mut pending = pending_requests.lock().await;
954                                    if let Some(tx) = pending.remove(req_id) {
955                                        let _ = tx.send(val.clone());
956                                        log::debug!(
957                                            "[sendbird ws] Matched READ response for req_id: {}",
958                                            req_id
959                                        );
960                                    }
961                                }
962                            }
963                            // Broadcast all messages to subscribers
964                            // Parse SYEV (system event) frames for structured typing events
965                            if t.starts_with("SYEV")
966                                && let Some(start) = t.find('{')
967                                && let Ok(val) =
968                                    serde_json::from_str::<serde_json::Value>(&t[start..])
969                            {
970                                // Broadcast raw
971                                let _ = tx_broadcast_c.send(t.clone());
972                                // Try to parse into structured model
973                                if let Ok(evt) = serde_json::from_value::<
974                                    crate::models::SendbirdSyevEvent,
975                                >(val.clone())
976                                {
977                                    // Typing start/end logging
978                                    if evt.cat
979                                        == crate::models::SendbirdSyevEvent::CATEGORY_TYPING_START
980                                    {
981                                        log::debug!(
982                                            "[sendbird ws] SYEV typing start user={} channel={}",
983                                            evt.data
984                                                .as_ref()
985                                                .map(|u| u.user_id.as_str())
986                                                .unwrap_or("unknown"),
987                                            evt.channel_url
988                                        );
989                                    } else if evt.cat
990                                        == crate::models::SendbirdSyevEvent::CATEGORY_TYPING_END
991                                    {
992                                        log::debug!(
993                                            "[sendbird ws] SYEV typing end user={} channel={}",
994                                            evt.data
995                                                .as_ref()
996                                                .map(|u| u.user_id.as_str())
997                                                .unwrap_or("unknown"),
998                                            evt.channel_url
999                                        );
1000                                    }
1001                                    // Broadcast structured event for consumers
1002                                    if let Ok(json_evt) = serde_json::to_string(&evt) {
1003                                        let _ =
1004                                            tx_broadcast_c.send(format!("__SYEV__:{}", json_evt));
1005                                    }
1006                                    continue;
1007                                }
1008                            }
1009                            let _ = tx_broadcast_c.send(t);
1010                        }
1011                        Ok(Message::Binary(b)) => {
1012                            let _ = tx_broadcast_c.send(String::from_utf8_lossy(&b).into_owned());
1013                        }
1014                        Ok(Message::Pong(_)) => {}
1015                        Ok(Message::Close(frame)) => {
1016                            if let Some(cf) = frame {
1017                                let code_u16: u16 = cf.code.into();
1018
1019                                // Analyze if this could be time-based
1020                                // Your observation: LOGI at 8:03:11.826, Close at 8:03:12.526, code 55409
1021                                // Theory: The code might be derived from timestamp
1022                                let now = chrono::Utc::now();
1023                                let ms_timestamp = now.timestamp_millis();
1024
1025                                // Various time-based calculations that might match
1026                                let last_5_of_ms = (ms_timestamp % 100000) as u16;
1027                                let last_5_of_seconds = ((ms_timestamp / 1000) % 100000) as u16;
1028                                let seconds_today = (now.timestamp() % 86400) as u16;
1029                                let ms_today = ((now.timestamp() % 86400) * 1000
1030                                    + now.timestamp_subsec_millis() as i64)
1031                                    as u32;
1032                                let ms_today_mod = (ms_today % 65536) as u16; // Fit in u16
1033
1034                                log::debug!(
1035                                    "[sendbird ws] Time analysis - code: {}, last5_ms: {}, last5_sec: {}, sec_today: {}, ms_today_mod: {}",
1036                                    code_u16,
1037                                    last_5_of_ms,
1038                                    last_5_of_seconds,
1039                                    seconds_today,
1040                                    ms_today_mod
1041                                );
1042
1043                                let code_desc = match code_u16 {
1044                                    // Standard WebSocket close codes (1000-4999)
1045                                    1000 => "Normal closure",
1046                                    1001 => "Going away",
1047                                    1002 => "Protocol error",
1048                                    1003 => "Unsupported data",
1049                                    1006 => "Abnormal closure",
1050                                    1008 => "Policy violation",
1051                                    1009 => "Message too big",
1052                                    1010 => "Mandatory extension",
1053                                    1011 => "Internal server error",
1054                                    1015 => "TLS handshake failure",
1055                                    // Sendbird appears to use dynamic codes possibly derived from timestamps
1056                                    _ if code_u16 >= 10000 => {
1057                                        "Sendbird dynamic code (possibly time-derived)"
1058                                    }
1059                                    _ => "Non-standard close code",
1060                                };
1061                                log::warn!(
1062                                    "[sendbird ws] Connection closed - code: {} ({}), reason: {}",
1063                                    code_u16,
1064                                    code_desc,
1065                                    cf.reason
1066                                );
1067                                let _ = tx_broadcast_c
1068                                    .send(format!("__CLOSE__:{}:{}", code_u16, cf.reason));
1069
1070                                // Since Sendbird uses dynamic codes, we can't determine reconnection strategy from the code
1071                                // The reason string might be more informative than the code itself
1072                                if !cf.reason.is_empty() {
1073                                    log::info!(
1074                                        "[sendbird ws] Close reason provided: {}",
1075                                        cf.reason
1076                                    );
1077                                }
1078                            } else {
1079                                log::warn!("[sendbird ws] Connection closed without frame");
1080                                let _ = tx_broadcast_c.send("__CLOSE__".into());
1081                            }
1082                            break;
1083                        }
1084                        Ok(_) => {}
1085                        Err(e) => {
1086                            log::error!("[sendbird ws] WebSocket error: {}", e);
1087                            let _ = tx_broadcast_c.send(format!("__ERROR__:{}", e));
1088                            break;
1089                        }
1090                    }
1091                }
1092            });
1093        }
1094
1095        // Writer: forward commands to WS
1096        {
1097            let write_for_cmds = write_half.clone();
1098            tokio::spawn(async move {
1099                while let Some(cmd) = rx_cmd.recv().await {
1100                    let mut w = write_for_cmds.lock().await;
1101
1102                    // Check if this is a special close command
1103                    if cmd.starts_with("__CLOSE__:") {
1104                        // Parse the close code and reason
1105                        let parts: Vec<&str> = cmd
1106                            .strip_prefix("__CLOSE__:")
1107                            .unwrap_or("")
1108                            .split(':')
1109                            .collect();
1110                        let code = parts
1111                            .first()
1112                            .and_then(|s| s.parse::<u16>().ok())
1113                            .unwrap_or(1000);
1114                        let reason = parts.get(1).unwrap_or(&"").to_string();
1115
1116                        // Send WebSocket Close frame
1117                        let close_frame = tokio_tungstenite::tungstenite::protocol::CloseFrame {
1118                            code: tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode::from(code),
1119                            reason: reason.into(),
1120                        };
1121                        let _ = w.send(Message::Close(Some(close_frame))).await;
1122                        break; // Stop the writer task after sending close
1123                    } else {
1124                        // Regular text command
1125                        let _ = w.send(Message::Text(cmd.into())).await;
1126                    }
1127                }
1128            });
1129        }
1130
1131        // Wait for LOGI to deliver session key before returning so REST can use it
1132        if let Ok(k) = sk_rx.await {
1133            self.sendbird_session_key = Some(k);
1134            log::info!("[sendbird] Session-Key captured");
1135            if let Some(path) = &self.session_path {
1136                let _ = self.save_session(path);
1137            }
1138        } else {
1139            log::warn!("Sendbird LOGI not received before startup return");
1140        }
1141        Ok((tx_cmd, tx_broadcast))
1142    }
1143
1144    pub async fn sendbird_list_my_group_channels(
1145        &mut self,
1146        user_id: &str,
1147        limit: usize,
1148    ) -> Result<serde_json::Value, HingeError> {
1149        self.ensure_sendbird_session().await?;
1150        let q = format!(
1151            "/users/{}/my_group_channels?&include_left_channel=false&member_state_filter=all&super_mode=all&show_latest_message=false&show_pinned_messages=false&unread_filter=all&show_delivery_receipt=true&show_conversation=false&show_member=true&show_empty=true&limit={}&user_id={}&is_feed_channel=false&order=latest_last_message&hidden_mode=unhidden_only&distinct_mode=all&show_read_receipt=true&show_metadata=true&is_explicit_request=true&show_frozen=true&public_mode=all&include_chat_notification=false",
1152            user_id, limit, user_id
1153        );
1154        let res = self.sendbird_get(&q).await?;
1155        self.parse_response(res).await
1156    }
1157
1158    pub async fn sendbird_list_channels_typed(
1159        &mut self,
1160        limit: usize,
1161    ) -> Result<crate::models::SendbirdChannelsResponse, HingeError> {
1162        let user_id = self
1163            .hinge_auth
1164            .as_ref()
1165            .ok_or_else(|| HingeError::Auth("hinge token missing".into()))?
1166            .identity_id
1167            .clone();
1168        let limit = limit.clamp(1, 200);
1169        let raw = self
1170            .sendbird_list_my_group_channels(&user_id, limit)
1171            .await?;
1172        parse_json_value_with_path(raw).map_err(|e| {
1173            HingeError::Serde(format!("Failed to parse Sendbird channels response: {}", e))
1174        })
1175    }
1176
1177    pub async fn sendbird_get_channel(
1178        &mut self,
1179        channel_url: &str,
1180    ) -> Result<serde_json::Value, HingeError> {
1181        self.ensure_sendbird_session().await?;
1182        let q = format!(
1183            "/sdk/group_channels/{}?&is_feed_channel=false&show_latest_message=false&show_metadata=false&show_empty=false&show_member=true&show_frozen=false&show_read_receipt=true&show_pinned_messages=false&include_chat_notification=false&show_delivery_receipt=true&show_conversation=true",
1184            channel_url
1185        );
1186        let res = self.sendbird_get(&q).await?;
1187        self.parse_response(res).await
1188    }
1189
1190    pub async fn sendbird_get_channel_typed(
1191        &mut self,
1192        channel_url: &str,
1193    ) -> Result<SendbirdGroupChannel, HingeError> {
1194        let value = self.sendbird_get_channel(channel_url).await?;
1195        parse_json_value_with_path(value)
1196            .map_err(|e| HingeError::Serde(format!("Failed to parse channel: {}", e)))
1197    }
1198
1199    pub async fn sendbird_get_messages(
1200        &mut self,
1201        channel_url: &str,
1202        message_ts: i64,
1203        prev_limit: usize,
1204    ) -> Result<crate::models::SendbirdMessagesResponse, HingeError> {
1205        self.ensure_sendbird_session().await?;
1206        let q = format!(
1207            "/group_channels/{}/messages?&include_reply_type=all&sdk_source=external_legacy&with_sorted_meta_array=true&message_ts={}&is_sdk=true&include_reactions_summary=true&include_parent_message_info=false&reverse=true&prev_limit={}&custom_types=%2A&include=false&next_limit=0&include_poll_details=true&show_subchannel_messages_only=false&include_thread_info=false",
1208            channel_url, message_ts, prev_limit
1209        );
1210        let res = self.sendbird_get(&q).await?;
1211        self.parse_response(res).await
1212    }
1213
1214    pub async fn sendbird_get_full_messages(
1215        &mut self,
1216        channel_url: &str,
1217    ) -> Result<Vec<SendbirdMessage>, HingeError> {
1218        self.ensure_sendbird_session().await?;
1219        const PAGE_SIZE: usize = 120;
1220        let mut anchor = chrono::Utc::now().timestamp_millis();
1221        let mut seen: HashSet<String> = HashSet::new();
1222        let mut collected: Vec<(i64, SendbirdMessage)> = Vec::new();
1223
1224        loop {
1225            let batch = self
1226                .sendbird_get_messages(channel_url, anchor, PAGE_SIZE)
1227                .await?;
1228            if batch.messages.is_empty() {
1229                break;
1230            }
1231            let mut earliest = anchor;
1232            let mut added = 0usize;
1233            for message in batch.messages {
1234                if seen.insert(message.message_id.clone()) {
1235                    let ts = parse_ts(&message.created_at).unwrap_or(anchor);
1236                    earliest = min(earliest, ts.saturating_sub(1));
1237                    collected.push((ts, message));
1238                    added += 1;
1239                }
1240            }
1241            if added == 0 {
1242                break;
1243            }
1244            if earliest >= anchor || earliest <= 0 {
1245                break;
1246            }
1247            anchor = earliest;
1248            if collected.len() >= 4000 {
1249                log::warn!(
1250                    "[sendbird] Stopping history fetch after {} messages to avoid huge exports",
1251                    collected.len()
1252                );
1253                break;
1254            }
1255        }
1256
1257        collected.sort_by_key(|(ts, _)| *ts);
1258        Ok(collected.into_iter().map(|(_, msg)| msg).collect())
1259    }
1260
1261    pub async fn export_chat(
1262        &mut self,
1263        input: ExportChatInput,
1264    ) -> Result<ExportChatResult, HingeError> {
1265        self.ensure_sendbird_session().await?;
1266        let auth = self
1267            .hinge_auth
1268            .as_ref()
1269            .ok_or_else(|| HingeError::Auth("hinge token missing".into()))?
1270            .clone();
1271        let self_user_id = auth.identity_id.clone();
1272
1273        let prompts_manager = match self.fetch_prompts_manager().await {
1274            Ok(mgr) => Some(mgr),
1275            Err(err) => {
1276                log::warn!("Failed to prefetch prompts for export: {}", err);
1277                None
1278            }
1279        };
1280
1281        let channel = self.sendbird_get_channel_typed(&input.channel_url).await?;
1282        let partner = channel
1283            .members
1284            .iter()
1285            .find(|member| !member.user_id.is_empty() && member.user_id != self_user_id)
1286            .cloned()
1287            .ok_or_else(|| HingeError::Http("unable to determine conversation partner".into()))?;
1288
1289        let peer_id = partner.user_id.clone();
1290        let profile = self
1291            .get_profiles(vec![peer_id.clone()])
1292            .await?
1293            .into_iter()
1294            .next();
1295        let profile_content = self
1296            .get_profile_content(vec![peer_id.clone()])
1297            .await?
1298            .into_iter()
1299            .next();
1300
1301        let display_name = profile
1302            .as_ref()
1303            .map(|p| p.profile.first_name.clone())
1304            .filter(|name| !name.trim().is_empty())
1305            .or_else(|| {
1306                if !partner.nickname.trim().is_empty() {
1307                    Some(partner.nickname.clone())
1308                } else {
1309                    None
1310                }
1311            })
1312            .unwrap_or_else(|| peer_id.clone());
1313
1314        let age_label = profile
1315            .as_ref()
1316            .and_then(|p| p.profile.age)
1317            .map(|age| age.to_string())
1318            .unwrap_or_else(|| "Unknown age".to_string());
1319
1320        let initiation_summary_lines = if let Some(lines) = input.initiation_summary_lines.clone() {
1321            if lines.is_empty() { None } else { Some(lines) }
1322        } else {
1323            match self.get_connections_v2().await {
1324                Ok(resp) => resp
1325                    .connections
1326                    .into_iter()
1327                    .find(|conn| {
1328                        let initiator = conn.initiator_id.trim();
1329                        let subject = conn.subject_id.trim();
1330                        (!initiator.is_empty() && initiator == self_user_id && subject == peer_id)
1331                            || (!subject.is_empty()
1332                                && subject == self_user_id
1333                                && initiator == peer_id)
1334                    })
1335                    .and_then(|conn| {
1336                        summarize_connection_initiation(
1337                            &conn,
1338                            &self_user_id,
1339                            &peer_id,
1340                            &display_name,
1341                        )
1342                    }),
1343                Err(err) => {
1344                    log::warn!(
1345                        "Failed to fetch connections for initiation summary: {}",
1346                        err
1347                    );
1348                    None
1349                }
1350            }
1351        };
1352
1353        let base_dir = Path::new(&input.output_dir);
1354        let export_dir = base_dir.to_path_buf();
1355        fs::create_dir_all(&export_dir).map_err(|e| HingeError::Storage(e.to_string()))?;
1356
1357        let messages = self.sendbird_get_full_messages(&input.channel_url).await?;
1358
1359        let mut transcript = String::new();
1360        writeln!(transcript, "Chat with {} ({})", display_name, age_label).ok();
1361        writeln!(transcript, "Channel: {}", input.channel_url).ok();
1362        writeln!(transcript, "Exported at {}", Utc::now().to_rfc3339()).ok();
1363        if let Some(lines) = &initiation_summary_lines {
1364            for line in lines {
1365                writeln!(transcript, "{line}").ok();
1366            }
1367        }
1368        transcript.push('\n');
1369
1370        let mut media_files: Vec<ExportedMediaFile> = Vec::new();
1371
1372        if input.include_media
1373            && let Some(ref content) = profile_content
1374        {
1375            for (idx, photo) in content.content.photos.iter().enumerate() {
1376                let mut file_name = format!("profile_photo_{}", idx + 1);
1377                if let Some(ext) = photo
1378                    .url
1379                    .split('.')
1380                    .next_back()
1381                    .filter(|part| part.len() <= 5)
1382                {
1383                    file_name.push('.');
1384                    file_name.push_str(ext);
1385                }
1386                let sanitized = sanitize_component(&file_name);
1387                let target_path = export_dir.join(&sanitized);
1388                let bytes = self.http_get_bytes(&photo.url).await?;
1389                fs::write(&target_path, &bytes).map_err(|e| HingeError::Storage(e.to_string()))?;
1390                media_files.push(ExportedMediaFile {
1391                    message_id: format!("profile_photo_{}", idx + 1),
1392                    file_name: sanitized.clone(),
1393                    file_path: target_path.to_string_lossy().to_string(),
1394                });
1395            }
1396        }
1397
1398        for message in &messages {
1399            let timestamp = parse_ts(&message.created_at).unwrap_or(0);
1400            let local_time: DateTime<Local> = DateTime::<Utc>::from_timestamp_millis(timestamp)
1401                .map(|dt| dt.with_timezone(&Local))
1402                .unwrap_or_else(Local::now);
1403            let sender = if message.user.user_id == self_user_id {
1404                "You".to_string()
1405            } else if !message.user.nickname.is_empty() {
1406                message.user.nickname.clone()
1407            } else {
1408                display_name.clone()
1409            };
1410            let body = if !message.message.trim().is_empty() {
1411                message.message.clone()
1412            } else if !message.data.trim().is_empty() {
1413                message.data.clone()
1414            } else if !message.custom_type.trim().is_empty() {
1415                format!("[{} message]", message.custom_type)
1416            } else {
1417                "[non-text message]".into()
1418            };
1419
1420            writeln!(
1421                transcript,
1422                "{} - {}: {}",
1423                local_time.format("%Y-%m-%d %H:%M:%S"),
1424                sender,
1425                body
1426            )
1427            .ok();
1428
1429            if input.include_media
1430                && let Some((url, name)) = attachment_from_value(&message.file)
1431            {
1432                let sanitized = sanitize_component(&name);
1433                let target_path = export_dir.join(&sanitized);
1434                let bytes = self.http_get_bytes(&url).await?;
1435                fs::write(&target_path, &bytes).map_err(|e| HingeError::Storage(e.to_string()))?;
1436                writeln!(transcript, "    [Saved attachment: {}]", sanitized).ok();
1437                media_files.push(ExportedMediaFile {
1438                    message_id: message.message_id.clone(),
1439                    file_name: sanitized.clone(),
1440                    file_path: target_path.to_string_lossy().to_string(),
1441                });
1442            }
1443        }
1444
1445        let transcript_path = export_dir.join("chat.txt");
1446        fs::write(&transcript_path, transcript).map_err(|e| HingeError::Storage(e.to_string()))?;
1447
1448        let profile_text = render_profile(
1449            profile.as_ref(),
1450            profile_content.as_ref(),
1451            prompts_manager.as_ref(),
1452        );
1453        let profile_path = if !profile_text.trim().is_empty() {
1454            let path = export_dir.join("profile.txt");
1455            fs::write(&path, profile_text).map_err(|e| HingeError::Storage(e.to_string()))?;
1456            Some(path)
1457        } else {
1458            None
1459        };
1460
1461        Ok(ExportChatResult {
1462            folder_path: export_dir.to_string_lossy().to_string(),
1463            transcript_path: transcript_path.to_string_lossy().to_string(),
1464            profile_path: profile_path.map(|p| p.to_string_lossy().to_string()),
1465            message_count: messages.len().min(i32::MAX as usize) as i32,
1466            media_files,
1467        })
1468    }
1469
1470    pub async fn sendbird_create_distinct_dm(
1471        &mut self,
1472        self_user_id: &str,
1473        peer_user_id: &str,
1474        data_mm: i32,
1475    ) -> Result<serde_json::Value, HingeError> {
1476        self.ensure_sendbird_session().await?;
1477        let payload = json!({
1478            "is_ephemeral": false,
1479            "is_exclusive": false,
1480            "data": format!("{{\n  \"mm\" : {}\n}}", data_mm),
1481            "user_ids": [peer_user_id, self_user_id],
1482            "is_super": false,
1483            "is_distinct": true,
1484            "strict": false,
1485            "is_broadcast": false,
1486            "message_survival_seconds": -1,
1487            "is_public": false
1488        });
1489        let url = format!(
1490            "{}/v3{}",
1491            self.settings.sendbird_api_url, "/group_channels?"
1492        );
1493        let mut headers = self.sendbird_headers()?;
1494        use reqwest::header::HeaderValue;
1495        headers.insert(
1496            "content-type",
1497            HeaderValue::from_static("application/x-www-form-urlencoded"),
1498        );
1499        log_request("POST", &url, &headers, Some(&payload));
1500        let res = self
1501            .http
1502            .post(url)
1503            .headers(headers)
1504            .body(serde_json::to_string(&payload).unwrap_or_default())
1505            .send()
1506            .await?;
1507        log::info!("[sendbird] POST /group_channels -> {}", res.status());
1508        self.parse_response(res).await
1509    }
1510
1511    pub async fn sendbird_get_or_create_dm_channel(
1512        &mut self,
1513        self_user_id: &str,
1514        peer_user_id: &str,
1515    ) -> Result<String, HingeError> {
1516        // Try find existing channel containing exactly the two members
1517        let q = format!(
1518            "/users/{}/my_group_channels?&members_exactly_in={}&show_latest_message=false&distinct_mode=all&hidden_mode=unhidden_only&show_pinned_messages=false&show_metadata=true&member_state_filter=all&user_id={}&is_explicit_request=true&public_mode=all&include_left_channel=false&show_conversation=false&show_frozen=true&is_feed_channel=false&show_delivery_receipt=true&unread_filter=all&super_mode=all&show_member=true&show_read_receipt=true&order=chronological&show_empty=true&include_chat_notification=false&limit=1",
1519            self_user_id, peer_user_id, self_user_id
1520        );
1521        self.ensure_sendbird_session().await?;
1522        let res = self.sendbird_get(&q).await?;
1523        let v: serde_json::Value = self.parse_response(res).await?;
1524        if let Some(url) = v
1525            .get("channels")
1526            .and_then(|c| c.as_array())
1527            .and_then(|arr| arr.first())
1528            .and_then(|c| c.get("channel_url"))
1529            .and_then(|s| s.as_str())
1530        {
1531            return Ok(url.to_string());
1532        }
1533        let created = self
1534            .sendbird_create_distinct_dm(self_user_id, peer_user_id, 1)
1535            .await?;
1536        let url = created
1537            .get("channel_url")
1538            .and_then(|s| s.as_str())
1539            .ok_or_else(|| HingeError::Http("missing channel_url in create response".into()))?;
1540        Ok(url.to_string())
1541    }
1542
1543    pub async fn ensure_sendbird_channel_with(
1544        &mut self,
1545        peer_user_id: &str,
1546    ) -> Result<SendbirdChannelHandle, HingeError> {
1547        let self_user_id = self
1548            .hinge_auth
1549            .as_ref()
1550            .ok_or_else(|| HingeError::Auth("hinge token missing".into()))?
1551            .identity_id
1552            .clone();
1553        let channel_url = self
1554            .sendbird_get_or_create_dm_channel(&self_user_id, peer_user_id)
1555            .await?;
1556        Ok(SendbirdChannelHandle { channel_url })
1557    }
1558
1559    pub async fn sendbird_init_flow(&mut self) -> Result<serde_json::Value, HingeError> {
1560        // Health probe, user update is done by Hinge; we just list channels for the current user
1561        self.ensure_sendbird_session().await?;
1562        let user_id = self
1563            .hinge_auth
1564            .as_ref()
1565            .ok_or_else(|| HingeError::Auth("hinge token missing".into()))?
1566            .identity_id
1567            .clone();
1568        let res = self.sendbird_list_my_group_channels(&user_id, 20).await?;
1569        Ok(res)
1570    }
1571
1572    /// Return Sendbird credentials for the JS client (appId and token), ensuring auth
1573    pub async fn sendbird_creds(&mut self) -> Result<serde_json::Value, HingeError> {
1574        // Ensure we have Sendbird JWT from Hinge but do not start WS
1575        if self.sendbird_auth.is_none() {
1576            self.authenticate_with_sendbird().await?;
1577        }
1578        let app_id = self.settings.sendbird_app_id.clone();
1579        let token = self
1580            .sendbird_auth
1581            .as_ref()
1582            .map(|t| t.token.clone())
1583            .unwrap_or_default();
1584        Ok(serde_json::json!({
1585            "appId": app_id,
1586            "token": token
1587        }))
1588    }
1589
1590    // Open Sendbird WS and yield frames to a channel; also auto-respond to pings and allow READ commands
1591    pub async fn sendbird_ws_subscribe(
1592        &mut self,
1593    ) -> Result<
1594        (
1595            tokio::sync::mpsc::UnboundedSender<String>,
1596            tokio::sync::broadcast::Receiver<String>,
1597        ),
1598        HingeError,
1599    > {
1600        self.ensure_sendbird_session().await?;
1601        let cmd = self
1602            .sendbird_ws_cmd_tx
1603            .as_ref()
1604            .cloned()
1605            .ok_or_else(|| HingeError::Http("sendbird ws not started".into()))?;
1606        let tx = self
1607            .sendbird_ws_broadcast_tx
1608            .as_ref()
1609            .cloned()
1610            .ok_or_else(|| HingeError::Http("sendbird ws broadcast not available".into()))?;
1611        let rx = tx.subscribe();
1612        Ok((cmd, rx))
1613    }
1614
1615    /// Send a raw command to the Sendbird WebSocket
1616    pub async fn sendbird_ws_send_command(&mut self, command: String) -> Result<(), HingeError> {
1617        self.ensure_sendbird_session().await?;
1618        let tx = self
1619            .sendbird_ws_cmd_tx
1620            .as_ref()
1621            .cloned()
1622            .ok_or_else(|| HingeError::Http("sendbird ws not started".into()))?;
1623        tx.send(command)
1624            .map_err(|e| HingeError::Http(format!("Failed to send WS command: {}", e)))?;
1625        Ok(())
1626    }
1627
1628    /// Send a READ acknowledgment for a Sendbird channel (fire and forget)
1629    pub async fn sendbird_ws_send_read(&mut self, channel_url: &str) -> Result<(), HingeError> {
1630        let req_id = Uuid::new_v4().to_string().to_uppercase();
1631        let read_command = format!(
1632            r#"READ{{"req_id":"{}","channel_url":"{}"}}"#,
1633            req_id, channel_url
1634        );
1635        self.sendbird_ws_send_command(read_command).await
1636    }
1637
1638    /// Send a READ acknowledgment and wait for the response
1639    pub async fn sendbird_ws_send_read_and_wait(
1640        &mut self,
1641        channel_url: &str,
1642    ) -> Result<crate::models::SendbirdReadResponse, HingeError> {
1643        self.ensure_sendbird_session().await?;
1644
1645        // Generate request ID
1646        let req_id = Uuid::new_v4().to_string().to_uppercase();
1647
1648        // Create oneshot channel for response
1649        let (tx, rx) = tokio::sync::oneshot::channel();
1650
1651        // Register the pending request
1652        {
1653            let mut pending = self.sendbird_ws_pending_requests.lock().await;
1654            pending.insert(req_id.clone(), tx);
1655        }
1656
1657        // Send the READ command
1658        let read_command = format!(
1659            r#"READ{{"req_id":"{}","channel_url":"{}"}}"#,
1660            req_id, channel_url
1661        );
1662        self.sendbird_ws_send_command(read_command).await?;
1663
1664        // Wait for response with timeout
1665        match tokio::time::timeout(Duration::from_secs(5), rx).await {
1666            Ok(Ok(response)) => {
1667                // Parse the JSON response into our typed model
1668                parse_json_value_with_path(response)
1669                    .map_err(|e| HingeError::Http(format!("Failed to parse READ response: {}", e)))
1670            }
1671            Ok(Err(_)) => {
1672                // Channel was dropped, clean up
1673                let mut pending = self.sendbird_ws_pending_requests.lock().await;
1674                pending.remove(&req_id);
1675                Err(HingeError::Http("READ response channel dropped".into()))
1676            }
1677            Err(_) => {
1678                // Timeout, clean up
1679                let mut pending = self.sendbird_ws_pending_requests.lock().await;
1680                pending.remove(&req_id);
1681                Err(HingeError::Http("READ response timeout".into()))
1682            }
1683        }
1684    }
1685
1686    /// Send a PING to keep the WebSocket alive
1687    pub async fn sendbird_ws_send_ping(&mut self) -> Result<(), HingeError> {
1688        let req_id = Uuid::new_v4().to_string().to_uppercase();
1689        let ping_command = format!(r#"PING{{"req_id":"{}"}}"#, req_id);
1690        self.sendbird_ws_send_command(ping_command).await
1691    }
1692
1693    /// Send a TPST (Typing Start) command - fire and forget
1694    pub async fn sendbird_ws_send_typing_start(
1695        &mut self,
1696        channel_url: &str,
1697    ) -> Result<(), HingeError> {
1698        let timestamp = chrono::Utc::now().timestamp_millis();
1699        let tpst_command = format!(
1700            r#"TPST{{"req_id":null,"channel_url":"{}","time":{}}}"#,
1701            channel_url, timestamp
1702        );
1703        self.sendbird_ws_send_command(tpst_command).await
1704    }
1705
1706    /// Send a TPEN (Typing End) command - fire and forget
1707    pub async fn sendbird_ws_send_typing_end(
1708        &mut self,
1709        channel_url: &str,
1710    ) -> Result<(), HingeError> {
1711        let timestamp = chrono::Utc::now().timestamp_millis();
1712        let tpen_command = format!(
1713            r#"TPEN{{"req_id":null,"channel_url":"{}","time":{}}}"#,
1714            channel_url, timestamp
1715        );
1716        self.sendbird_ws_send_command(tpen_command).await
1717    }
1718
1719    /// Send an ENTR (Enter Channel) command - fire and forget
1720    pub async fn sendbird_ws_send_enter_channel(
1721        &mut self,
1722        channel_url: &str,
1723    ) -> Result<(), HingeError> {
1724        let entr_command = format!(r#"ENTR{{"req_id":null,"channel_url":"{}"}}"#, channel_url);
1725        self.sendbird_ws_send_command(entr_command).await
1726    }
1727
1728    /// Send an EXIT (Exit Channel) command - fire and forget
1729    pub async fn sendbird_ws_send_exit_channel(
1730        &mut self,
1731        channel_url: &str,
1732    ) -> Result<(), HingeError> {
1733        let exit_command = format!(r#"EXIT{{"req_id":null,"channel_url":"{}"}}"#, channel_url);
1734        self.sendbird_ws_send_command(exit_command).await
1735    }
1736
1737    /// Send a MACK (Message Acknowledgment) command - fire and forget
1738    pub async fn sendbird_ws_send_message_ack(
1739        &mut self,
1740        channel_url: &str,
1741        message_id: &str,
1742    ) -> Result<(), HingeError> {
1743        let mack_command = format!(
1744            r#"MACK{{"req_id":null,"channel_url":"{}","msg_id":"{}"}}"#,
1745            channel_url, message_id
1746        );
1747        self.sendbird_ws_send_command(mack_command).await
1748    }
1749
1750    /// Close the WebSocket connection with a specific code
1751    pub async fn sendbird_ws_close(
1752        &mut self,
1753        code: Option<u16>,
1754        reason: Option<String>,
1755    ) -> Result<(), HingeError> {
1756        // Send close frame if we have a command channel
1757        if let Some(ref tx) = self.sendbird_ws_cmd_tx {
1758            // Sendbird uses custom close codes like 40909
1759            let close_code = code.unwrap_or(1000); // 1000 = Normal Closure
1760            let close_reason = reason.unwrap_or_else(|| "Client initiated close".to_string());
1761
1762            // Send a close command through the channel
1763            // The writer task will handle converting this to a proper WebSocket Close frame
1764            let close_command = format!("__CLOSE__:{}:{}", close_code, close_reason);
1765            let _ = tx.send(close_command);
1766
1767            log::info!(
1768                "[sendbird ws] Closing connection with code {} reason: {}",
1769                close_code,
1770                close_reason
1771            );
1772        }
1773
1774        // Clear our state
1775        self.sendbird_ws_cmd_tx = None;
1776        self.sendbird_ws_broadcast_tx = None;
1777        self.sendbird_ws_connected = false;
1778
1779        // Clear any pending requests
1780        let mut pending = self.sendbird_ws_pending_requests.lock().await;
1781        pending.clear();
1782
1783        Ok(())
1784    }
1785
1786    /// Check if WebSocket is connected and reconnect if needed
1787    pub async fn sendbird_ws_ensure_connected(&mut self) -> Result<bool, HingeError> {
1788        // Check if we have an active WebSocket connection
1789        if self.sendbird_ws_cmd_tx.is_some() {
1790            // Try to send a ping to verify connection is alive
1791            if self.sendbird_ws_send_ping().await.is_ok() {
1792                return Ok(true);
1793            }
1794        }
1795
1796        // Connection is not active, clear the old state
1797        self.sendbird_ws_cmd_tx = None;
1798        self.sendbird_ws_broadcast_tx = None;
1799
1800        // Try to reconnect
1801        log::info!("[sendbird ws] Reconnecting WebSocket...");
1802        self.start_sendbird_ws().await?;
1803        Ok(true)
1804    }
1805
1806    async fn ensure_device_registered(&mut self) -> Result<(), HingeError> {
1807        if self.installed {
1808            return Ok(());
1809        }
1810        let url = format!("{}/identity/install", self.settings.base_url);
1811        let body = json!({"installId": self.install_id});
1812        let res = self
1813            .http_post(&url, &body)
1814            .await
1815            .map_err(|e| HingeError::Http(format!("Failed to register device: {}", e)))?;
1816
1817        if !res.status().is_success() {
1818            return Err(HingeError::Http(format!(
1819                "Device registration failed with status {}",
1820                res.status()
1821            )));
1822        }
1823        self.installed = true;
1824        Ok(())
1825    }
1826
1827    pub async fn initiate_login(&mut self) -> Result<(), HingeError> {
1828        self.ensure_device_registered().await?;
1829        let url = format!("{}/auth/sms/v2/initiate", self.settings.base_url);
1830        let body = json!({"deviceId": self.device_id, "phoneNumber": self.phone_number});
1831        let res = self
1832            .http_post(&url, &body)
1833            .await
1834            .map_err(|e| HingeError::Http(format!("Failed to initiate SMS login: {}", e)))?;
1835        if !res.status().is_success() {
1836            return Err(HingeError::Http(format!(
1837                "SMS initiation failed with status {}",
1838                res.status()
1839            )));
1840        }
1841        Ok(())
1842    }
1843
1844    pub async fn submit_otp(&mut self, otp: &str) -> Result<LoginTokens, HingeError> {
1845        let url = format!("{}/auth/sms/v2", self.settings.base_url);
1846        let body = json!({
1847            "installId": self.install_id,
1848            "deviceId": self.device_id,
1849            "phoneNumber": self.phone_number,
1850            "otp": otp,
1851        });
1852        let res = self.http_post(&url, &body).await?;
1853        if res.status() == reqwest::StatusCode::PRECONDITION_FAILED {
1854            let v: serde_json::Value = res.json().await?;
1855            let case_id = v
1856                .get("caseId")
1857                .and_then(|v| v.as_str())
1858                .unwrap_or("")
1859                .to_string();
1860            let email = v
1861                .get("email")
1862                .and_then(|v| v.as_str())
1863                .unwrap_or("")
1864                .to_string();
1865            return Err(HingeError::Email2FA { case_id, email });
1866        }
1867        if !res.status().is_success() {
1868            return Err(HingeError::Http(format!("status {}", res.status())));
1869        }
1870        let v = self.parse_response::<LoginTokens>(res).await?;
1871        if let Some(t) = v.hinge_auth_token.clone() {
1872            self.hinge_auth = Some(t);
1873        }
1874        if let Some(t) = v.sendbird_auth_token.clone() {
1875            self.sendbird_auth = Some(t);
1876        }
1877        Ok(v)
1878    }
1879
1880    pub async fn submit_email_code(
1881        &mut self,
1882        case_id: &str,
1883        email_code: &str,
1884    ) -> Result<LoginTokens, HingeError> {
1885        let url = format!("{}/auth/device/validate", self.settings.base_url);
1886        let body = json!({
1887            "installId": self.install_id,
1888            "code": email_code,
1889            "caseId": case_id,
1890            "deviceId": self.device_id,
1891        });
1892        let res = self.http_post(&url, &body).await?;
1893        if !res.status().is_success() {
1894            return Err(HingeError::Http(format!("status {}", res.status())));
1895        }
1896        let t = self.parse_response::<HingeAuthToken>(res).await?;
1897        self.hinge_auth = Some(t);
1898        let _ = self.authenticate_with_sendbird().await; // best-effort
1899        Ok(LoginTokens {
1900            hinge_auth_token: self.hinge_auth.clone(),
1901            sendbird_auth_token: self.sendbird_auth.clone(),
1902        })
1903    }
1904
1905    pub fn save_session(&self, path: &str) -> Result<(), HingeError> {
1906        let session = json!({
1907          "phoneNumber": self.phone_number,
1908          "deviceId": self.device_id,
1909          "installId": self.install_id,
1910          "sessionId": self.session_id,
1911          "installed": self.installed,
1912          "hingeAuth": self.hinge_auth,
1913          "sendbirdAuth": self.sendbird_auth,
1914          "sendbirdSessionKey": self.sendbird_session_key,
1915        });
1916        let data =
1917            serde_json::to_string_pretty(&session).map_err(|e| HingeError::Serde(e.to_string()))?;
1918        self.storage
1919            .write_string(path, &data)
1920            .map_err(|e| HingeError::Storage(e.to_string()))?;
1921        Ok(())
1922    }
1923
1924    pub fn load_session(&mut self, path: &str) -> Result<(), HingeError> {
1925        if !self.storage.exists(path) {
1926            return Ok(());
1927        }
1928        let data = self
1929            .storage
1930            .read_to_string(path)
1931            .map_err(|e| HingeError::Storage(e.to_string()))?;
1932        let v: serde_json::Value =
1933            serde_json::from_str(&data).map_err(|e| HingeError::Serde(e.to_string()))?;
1934        if let Some(s) = v.get("phoneNumber").and_then(|v| v.as_str()) {
1935            self.phone_number = s.to_string();
1936        }
1937        if let Some(s) = v.get("deviceId").and_then(|v| v.as_str()) {
1938            self.device_id = s.to_string();
1939        }
1940        if let Some(s) = v.get("installId").and_then(|v| v.as_str()) {
1941            self.install_id = s.to_string();
1942        }
1943        if let Some(s) = v.get("sessionId").and_then(|v| v.as_str()) {
1944            self.session_id = s.to_string();
1945        }
1946        if let Some(b) = v.get("installed").and_then(|v| v.as_bool()) {
1947            self.installed = b;
1948        }
1949        if let Some(t) = v.get("hingeAuth").cloned() {
1950            self.hinge_auth = serde_json::from_value(t).ok();
1951        }
1952        if let Some(t) = v.get("sendbirdAuth").cloned() {
1953            self.sendbird_auth = serde_json::from_value(t).ok();
1954        }
1955        if let Some(k) = v.get("sendbirdSessionKey").and_then(|v| v.as_str()) {
1956            self.sendbird_session_key = Some(k.to_string());
1957        }
1958        Ok(())
1959    }
1960
1961    pub fn load_tokens_secure(&mut self) -> Result<(), HingeError> {
1962        if let Some(store) = &self.secret_store {
1963            if let Some(v) = store
1964                .get_secret("hinge_auth")
1965                .map_err(|e| HingeError::Storage(e.to_string()))?
1966            {
1967                self.hinge_auth = serde_json::from_str(&v).ok();
1968            }
1969            if let Some(v) = store
1970                .get_secret("sendbird_auth")
1971                .map_err(|e| HingeError::Storage(e.to_string()))?
1972            {
1973                self.sendbird_auth = serde_json::from_str(&v).ok();
1974            }
1975        }
1976        Ok(())
1977    }
1978
1979    pub fn with_persistence(
1980        mut self,
1981        session_path: Option<String>,
1982        cache_dir: Option<PathBuf>,
1983        auto_persist: bool,
1984    ) -> Self {
1985        self.session_path = session_path;
1986        self.cache_dir = cache_dir;
1987        self.auto_persist = auto_persist;
1988        if let Some(path) = self.session_path.clone() {
1989            let _ = self.load_session(&path);
1990        }
1991        if let Some(dir) = &self.cache_dir {
1992            let rec_path = dir.join(format!("recommendations_{}.json", self.session_id));
1993            let _ = self.load_recommendations(rec_path.to_string_lossy().as_ref());
1994        }
1995        self
1996    }
1997
1998    fn recs_cache_path(&self) -> Option<String> {
1999        self.cache_dir.as_ref().map(|d| {
2000            d.join(format!("recommendations_{}.json", self.session_id))
2001                .to_string_lossy()
2002                .into_owned()
2003        })
2004    }
2005
2006    fn prompts_cache_path(&self) -> Option<String> {
2007        self.cache_dir
2008            .as_ref()
2009            .map(|d| d.join("prompts_cache.json").to_string_lossy().into_owned())
2010    }
2011
2012    pub async fn fetch_prompts(&mut self) -> Result<PromptsResponse, HingeError> {
2013        if self.auto_persist
2014            && let Some(path) = self.prompts_cache_path()
2015            && Path::new(&path).exists()
2016            && let Ok(text) = std::fs::read_to_string(&path)
2017            && let Ok(val) = serde_json::from_str::<PromptsResponse>(&text)
2018        {
2019            return Ok(val);
2020        }
2021        let url = format!("{}/prompts", self.settings.base_url);
2022        let payload = self.prompt_payload().await;
2023        let res = self.http_post(&url, &payload).await?;
2024        let body = self.parse_response::<PromptsResponse>(res).await?;
2025        if self.auto_persist
2026            && let Some(path) = self.prompts_cache_path()
2027        {
2028            let _ = std::fs::write(
2029                &path,
2030                serde_json::to_string_pretty(&body).unwrap_or("{}".into()),
2031            );
2032        }
2033        Ok(body)
2034    }
2035
2036    pub async fn fetch_prompts_manager(&mut self) -> Result<HingePromptsManager, HingeError> {
2037        let resp = self.fetch_prompts().await?;
2038        Ok(HingePromptsManager::new(resp))
2039    }
2040
2041    pub async fn get_prompt_text(&mut self, prompt_id: &str) -> Result<String, HingeError> {
2042        let mgr = self.fetch_prompts_manager().await?;
2043        Ok(mgr.get_prompt_display_text(prompt_id))
2044    }
2045
2046    pub async fn search_prompts(&mut self, query: &str) -> Result<Vec<Prompt>, HingeError> {
2047        let mgr = self.fetch_prompts_manager().await?;
2048        let items = mgr.search_prompts(query);
2049        Ok(items.into_iter().cloned().collect())
2050    }
2051
2052    pub async fn get_prompts_by_category(
2053        &mut self,
2054        category_slug: &str,
2055    ) -> Result<Vec<Prompt>, HingeError> {
2056        let mgr = self.fetch_prompts_manager().await?;
2057        let items = mgr.get_prompts_by_category(category_slug);
2058        Ok(items.into_iter().cloned().collect())
2059    }
2060
2061    pub async fn get_recommendations(&mut self) -> Result<RecommendationsResponse, HingeError> {
2062        self.get_recommendations_v2_params(crate::models::RecsV2Params {
2063            new_here: false,
2064            active_today: false,
2065        })
2066        .await
2067    }
2068
2069    pub async fn get_recommendations_v2_params(
2070        &mut self,
2071        params: crate::models::RecsV2Params,
2072    ) -> Result<RecommendationsResponse, HingeError> {
2073        let url = format!("{}/rec/v2", self.settings.base_url);
2074        let identity_id = self
2075            .hinge_auth
2076            .as_ref()
2077            .ok_or_else(|| HingeError::Auth("hinge token missing".into()))?
2078            .identity_id
2079            .clone();
2080
2081        use serde::Serialize;
2082        #[derive(Serialize)]
2083        #[serde(rename_all = "camelCase")]
2084        struct Body {
2085            player_id: String,
2086            new_here: bool,
2087            active_today: bool,
2088        }
2089
2090        let body = Body {
2091            player_id: identity_id,
2092            new_here: params.new_here,
2093            active_today: params.active_today,
2094        };
2095
2096        let body_json =
2097            serde_json::to_value(&body).map_err(|e| HingeError::Serde(e.to_string()))?;
2098
2099        let fetch_count = self.recs_fetch_config.multi_fetch_count.max(1);
2100        let min_delay = Duration::from_millis(self.recs_fetch_config.request_delay_ms);
2101        let mut aggregated: Option<RecommendationsResponse> = None;
2102        let mut completed_calls = 0usize;
2103        let mut rate_limit_attempts = 0usize;
2104        let max_rate_limit_retries = self.recs_fetch_config.rate_limit_retries;
2105        let base_backoff_ms = self.recs_fetch_config.rate_limit_backoff_ms.max(1);
2106
2107        while completed_calls < fetch_count {
2108            if let Some(last_call) = self.last_recs_v2_call {
2109                let elapsed = last_call.elapsed();
2110                if elapsed < min_delay {
2111                    sleep(min_delay - elapsed).await;
2112                }
2113            }
2114
2115            let res = self.http_post(&url, &body_json).await?;
2116            self.last_recs_v2_call = Some(Instant::now());
2117
2118            let status = res.status();
2119            if status == StatusCode::TOO_MANY_REQUESTS || status == StatusCode::SERVICE_UNAVAILABLE
2120            {
2121                rate_limit_attempts += 1;
2122                if rate_limit_attempts > max_rate_limit_retries {
2123                    log::warn!(
2124                        "[rec/v2] rate limited after {} retries; returning aggregated data",
2125                        rate_limit_attempts
2126                    );
2127                    break;
2128                }
2129                let exponent = rate_limit_attempts.saturating_sub(1) as u32;
2130                let factor = 1u64
2131                    .checked_shl(exponent)
2132                    .filter(|&v| v > 0)
2133                    .unwrap_or(u64::MAX);
2134                let backoff = base_backoff_ms.saturating_mul(factor);
2135                log::warn!(
2136                    "[rec/v2] rate limited (status {}). backing off {} ms before retry (attempt {}/{})",
2137                    status,
2138                    backoff,
2139                    rate_limit_attempts,
2140                    max_rate_limit_retries
2141                );
2142                sleep(Duration::from_millis(backoff)).await;
2143                continue;
2144            }
2145
2146            rate_limit_attempts = 0;
2147
2148            let response = self.parse_response::<RecommendationsResponse>(res).await?;
2149            if let Some(existing) = aggregated.as_mut() {
2150                merge_recommendation_responses(existing, response);
2151            } else {
2152                aggregated = Some(response);
2153            }
2154
2155            completed_calls += 1;
2156        }
2157
2158        let mut out = aggregated.unwrap_or_else(|| RecommendationsResponse {
2159            feeds: Vec::new(),
2160            active_pills: None,
2161            cache_control: None,
2162        });
2163
2164        normalize_recommendations_response(&mut out);
2165
2166        if self.auto_persist {
2167            match self.recs_cache_path() {
2168                Some(path) => {
2169                    let _ = self.apply_recommendations_and_save(&mut out, Some(&path));
2170                }
2171                None => {
2172                    let _ = self.apply_recommendations_and_save(&mut out, None);
2173                }
2174            }
2175        }
2176        Ok(out)
2177    }
2178
2179    pub fn apply_recommendations_and_save(
2180        &mut self,
2181        recs: &mut RecommendationsResponse,
2182        path: Option<&str>,
2183    ) -> Result<(), HingeError> {
2184        for feed in &mut recs.feeds {
2185            for subj in &mut feed.subjects {
2186                if subj.origin.is_none() {
2187                    subj.origin = Some(feed.origin.clone());
2188                }
2189                if !self.recommendations.contains_key(&subj.subject_id) {
2190                    self.recommendations
2191                        .insert(subj.subject_id.clone(), subj.clone());
2192                }
2193            }
2194        }
2195        if let Some(p) = path {
2196            self.save_recommendations(p)?;
2197        }
2198        Ok(())
2199    }
2200
2201    pub async fn get_self_profile(&self) -> Result<SelfProfileResponse, HingeError> {
2202        let url = format!("{}/user/v3", self.settings.base_url);
2203        let res = self.http_get(&url).await?;
2204        self.parse_response::<SelfProfileResponse>(res).await
2205    }
2206
2207    pub async fn get_self_content(&self) -> Result<SelfContentResponse, HingeError> {
2208        let url = format!("{}/content/v2", self.settings.base_url);
2209        let res = self.http_get(&url).await?;
2210        self.parse_response::<SelfContentResponse>(res).await
2211    }
2212
2213    pub async fn get_self_preferences(&self) -> Result<PreferencesResponse, HingeError> {
2214        let url = format!("{}/preference/v2/selected", self.settings.base_url);
2215        let res = self.http_get(&url).await?;
2216        self.parse_response::<PreferencesResponse>(res).await
2217    }
2218
2219    pub async fn get_like_limit(&self) -> Result<LikeLimit, HingeError> {
2220        let url = format!("{}/likelimit", self.settings.base_url);
2221        let res = self
2222            .http
2223            .get(url)
2224            .headers(self.default_headers()?)
2225            .send()
2226            .await?;
2227        if !res.status().is_success() {
2228            return Err(HingeError::Http(format!("status {}", res.status())));
2229        }
2230        self.parse_response(res).await
2231    }
2232
2233    pub async fn get_likes_v2(&self) -> Result<LikesV2Response, HingeError> {
2234        let url = format!("{}/like/v2", self.settings.base_url);
2235        let res = self.http_get(&url).await?;
2236        self.parse_response(res).await
2237    }
2238
2239    pub async fn get_like_subject(
2240        &self,
2241        subject_id: &str,
2242    ) -> Result<crate::models::LikeItemV2, HingeError> {
2243        let url = format!("{}/like/subject/{}", self.settings.base_url, subject_id);
2244        let res = self.http_get(&url).await?;
2245        self.parse_response(res).await
2246    }
2247
2248    pub async fn get_match_note(&self, subject_id: &str) -> Result<MatchNoteResponse, HingeError> {
2249        let url = format!(
2250            "{}/connection/v2/matchnote/{}",
2251            self.settings.base_url, subject_id
2252        );
2253        let res = self.http_get(&url).await?;
2254        self.parse_response(res).await
2255    }
2256
2257    /// Fetch the raw JSON response for likes v2 without mapping to typed structs
2258    pub async fn get_likes_v2_raw(&self) -> Result<serde_json::Value, HingeError> {
2259        let url = format!("{}/like/v2", self.settings.base_url);
2260        let res = self.http_get(&url).await?;
2261        let status = res.status();
2262        let headers = res.headers().clone();
2263        let text = res
2264            .text()
2265            .await
2266            .map_err(|e| HingeError::Http(e.to_string()))?;
2267        if !status.is_success() {
2268            log::error!("HTTP Error {}: {}", status, text);
2269            return Err(HingeError::Http(format!("status {}: {}", status, text)));
2270        }
2271        let val: serde_json::Value =
2272            serde_json::from_str(&text).map_err(|e| HingeError::Serde(e.to_string()))?;
2273        log_response(status, &headers, Some(&val));
2274        Ok(val)
2275    }
2276
2277    /// Raw, unfiltered request to user/v3/public for explicit ids
2278    pub async fn get_profiles_public_raw_unfiltered(
2279        &self,
2280        ids: Vec<String>,
2281    ) -> Result<serde_json::Value, HingeError> {
2282        let url = format!(
2283            "{}/user/v3/public?ids={}",
2284            self.settings.base_url,
2285            ids.join(",")
2286        );
2287        let res = self.http_get(&url).await?;
2288        let status = res.status();
2289        let headers = res.headers().clone();
2290        let text = res
2291            .text()
2292            .await
2293            .map_err(|e| HingeError::Http(e.to_string()))?;
2294        if !status.is_success() {
2295            log::error!("HTTP Error {}: {}", status, text);
2296            return Err(HingeError::Http(format!("status {}: {}", status, text)));
2297        }
2298        let val: serde_json::Value =
2299            serde_json::from_str(&text).map_err(|e| HingeError::Serde(e.to_string()))?;
2300        log_response(status, &headers, Some(&val));
2301        Ok(val)
2302    }
2303
2304    /// Raw, unfiltered request to content/v2/public for explicit ids
2305    pub async fn get_content_public_raw_unfiltered(
2306        &self,
2307        ids: Vec<String>,
2308    ) -> Result<serde_json::Value, HingeError> {
2309        let url = format!(
2310            "{}/content/v2/public?ids={}",
2311            self.settings.base_url,
2312            ids.join(",")
2313        );
2314        let res = self.http_get(&url).await?;
2315        let status = res.status();
2316        let headers = res.headers().clone();
2317        let text = res
2318            .text()
2319            .await
2320            .map_err(|e| HingeError::Http(e.to_string()))?;
2321        if !status.is_success() {
2322            log::error!("HTTP Error {}: {}", status, text);
2323            return Err(HingeError::Http(format!("status {}: {}", status, text)));
2324        }
2325        let val: serde_json::Value =
2326            serde_json::from_str(&text).map_err(|e| HingeError::Serde(e.to_string()))?;
2327        log_response(status, &headers, Some(&val));
2328        Ok(val)
2329    }
2330
2331    pub async fn get_profiles(
2332        &self,
2333        user_ids: Vec<String>,
2334    ) -> Result<Vec<PublicUserProfile>, HingeError> {
2335        let chunks = self.prepare_user_id_chunks(user_ids);
2336        if chunks.is_empty() {
2337            return Ok(Vec::new());
2338        }
2339        let mut aggregated: Vec<PublicUserProfile> = Vec::new();
2340        for batch in chunks {
2341            let url = format!(
2342                "{}/user/v3/public?ids={}",
2343                self.settings.base_url,
2344                batch.join(",")
2345            );
2346            let res = self.http_get(&url).await?;
2347            let mut part: Vec<PublicUserProfile> = self.parse_response(res).await?;
2348            aggregated.append(&mut part);
2349        }
2350        Ok(aggregated)
2351    }
2352
2353    pub async fn get_profile_content(
2354        &self,
2355        user_ids: Vec<String>,
2356    ) -> Result<Vec<ProfileContentFull>, HingeError> {
2357        let chunks = self.prepare_user_id_chunks(user_ids);
2358        if chunks.is_empty() {
2359            return Ok(Vec::new());
2360        }
2361        let mut aggregated: Vec<ProfileContentFull> = Vec::new();
2362        for batch in chunks {
2363            let url = format!(
2364                "{}/content/v2/public?ids={}",
2365                self.settings.base_url,
2366                batch.join(",")
2367            );
2368            let res = self.http_get(&url).await?;
2369            let mut part: Vec<ProfileContentFull> = self.parse_response(res).await?;
2370            aggregated.append(&mut part);
2371        }
2372        Ok(aggregated)
2373    }
2374
2375    // Removed non-DTO rating/skip methods to standardize on DTO-based API
2376
2377    pub async fn skip(&mut self, input: SkipInput) -> Result<serde_json::Value, HingeError> {
2378        let url = format!("{}/rate/v2/initiate", self.settings.base_url);
2379        let payload = CreateRate {
2380            rating_id: Uuid::new_v4().to_string().to_uppercase(),
2381            hcm_run_id: None,
2382            session_id: self.session_id.clone(),
2383            // Explicitly set None; it will be omitted during serialization
2384            content: None,
2385            created: Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(),
2386            rating_token: input.rating_token,
2387            initiated_with: None,
2388            rating: "skip".into(),
2389            has_pairing: false,
2390            origin: Some(input.origin.unwrap_or_else(|| "compatibles".into())),
2391            subject_id: input.subject_id.clone(),
2392        };
2393        let res = self
2394            .http_post(&url, &serde_json::to_value(&payload).unwrap())
2395            .await?;
2396        if !res.status().is_success() {
2397            return Err(HingeError::Http(format!("status {}", res.status())));
2398        }
2399        let body = res.json::<serde_json::Value>().await?;
2400        self.remove_recommendation(&input.subject_id);
2401        if self.auto_persist
2402            && let Some(path) = self.recs_cache_path()
2403        {
2404            let _ = self.save_recommendations(&path);
2405        }
2406        Ok(body)
2407    }
2408
2409    pub async fn rate_user(&mut self, input: RateInput) -> Result<LikeResponse, HingeError> {
2410        let mut hcm_run_id: Option<String> = None;
2411        if let Some(text) = input.comment.as_deref() {
2412            let run_id = self.run_text_review(text, &input.subject_id).await?;
2413            hcm_run_id = Some(run_id);
2414        }
2415        let prompt_answer = input.answer_text.clone().unwrap_or_default();
2416        let prompt_question = input.question_text.clone().unwrap_or_default();
2417        let prompt_content_id = input.content_id.clone();
2418
2419        let content = if let Some(photo) = input.photo {
2420            let PhotoAssetInput {
2421                url,
2422                content_id,
2423                cdn_id,
2424                bounding_box,
2425                selfie_verified,
2426            } = photo;
2427            Some(CreateRateContent {
2428                comment: input.comment.clone(),
2429                photo: Some(PhotoAsset {
2430                    id: None,
2431                    url,
2432                    cdn_id,
2433                    content_id,
2434                    prompt_id: None,
2435                    caption: None,
2436                    width: None,
2437                    height: None,
2438                    video_url: None,
2439                    selfie_verified,
2440                    bounding_box,
2441                    location: None,
2442                    source: None,
2443                    source_id: None,
2444                    p_hash: None,
2445                }),
2446                prompt: None,
2447            })
2448        } else {
2449            let prompt = CreateRateContentPrompt {
2450                answer: prompt_answer,
2451                content_id: prompt_content_id,
2452                question: prompt_question,
2453            };
2454            Some(CreateRateContent {
2455                comment: input.comment.clone(),
2456                photo: None,
2457                prompt: Some(prompt),
2458            })
2459        };
2460        let payload = CreateRate {
2461            rating_id: Uuid::new_v4().to_string().to_uppercase(),
2462            hcm_run_id,
2463            session_id: self.session_id.clone(),
2464            content,
2465            created: Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(),
2466            rating_token: input.rating_token,
2467            initiated_with: Some(if input.use_superlike.unwrap_or(false) {
2468                "superlike".into()
2469            } else {
2470                "standard".into()
2471            }),
2472            rating: if input.comment.is_some() {
2473                "note".into()
2474            } else {
2475                "like".into()
2476            },
2477            has_pairing: false,
2478            origin: Some(input.origin.unwrap_or_else(|| "compatibles".into())),
2479            subject_id: input.subject_id,
2480        };
2481        let url = format!("{}/rate/v2/initiate", self.settings.base_url);
2482        let res = self
2483            .http_post(&url, &serde_json::to_value(&payload).unwrap())
2484            .await?;
2485        if !res.status().is_success() {
2486            return Err(HingeError::Http(format!("status {}", res.status())));
2487        }
2488        let body = self.parse_response::<LikeResponse>(res).await?;
2489        if self.auto_persist
2490            && let Some(path) = self.recs_cache_path()
2491        {
2492            let _ = self.save_recommendations(&path);
2493        }
2494        Ok(body)
2495    }
2496
2497    pub async fn respond_rate(
2498        &self,
2499        mut payload: RateRespondRequest,
2500    ) -> Result<RateRespondResponse, HingeError> {
2501        // Generate rating_id if not provided
2502        if payload.rating_id.is_none() {
2503            payload.rating_id = Some(Uuid::new_v4().to_string().to_uppercase());
2504        }
2505
2506        // Use client session_id if not provided
2507        if payload.session_id.is_none() {
2508            payload.session_id = Some(self.session_id.clone());
2509        }
2510
2511        // Generate created timestamp if not provided
2512        if payload.created.is_none() {
2513            payload.created = Some(Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string());
2514        }
2515
2516        let url = format!("{}/rate/v2/respond", self.settings.base_url);
2517        let body = serde_json::to_value(&payload).map_err(|e| HingeError::Serde(e.to_string()))?;
2518        let res = self.http_post(&url, &body).await?;
2519        self.parse_response(res).await
2520    }
2521
2522    pub async fn update_self_preferences(
2523        &self,
2524        preferences: Preferences,
2525    ) -> Result<serde_json::Value, HingeError> {
2526        let url = format!("{}/preference/v2/selected", self.settings.base_url);
2527
2528        // Convert to API format with numeric enums using type-specific converter
2529        let prefs_json = preferences_to_api_json(&preferences);
2530        let payload = serde_json::json!([prefs_json]);
2531
2532        let res = self.http_patch(&url, &payload).await?;
2533        if !res.status().is_success() {
2534            return Err(HingeError::Http(format!("status {}", res.status())));
2535        }
2536        let body = res.json::<serde_json::Value>().await?;
2537        Ok(body)
2538    }
2539
2540    pub async fn update_self_profile(
2541        &self,
2542        profile_updates: ProfileUpdate,
2543    ) -> Result<serde_json::Value, HingeError> {
2544        let url = format!("{}/user/v3", self.settings.base_url);
2545
2546        // Convert to API format with numeric enums using type-specific converter
2547        let profile_json = profile_update_to_api_json(&profile_updates);
2548        let payload = serde_json::json!({ "profile": profile_json });
2549
2550        let res = self.http_patch(&url, &payload).await?;
2551        if !res.status().is_success() {
2552            return Err(HingeError::Http(format!("status {}", res.status())));
2553        }
2554        let body = res.json::<serde_json::Value>().await?;
2555        Ok(body)
2556    }
2557
2558    pub async fn update_answers(
2559        &self,
2560        answers: Vec<AnswerContentPayload>,
2561    ) -> Result<serde_json::Value, HingeError> {
2562        let url = format!("{}/content/v1/answers", self.settings.base_url);
2563        let res = self
2564            .http
2565            .put(url)
2566            .headers(self.default_headers()?)
2567            .json(&answers)
2568            .send()
2569            .await?;
2570        if !res.status().is_success() {
2571            return Err(HingeError::Http(format!("status {}", res.status())));
2572        }
2573        let body = res.json::<serde_json::Value>().await?;
2574        Ok(body)
2575    }
2576
2577    pub async fn repeat_profiles(&mut self) -> Result<serde_json::Value, HingeError> {
2578        let url = format!("{}/user/repeat", self.settings.base_url);
2579        let res = self
2580            .http
2581            .get(url)
2582            .headers(self.default_headers()?)
2583            .send()
2584            .await?;
2585        if !res.status().is_success() {
2586            return Err(HingeError::Http(format!("status {}", res.status())));
2587        }
2588        let body = res.json::<serde_json::Value>().await?;
2589        if self.auto_persist
2590            && let Some(path) = self.recs_cache_path()
2591        {
2592            let _ = self.save_recommendations(&path);
2593        }
2594        Ok(body)
2595    }
2596
2597    async fn authenticate_with_sendbird(&mut self) -> Result<(), HingeError> {
2598        let _hinge = self
2599            .hinge_auth
2600            .as_ref()
2601            .ok_or_else(|| HingeError::Auth("hinge token missing".into()))?;
2602        let url = format!("{}/message/authenticate", self.settings.base_url);
2603        let res = self
2604            .http
2605            .post(url)
2606            .headers(self.default_headers()?)
2607            .json(&json!({"refresh": false}))
2608            .send()
2609            .await?;
2610        if !res.status().is_success() {
2611            return Err(HingeError::Http(format!("status {}", res.status())));
2612        }
2613        let v = self.parse_response::<SendbirdAuthToken>(res).await?;
2614        self.sendbird_auth = Some(v);
2615        // Session key capture is handled in ensure_sendbird_session via WS handshake.
2616        if self.auto_persist
2617            && let Some(path) = &self.session_path
2618        {
2619            let _ = self.save_session(path);
2620        }
2621        Ok(())
2622    }
2623
2624    async fn run_text_review(&self, text: &str, receiver_id: &str) -> Result<String, HingeError> {
2625        let url = format!("{}/flag/textreview", self.settings.base_url);
2626        let res = self
2627            .http
2628            .post(url)
2629            .headers(self.default_headers()?)
2630            .json(&json!({ "text": text, "receiverId": receiver_id }))
2631            .send()
2632            .await?;
2633        if !res.status().is_success() {
2634            return Err(HingeError::Http(format!("status {}", res.status())));
2635        }
2636        let v = res.json::<serde_json::Value>().await?;
2637        let run_id = v
2638            .get("hcmRunId")
2639            .and_then(|v| v.as_str())
2640            .unwrap_or("")
2641            .to_string();
2642        Ok(run_id)
2643    }
2644
2645    pub async fn is_session_valid(&mut self) -> Result<bool, HingeError> {
2646        // Check if Hinge token exists
2647        if self.hinge_auth.is_none() {
2648            log::warn!("Hinge token is empty, session is invalid.");
2649            return Ok(false);
2650        }
2651
2652        // Check if Sendbird token exists, try to authenticate if not
2653        if self.sendbird_auth.is_none() {
2654            log::warn!("Sendbird JWT is empty, reauthenticating...");
2655            if let Err(e) = self.authenticate_with_sendbird().await {
2656                log::error!("Failed to reauthenticate with Sendbird: {}", e);
2657                return Ok(false);
2658            }
2659        }
2660
2661        let now = Utc::now();
2662
2663        // Check Hinge token validity
2664        let hinge_token_valid = if let Some(hinge_auth) = &self.hinge_auth {
2665            hinge_auth.expires > now
2666        } else {
2667            false
2668        };
2669
2670        // Check Sendbird token validity and re-authenticate if expired
2671        let sendbird_needs_refresh = if let Some(sb_auth) = &self.sendbird_auth {
2672            sb_auth.expires <= now
2673        } else {
2674            true
2675        };
2676
2677        if sendbird_needs_refresh {
2678            log::warn!("Sendbird JWT has expired or is missing, reauthenticating...");
2679            if let Err(e) = self.authenticate_with_sendbird().await {
2680                log::error!("Failed to reauthenticate with Sendbird: {}", e);
2681                return Ok(false);
2682            }
2683        }
2684
2685        // Re-check Sendbird validity after potential re-authentication
2686        let sendbird_token_valid = if let Some(sb_auth) = &self.sendbird_auth {
2687            sb_auth.expires > now
2688        } else {
2689            false
2690        };
2691
2692        let is_valid = hinge_token_valid && sendbird_token_valid;
2693        log::info!(
2694            "Session validity check: is_valid={}, hinge_token_valid={}, sendbird_token_valid={}",
2695            is_valid,
2696            hinge_token_valid,
2697            sendbird_token_valid
2698        );
2699
2700        Ok(is_valid)
2701    }
2702
2703    pub fn save_recommendations(&self, path: &str) -> Result<(), HingeError> {
2704        let data = serde_json::to_string_pretty(&self.recommendations)
2705            .map_err(|e| HingeError::Serde(e.to_string()))?;
2706        self.storage
2707            .write_string(path, &data)
2708            .map_err(|e| HingeError::Storage(e.to_string()))?;
2709        Ok(())
2710    }
2711
2712    pub fn load_recommendations(&mut self, path: &str) -> Result<(), HingeError> {
2713        if !self.storage.exists(path) {
2714            return Ok(());
2715        }
2716        let data = self
2717            .storage
2718            .read_to_string(path)
2719            .map_err(|e| HingeError::Storage(e.to_string()))?;
2720        self.recommendations =
2721            serde_json::from_str(&data).map_err(|e| HingeError::Serde(e.to_string()))?;
2722        Ok(())
2723    }
2724
2725    pub fn remove_recommendation(&mut self, subject_id: &str) {
2726        self.recommendations.remove(subject_id);
2727    }
2728
2729    pub async fn prompt_payload(&mut self) -> serde_json::Value {
2730        // Ported from Python client
2731        if !self.is_session_valid().await.unwrap_or(false) {
2732            return json!({});
2733        }
2734        let preferences = match self.get_self_preferences().await {
2735            Ok(v) => v,
2736            Err(_) => return json!({}),
2737        };
2738        let profile = match self.get_self_profile().await {
2739            Ok(v) => v,
2740            Err(_) => return json!({}),
2741        };
2742        let mut preferences_dict = serde_json::to_value(&preferences).unwrap_or(json!({}));
2743        let profile_dict = serde_json::to_value(&profile).unwrap_or(json!({}));
2744
2745        let selected: Vec<String> = preferences_dict
2746            .get("preferences")
2747            .and_then(|p| p.get("genderPreferences"))
2748            .and_then(|v| v.as_array())
2749            .map(|arr| {
2750                arr.iter()
2751                    .filter_map(|x| x.as_u64().map(|n| n.to_string()))
2752                    .collect()
2753            })
2754            .unwrap_or_default();
2755
2756        let keep_selected = |mut d: serde_json::Value| {
2757            if let serde_json::Value::Object(map) = &mut d
2758                && !selected.is_empty()
2759            {
2760                map.retain(|k, _| selected.contains(k));
2761            }
2762            d
2763        };
2764
2765        if let Some(obj) = preferences_dict
2766            .get_mut("preferences")
2767            .and_then(|p| p.get_mut("genderedHeightRanges"))
2768        {
2769            *obj = keep_selected(obj.clone());
2770        }
2771        if let Some(obj) = preferences_dict
2772            .get_mut("preferences")
2773            .and_then(|p| p.get_mut("genderedAgeRanges"))
2774        {
2775            *obj = keep_selected(obj.clone());
2776        }
2777
2778        if let Some(db) = preferences_dict
2779            .get_mut("preferences")
2780            .and_then(|p| p.get_mut("dealbreakers"))
2781        {
2782            if let Some(obj) = db.get_mut("genderedHeight") {
2783                *obj = keep_selected(obj.clone());
2784            }
2785            if let Some(obj) = db.get_mut("genderedAge") {
2786                *obj = keep_selected(obj.clone());
2787            }
2788        }
2789
2790        fn unwrap_visible(obj: &serde_json::Value) -> serde_json::Value {
2791            match obj {
2792                serde_json::Value::Object(m) => {
2793                    if m.contains_key("value") && m.contains_key("visible") {
2794                        unwrap_visible(&m["value"])
2795                    } else {
2796                        let mut out = serde_json::Map::new();
2797                        for (k, v) in m.iter() {
2798                            out.insert(k.clone(), unwrap_visible(v));
2799                        }
2800                        serde_json::Value::Object(out)
2801                    }
2802                }
2803                serde_json::Value::Array(arr) => {
2804                    serde_json::Value::Array(arr.iter().map(unwrap_visible).collect())
2805                }
2806                _ => obj.clone(),
2807            }
2808        }
2809
2810        let p = profile_dict
2811            .get("content")
2812            .map(unwrap_visible)
2813            .unwrap_or(json!({}));
2814        let loc_name = profile_dict
2815            .get("content")
2816            .and_then(|c| c.get("location"))
2817            .and_then(|l| l.get("name"))
2818            .cloned()
2819            .unwrap_or(json!(null));
2820
2821        let profile_payload = json!({
2822          "works": match p.get("works") { Some(v) if v.is_string() => json!([v]), _ => p.get("works").cloned().unwrap_or(json!([])) },
2823          "sexualOrientations": p.get("sexualOrientations").cloned().unwrap_or(json!([])),
2824          "didJustJoin": false,
2825          "smoking": p.get("smoking").cloned().unwrap_or(json!(null)),
2826          "selfieVerified": p.get("selfieVerified").cloned().unwrap_or(json!(false)),
2827          "politics": p.get("politics").cloned().unwrap_or(json!(null)),
2828          "relationshipTypesText": p.get("relationshipTypesText").cloned().unwrap_or(json!("")),
2829          "datingIntention": p.get("datingIntention").cloned().unwrap_or(json!(null)),
2830          "height": p.get("height").cloned().unwrap_or(json!(null)),
2831          "children": p.get("children").cloned().unwrap_or(json!(null)),
2832          "religions": p.get("religions").cloned().unwrap_or(json!([])),
2833          "relationshipTypes": p.get("relationshipTypeIds").cloned().unwrap_or(json!([])),
2834          "educations": p.get("educations").cloned().unwrap_or(json!([])),
2835          "age": p.get("age").cloned().unwrap_or(json!(null)),
2836          "jobTitle": p.get("jobTitle").cloned().unwrap_or(json!(null)),
2837          "birthday": p.get("birthday").cloned().unwrap_or(json!(null)),
2838          "drugs": p.get("drugs").cloned().unwrap_or(json!(null)),
2839          "content": json!({}),
2840          "hometown": p.get("hometown").cloned().unwrap_or(json!(null)),
2841          "firstName": p.get("firstName").cloned().unwrap_or(json!(null)),
2842          "familyPlans": p.get("familyPlans").cloned().unwrap_or(json!(null)),
2843          "location": json!({"name": loc_name}),
2844          "marijuana": p.get("marijuana").cloned().unwrap_or(json!(null)),
2845          "pets": p.get("pets").cloned().unwrap_or(json!([])),
2846          "datingIntentionText": p.get("datingIntentionText").cloned().unwrap_or(json!("")),
2847          "educationAttained": p.get("educationAttained").cloned().unwrap_or(json!(null)),
2848          "ethnicities": p.get("ethnicities").cloned().unwrap_or(json!([])),
2849          "pronouns": p.get("pronouns").cloned().unwrap_or(json!([])),
2850          "languagesSpoken": p.get("languagesSpoken").cloned().unwrap_or(json!([])),
2851          "lastName": p.get("lastName").cloned().unwrap_or(json!("")),
2852          "ethnicitiesText": p.get("ethnicitiesText").cloned().unwrap_or(json!("")),
2853          "drinking": p.get("drinking").cloned().unwrap_or(json!(null)),
2854          "userId": profile_dict.get("userId").cloned().unwrap_or(json!(null)),
2855          "genderIdentityId": p.get("genderIdentityId").cloned().unwrap_or(json!(null)),
2856        });
2857
2858        json!({
2859          "preferences": preferences_dict.get("preferences").cloned().unwrap_or(json!({})),
2860          "profile": profile_payload
2861        })
2862    }
2863
2864    pub async fn send_message(
2865        &self,
2866        mut payload: crate::models::SendMessagePayload,
2867    ) -> Result<serde_json::Value, HingeError> {
2868        // Ensure Sendbird DM channel exists for this subject before sending via Hinge
2869        // We clone a mutable self to call sendbird helpers (since self is &self here); alternatively make self &mut.
2870        let mut cloned = self.clone();
2871        let self_user_id = cloned
2872            .hinge_auth
2873            .as_ref()
2874            .ok_or_else(|| HingeError::Auth("hinge token missing".into()))?
2875            .identity_id
2876            .clone();
2877        let _channel_url = cloned
2878            .sendbird_get_or_create_dm_channel(&self_user_id, &payload.subject_id)
2879            .await
2880            .unwrap_or_else(|e| {
2881                log::warn!("sendbird get-or-create failed before send: {}", e);
2882                String::new()
2883            });
2884        // Generate dedupId if not provided
2885        if payload.dedup_id.is_none() {
2886            payload.dedup_id = Some(Uuid::new_v4().to_string().to_uppercase());
2887        }
2888
2889        let url = format!("{}/message/send", self.settings.base_url);
2890        let body = serde_json::to_value(&payload).map_err(|e| HingeError::Serde(e.to_string()))?;
2891        let res = self.http_post(&url, &body).await?;
2892        self.parse_response(res).await
2893    }
2894
2895    pub async fn evaluate_answer(
2896        &self,
2897        payload: AnswerEvaluateRequest,
2898    ) -> Result<serde_json::Value, HingeError> {
2899        let url = format!("{}/content/v1/answer/evaluate", self.settings.base_url);
2900        let body = serde_json::to_value(&payload).map_err(|e| HingeError::Serde(e.to_string()))?;
2901        let res = self.http_post(&url, &body).await?;
2902        self.parse_response(res).await
2903    }
2904
2905    pub async fn create_prompt_poll(
2906        &self,
2907        payload: CreatePromptPollRequest,
2908    ) -> Result<CreatePromptPollResponse, HingeError> {
2909        let url = format!("{}/content/v1/prompt_poll", self.settings.base_url);
2910        let body = serde_json::to_value(&payload).map_err(|e| HingeError::Serde(e.to_string()))?;
2911        let res = self.http_post(&url, &body).await?;
2912        self.parse_response(res).await
2913    }
2914
2915    pub async fn create_video_prompt(
2916        &self,
2917        payload: CreateVideoPromptRequest,
2918    ) -> Result<CreateVideoPromptResponse, HingeError> {
2919        let url = format!("{}/content/v1/video_prompt", self.settings.base_url);
2920        let body = serde_json::to_value(&payload).map_err(|e| HingeError::Serde(e.to_string()))?;
2921        let res = self.http_post(&url, &body).await?;
2922        self.parse_response(res).await
2923    }
2924
2925    pub async fn get_connections_v2(&self) -> Result<ConnectionsResponse, HingeError> {
2926        let url = format!("{}/connection/v2", self.settings.base_url);
2927        let res = self.http_get(&url).await?;
2928        self.parse_response(res).await
2929    }
2930
2931    pub async fn get_connection_detail(
2932        &self,
2933        subject_id: &str,
2934    ) -> Result<ConnectionDetailApi, HingeError> {
2935        let url = format!(
2936            "{}/connection/subject/{}",
2937            self.settings.base_url, subject_id
2938        );
2939        let res = self.http_get(&url).await?;
2940        self.parse_response(res).await
2941    }
2942
2943    pub async fn get_connection_match_note(
2944        &self,
2945        subject_id: &str,
2946    ) -> Result<MatchNoteResponse, HingeError> {
2947        let url = format!(
2948            "{}/connection/v2/matchnote/{}",
2949            self.settings.base_url, subject_id
2950        );
2951        let res = self.http_get(&url).await?;
2952        self.parse_response(res).await
2953    }
2954
2955    pub async fn get_standouts(&self) -> Result<StandoutsResponse, HingeError> {
2956        let url = format!("{}/standouts/v3", self.settings.base_url);
2957        let res = self.http_get(&url).await?;
2958        self.parse_response(res).await
2959    }
2960
2961    pub async fn delete_content(&self, content_ids: Vec<String>) -> Result<(), HingeError> {
2962        let url = format!(
2963            "{}/content/v1?ids={}",
2964            self.settings.base_url,
2965            content_ids.join(",")
2966        );
2967        let res = self
2968            .http
2969            .delete(url)
2970            .headers(self.default_headers()?)
2971            .send()
2972            .await?;
2973        if !res.status().is_success() {
2974            return Err(HingeError::Http(format!("status {}", res.status())));
2975        }
2976        Ok(())
2977    }
2978
2979    pub async fn get_content_settings(&self) -> Result<UserSettings, HingeError> {
2980        let url = format!("{}/content/v1/settings", self.settings.base_url);
2981        let res = self.http_get(&url).await?;
2982        self.parse_response(res).await
2983    }
2984
2985    pub async fn update_content_settings(
2986        &self,
2987        settings: UserSettings,
2988    ) -> Result<serde_json::Value, HingeError> {
2989        let url = format!("{}/content/v1/settings", self.settings.base_url);
2990        let payload =
2991            serde_json::to_value(&settings).map_err(|e| HingeError::Serde(e.to_string()))?;
2992        let res = self.http_patch(&url, &payload).await?;
2993        if !res.status().is_success() {
2994            return Err(HingeError::Http(format!("status {}", res.status())));
2995        }
2996        let body = res.json::<serde_json::Value>().await?;
2997        Ok(body)
2998    }
2999
3000    pub async fn get_auth_settings(&self) -> Result<AuthSettings, HingeError> {
3001        let url = format!("{}/auth/settings", self.settings.base_url);
3002        let res = self.http_get(&url).await?;
3003        self.parse_response(res).await
3004    }
3005
3006    pub async fn get_notification_settings(&self) -> Result<NotificationSettings, HingeError> {
3007        let url = format!("{}/notification/v1/settings", self.settings.base_url);
3008        let res = self.http_get(&url).await?;
3009        self.parse_response(res).await
3010    }
3011
3012    pub async fn get_user_traits(&self) -> Result<Vec<UserTrait>, HingeError> {
3013        let url = format!("{}/user/v2/traits", self.settings.base_url);
3014        let res = self.http_get(&url).await?;
3015        self.parse_response(res).await
3016    }
3017
3018    pub async fn get_account_info(&self) -> Result<AccountInfo, HingeError> {
3019        let url = format!("{}/store/v2/account", self.settings.base_url);
3020        let res = self.http_get(&url).await?;
3021        self.parse_response(res).await
3022    }
3023
3024    pub async fn get_export_status(&self) -> Result<ExportStatus, HingeError> {
3025        let url = format!("{}/user/export/status", self.settings.base_url);
3026        let res = self.http_get(&url).await?;
3027        self.parse_response(res).await
3028    }
3029
3030    pub async fn raw_hinge_json(
3031        &self,
3032        method: reqwest::Method,
3033        path_or_url: &str,
3034        body: Option<serde_json::Value>,
3035    ) -> Result<serde_json::Value, HingeError> {
3036        let url = if path_or_url.starts_with("http://") || path_or_url.starts_with("https://") {
3037            path_or_url.to_string()
3038        } else {
3039            format!(
3040                "{}/{}",
3041                self.settings.base_url.trim_end_matches('/'),
3042                path_or_url.trim_start_matches('/')
3043            )
3044        };
3045        let headers = self.default_headers()?;
3046        log_request(method.as_str(), &url, &headers, body.as_ref());
3047        let mut request = self.http.request(method, &url).headers(headers);
3048        if let Some(body) = body {
3049            request = request.json(&body);
3050        }
3051        let res = request.send().await?;
3052        self.parse_response(res).await
3053    }
3054
3055    pub async fn raw_sendbird_json(
3056        &self,
3057        method: reqwest::Method,
3058        path_or_url: &str,
3059        body: Option<serde_json::Value>,
3060    ) -> Result<serde_json::Value, HingeError> {
3061        let url = if path_or_url.starts_with("http://") || path_or_url.starts_with("https://") {
3062            path_or_url.to_string()
3063        } else {
3064            format!(
3065                "{}/v3/{}",
3066                self.settings.sendbird_api_url.trim_end_matches('/'),
3067                path_or_url.trim_start_matches('/')
3068            )
3069        };
3070        let mut headers = self.sendbird_headers()?;
3071        if body.is_some() {
3072            headers.insert(
3073                reqwest::header::CONTENT_TYPE,
3074                reqwest::header::HeaderValue::from_static("application/json"),
3075            );
3076        }
3077        log_request(method.as_str(), &url, &headers, body.as_ref());
3078        let mut request = self.http.request(method, &url).headers(headers);
3079        if let Some(body) = body {
3080            request = request.json(&body);
3081        }
3082        let res = request.send().await?;
3083        self.parse_response(res).await
3084    }
3085}
3086
3087fn merge_recommendation_responses(
3088    base: &mut RecommendationsResponse,
3089    mut additional: RecommendationsResponse,
3090) {
3091    let mut feed_index: HashMap<String, usize> = HashMap::new();
3092    for (idx, feed) in base.feeds.iter().enumerate() {
3093        feed_index.insert(feed.origin.clone(), idx);
3094    }
3095
3096    for feed in additional.feeds.drain(..) {
3097        if let Some(&idx) = feed_index.get(&feed.origin) {
3098            let existing_feed = &mut base.feeds[idx];
3099            let mut seen: HashSet<String> = existing_feed
3100                .subjects
3101                .iter()
3102                .map(|s| s.subject_id.clone())
3103                .collect();
3104            for mut subj in feed.subjects {
3105                if seen.insert(subj.subject_id.clone()) {
3106                    if subj.origin.is_none() {
3107                        subj.origin = Some(feed.origin.clone());
3108                    }
3109                    existing_feed.subjects.push(subj);
3110                }
3111            }
3112            if existing_feed.permission.is_none() {
3113                existing_feed.permission = feed.permission;
3114            }
3115            if existing_feed.preview.is_none() {
3116                existing_feed.preview = feed.preview;
3117            }
3118        } else {
3119            let mut new_feed = feed;
3120            for subj in &mut new_feed.subjects {
3121                if subj.origin.is_none() {
3122                    subj.origin = Some(new_feed.origin.clone());
3123                }
3124            }
3125            feed_index.insert(new_feed.origin.clone(), base.feeds.len());
3126            base.feeds.push(new_feed);
3127        }
3128    }
3129
3130    match (&mut base.active_pills, additional.active_pills) {
3131        (Some(existing), Some(mut incoming)) => {
3132            let mut seen: HashSet<String> = existing.iter().map(|pill| pill.id.clone()).collect();
3133            for pill in incoming.drain(..) {
3134                if seen.insert(pill.id.clone()) {
3135                    existing.push(pill);
3136                }
3137            }
3138        }
3139        (None, Some(pills)) => base.active_pills = Some(pills),
3140        _ => {}
3141    }
3142
3143    if base.cache_control.is_none() && additional.cache_control.is_some() {
3144        base.cache_control = additional.cache_control;
3145    }
3146}
3147
3148fn normalize_recommendations_response(response: &mut RecommendationsResponse) {
3149    let mut ordered_subjects: Vec<RecommendationSubject> = Vec::new();
3150    let mut seen = HashSet::new();
3151
3152    for feed in &response.feeds {
3153        for subj in &feed.subjects {
3154            if seen.insert(subj.subject_id.clone()) {
3155                let mut clone = subj.clone();
3156                if clone.origin.is_none() {
3157                    clone.origin = Some(feed.origin.clone());
3158                }
3159                ordered_subjects.push(clone);
3160            }
3161        }
3162    }
3163
3164    let (permission, preview) = response
3165        .feeds
3166        .first()
3167        .map(|feed| (feed.permission.clone(), feed.preview.clone()))
3168        .unwrap_or((None, None));
3169
3170    let origin = response
3171        .feeds
3172        .first()
3173        .map(|feed| feed.origin.clone())
3174        .unwrap_or_else(|| "combined".to_string());
3175
3176    response.feeds = vec![crate::models::RecommendationsFeed {
3177        id: 0,
3178        origin,
3179        subjects: ordered_subjects,
3180        permission,
3181        preview,
3182    }];
3183}
3184
3185fn summarize_connection_initiation(
3186    connection: &ConnectionItem,
3187    self_user_id: &str,
3188    peer_user_id: &str,
3189    peer_display_name: &str,
3190) -> Option<Vec<String>> {
3191    let initiator_id = connection.initiator_id.trim();
3192    let initiator_label = if initiator_id.is_empty() {
3193        "Unknown".to_string()
3194    } else if initiator_id == self_user_id {
3195        "You".to_string()
3196    } else if initiator_id == peer_user_id {
3197        peer_display_name.to_string()
3198    } else {
3199        initiator_id.to_string()
3200    };
3201
3202    let mut lines = Vec::new();
3203    if let Some(with_label) = prettify_initiated_with(&connection.initiated_with) {
3204        lines.push(format!(
3205            "Conversation initiated by {} via {}.",
3206            initiator_label, with_label
3207        ));
3208    } else {
3209        lines.push(format!("Conversation initiated by {}.", initiator_label));
3210    }
3211
3212    let mut seen: HashSet<String> = HashSet::new();
3213    let mut detail_lines = Vec::new();
3214    for content in &connection.sent_content {
3215        for description in describe_connection_content_item(content) {
3216            if seen.insert(description.clone()) {
3217                detail_lines.push(description);
3218            }
3219        }
3220    }
3221
3222    for detail in detail_lines {
3223        lines.push(format!("  • {}", detail));
3224    }
3225
3226    Some(lines)
3227}
3228
3229fn describe_connection_content_item(item: &ConnectionContentItem) -> Vec<String> {
3230    let mut lines = Vec::new();
3231    if let Some(prompt) = &item.prompt {
3232        let question = prompt.question.trim();
3233        let answer = prompt.answer.trim();
3234        if !question.is_empty() && !answer.is_empty() {
3235            lines.push(format!("Prompt \"{}\" – \"{}\"", question, answer));
3236        } else if !question.is_empty() {
3237            lines.push(format!("Prompt \"{}\"", question));
3238        } else if !answer.is_empty() {
3239            lines.push(format!("Prompt answer \"{}\"", answer));
3240        }
3241    }
3242
3243    if let Some(comment) = &item.comment {
3244        let trimmed = comment.trim();
3245        if !trimmed.is_empty() {
3246            lines.push(format!("Comment: {}", trimmed));
3247        }
3248    }
3249
3250    if let Some(photo) = &item.photo {
3251        let caption = photo.caption.as_deref().map(str::trim).unwrap_or("");
3252        if !caption.is_empty() {
3253            lines.push(format!("Photo liked – {}", caption));
3254        } else {
3255            lines.push("Photo liked".to_string());
3256        }
3257    }
3258
3259    if let Some(video) = &item.video {
3260        if !video.url.trim().is_empty() {
3261            lines.push("Video shared".to_string());
3262        } else {
3263            lines.push("Video interaction".to_string());
3264        }
3265    }
3266
3267    lines
3268}
3269
3270fn prettify_initiated_with(value: &str) -> Option<String> {
3271    let trimmed = value.trim();
3272    if trimmed.is_empty() {
3273        return None;
3274    }
3275
3276    let words: Vec<String> = trimmed
3277        .split(['_', ' '])
3278        .filter(|part| !part.is_empty())
3279        .map(|part| {
3280            let mut chars = part.chars();
3281            if let Some(first) = chars.next() {
3282                let mut result = first.to_uppercase().collect::<String>();
3283                result.push_str(&chars.as_str().to_lowercase());
3284                result
3285            } else {
3286                String::new()
3287            }
3288        })
3289        .filter(|s| !s.is_empty())
3290        .collect();
3291
3292    if words.is_empty() {
3293        None
3294    } else {
3295        Some(words.join(" "))
3296    }
3297}
3298
3299fn render_profile(
3300    profile: Option<&PublicUserProfile>,
3301    content: Option<&ProfileContentFull>,
3302    prompts: Option<&HingePromptsManager>,
3303) -> String {
3304    let mut out = String::new();
3305
3306    if let Some(wrapper) = profile {
3307        let p = &wrapper.profile;
3308        let _ = writeln!(out, "Name: {}", p.first_name);
3309        if let Some(age) = p.age {
3310            let _ = writeln!(out, "Age: {}", age);
3311        }
3312        if let Some(height) = p.height {
3313            let _ = writeln!(out, "Height: {} cm", height);
3314        }
3315        if let Some(children) = label_from_map(CHILDREN_LABELS, p.children) {
3316            let _ = writeln!(out, "Children: {}", children);
3317        }
3318        if let Some(label) = label_from_map(DATING_LABELS, p.dating_intention) {
3319            let _ = writeln!(out, "Dating intention: {}", label);
3320        }
3321        if let Some(label) = label_from_map(DRINKING_LABELS, p.drinking) {
3322            let _ = writeln!(out, "Drinking: {}", label);
3323        }
3324        if let Some(label) = label_from_map(SMOKING_LABELS, p.smoking) {
3325            let _ = writeln!(out, "Smoking: {}", label);
3326        }
3327        if let Some(label) = label_from_map(MARIJUANA_LABELS, p.marijuana) {
3328            let _ = writeln!(out, "Marijuana: {}", label);
3329        }
3330        if let Some(label) = label_from_map(DRUG_LABELS, p.drugs) {
3331            let _ = writeln!(out, "Drugs: {}", label);
3332        }
3333        let relationship_labels =
3334            labels_from_map(RELATIONSHIP_TYPE_LABELS, &p.relationship_type_ids);
3335        if !relationship_labels.is_empty() {
3336            let _ = writeln!(
3337                out,
3338                "Relationship types: {}",
3339                relationship_labels.join(", ")
3340            );
3341        }
3342        if let Some(job) = p.job_title.as_ref().filter(|v| !v.trim().is_empty()) {
3343            let _ = writeln!(out, "Job title: {}", job);
3344        }
3345        if let Some(work) = p.works.as_ref().filter(|v| !v.trim().is_empty()) {
3346            let _ = writeln!(out, "Workplace: {}", work);
3347        }
3348        if let Some(level) = p.education_attained.as_ref() {
3349            let _ = writeln!(out, "Education level: {}", education_attained_label(level));
3350        }
3351        if let Some(schools) = p.educations.as_ref() {
3352            let entries: Vec<&str> = schools
3353                .iter()
3354                .map(|s| s.trim())
3355                .filter(|s| !s.is_empty())
3356                .collect();
3357            if !entries.is_empty() {
3358                let _ = writeln!(out, "Education: {}", entries.join(", "));
3359            }
3360        }
3361        if !p.location.name.trim().is_empty() {
3362            let _ = writeln!(out, "Location: {}", p.location.name);
3363        }
3364        out.push('\n');
3365    } else {
3366        out.push_str("Profile information unavailable.\n\n");
3367    }
3368
3369    if let Some(full) = content
3370        && !full.content.answers.is_empty()
3371    {
3372        out.push_str("Prompts:\n");
3373        for answer in &full.content.answers {
3374            let response = answer
3375                .response
3376                .as_ref()
3377                .map(|text| text.trim())
3378                .filter(|text| !text.is_empty());
3379            if let Some(resp) = response {
3380                let mut question: Option<String> = None;
3381
3382                if let Some(mgr) = prompts
3383                    && let Some(prompt_id) = answer.prompt_id.as_ref()
3384                {
3385                    let text = mgr.get_prompt_display_text(prompt_id);
3386                    if !text.trim().is_empty() && text != "Unknown Question" {
3387                        question = Some(text);
3388                    }
3389                }
3390
3391                if question.is_none()
3392                    && let Some(mgr) = prompts
3393                    && let Some(question_id) = answer.question_id.as_ref()
3394                {
3395                    let text = mgr.get_prompt_display_text(question_id);
3396                    if !text.trim().is_empty() && text != "Unknown Question" {
3397                        question = Some(text);
3398                    }
3399                }
3400
3401                if question.is_none() {
3402                    question = answer
3403                        .content
3404                        .as_ref()
3405                        .map(|s| s.trim().to_string())
3406                        .filter(|s| !s.is_empty());
3407                }
3408
3409                if question.is_none() {
3410                    question = answer
3411                        .question_id
3412                        .as_ref()
3413                        .map(|s| s.trim().to_string())
3414                        .filter(|s| !s.is_empty());
3415                }
3416
3417                if question.is_none() {
3418                    question = answer
3419                        .prompt_id
3420                        .as_ref()
3421                        .map(|s| s.trim().to_string())
3422                        .filter(|s| !s.is_empty());
3423                }
3424
3425                let question = question.unwrap_or_else(|| "Prompt".to_string());
3426                let _ = writeln!(out, "- {}: {}", question, resp);
3427            }
3428        }
3429        out.push('\n');
3430    }
3431
3432    out
3433}
3434
3435impl<S: Storage + Clone> HingeClient<S> {
3436    fn prepare_user_id_chunks(&self, user_ids: Vec<String>) -> Vec<Vec<String>> {
3437        // Accept numeric IDs or 32-char hex user IDs (observed in likes feed)
3438        fn is_user_id_like(id: &str) -> bool {
3439            if id.is_empty() {
3440                return false;
3441            }
3442            let trimmed = id.trim();
3443            if trimmed.chars().all(|c| c.is_ascii_digit()) {
3444                return true;
3445            }
3446            trimmed.len() == 32 && trimmed.chars().all(|c| c.is_ascii_hexdigit())
3447        }
3448
3449        let (mut accepted, mut dropped) = (Vec::new(), 0usize);
3450        let mut seen: HashSet<String> = HashSet::new();
3451        for raw in user_ids.into_iter() {
3452            let id = raw.trim().to_string();
3453            if is_user_id_like(&id) && seen.insert(id.clone()) {
3454                accepted.push(id);
3455            } else {
3456                dropped += 1;
3457            }
3458        }
3459
3460        if accepted.is_empty() {
3461            log::warn!("No valid user IDs to fetch (dropped {})", dropped);
3462            return Vec::new();
3463        }
3464        if dropped > 0 {
3465            log::debug!("Dropped {} non user-like IDs from public fetch", dropped);
3466        }
3467
3468        let batch_size = self.public_ids_batch_size.max(1);
3469        let mut out: Vec<Vec<String>> = Vec::new();
3470        let mut idx = 0usize;
3471        while idx < accepted.len() {
3472            let end = (idx + batch_size).min(accepted.len());
3473            out.push(accepted[idx..end].to_vec());
3474            idx = end;
3475        }
3476        if out.len() > 1 {
3477            log::info!(
3478                "Fetching public user data in {} batches of up to {} IDs",
3479                out.len(),
3480                batch_size
3481            );
3482        }
3483        out
3484    }
3485}
3486
3487#[cfg(test)]
3488mod tests {
3489    use super::*;
3490    use crate::models::{
3491        MessageData, SendMessagePayload, SendbirdChannelsResponse, SendbirdMessagesResponse,
3492    };
3493    use serde::Deserialize;
3494
3495    #[allow(dead_code)]
3496    #[derive(Debug, Deserialize)]
3497    struct PathAwareOuter {
3498        items: Vec<PathAwareInner>,
3499    }
3500
3501    #[allow(dead_code)]
3502    #[derive(Debug, Deserialize)]
3503    struct PathAwareInner {
3504        count: u32,
3505    }
3506
3507    #[test]
3508    fn response_deserialization_reports_json_path() {
3509        let err = parse_json_with_path::<PathAwareOuter>(r#"{"items":[{"count":"not-a-number"}]}"#)
3510            .expect_err("invalid nested field should fail");
3511        let message = err.to_string();
3512        assert!(message.contains("items[0].count"), "{message}");
3513    }
3514
3515    #[test]
3516    fn send_message_payload_serializes_camel_case() {
3517        let payload = SendMessagePayload {
3518            dedup_id: Some("dedup-1".to_string()),
3519            ays: false,
3520            match_message: true,
3521            message_type: "text".to_string(),
3522            message_data: MessageData {
3523                message: "hello".to_string(),
3524            },
3525            subject_id: "subject-1".to_string(),
3526            origin: "connection".to_string(),
3527        };
3528
3529        let value = serde_json::to_value(payload).expect("payload should serialize");
3530        assert_eq!(value["dedupId"], "dedup-1");
3531        assert_eq!(value["messageData"]["message"], "hello");
3532        assert_eq!(value["subjectId"], "subject-1");
3533    }
3534
3535    #[test]
3536    fn sendbird_channel_and_message_fixtures_deserialize() {
3537        let channels = parse_json_with_path::<SendbirdChannelsResponse>(
3538            r#"{
3539                "channels": [{
3540                    "channel_url": "sendbird_group_channel_1",
3541                    "members": [{"user_id": "user-1", "nickname": "A"}],
3542                    "created_at": 1710000000000,
3543                    "updated_at": 1710000000100,
3544                    "last_message": {
3545                        "type": "MESG",
3546                        "message_id": 42,
3547                        "message": "hello",
3548                        "created_at": 1710000000000,
3549                        "user": {"user_id": "user-1"},
3550                        "channel_url": "sendbird_group_channel_1"
3551                    }
3552                }]
3553            }"#,
3554        )
3555        .expect("channels fixture should deserialize");
3556        assert_eq!(channels.channels[0].channel_url, "sendbird_group_channel_1");
3557        assert_eq!(
3558            channels.channels[0]
3559                .last_message
3560                .as_ref()
3561                .expect("last message")
3562                .message_id,
3563            "42"
3564        );
3565
3566        let messages = parse_json_with_path::<SendbirdMessagesResponse>(
3567            r#"{
3568                "messages": [{
3569                    "type": "MESG",
3570                    "message_id": 43,
3571                    "message": "reply",
3572                    "created_at": 1710000000200,
3573                    "user": {"user_id": "user-2", "nickname": "B"},
3574                    "channel_url": "sendbird_group_channel_1"
3575                }]
3576            }"#,
3577        )
3578        .expect("messages fixture should deserialize");
3579        assert_eq!(messages.messages[0].message_id, "43");
3580        assert_eq!(messages.messages[0].user.user_id, "user-2");
3581    }
3582}