wechat-pub-rs 0.5.1

A simple, high-performance WeChat Official Account Rust SDK for uploading articles and managing drafts
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
//! Upload module for handling image uploads and draft management.
//!
//! This module provides comprehensive functionality for uploading images and managing
//! WeChat article drafts, with built-in deduplication, concurrent processing, and error recovery.
//!
//! ## Features
//!
//! - **Unified Upload Flow**: All images uploaded as permanent materials for consistency
//! - **Concurrent Image Uploads**: Up to 5 simultaneous image uploads for performance
//! - **Content Deduplication**: BLAKE3 hash-based image deduplication to avoid duplicates
//! - **Size Validation**: Automatic file size validation (max 10MB for images)
//! - **Format Support**: JPEG, PNG, GIF image format support
//! - **Draft Management**: Full CRUD operations for article drafts
//! - **Streaming Downloads**: Memory-efficient handling of remote images
//!
//! ## Image Upload Process
//!
//! 1. **Validation**: Check file size and format
//! 2. **Hash Calculation**: Generate BLAKE3 hash for deduplication
//! 3. **Deduplication Check**: Search existing permanent materials by hash
//! 4. **Upload as Permanent Material**: All images uploaded using the permanent material API
//! 5. **Concurrent Processing**: Process multiple images simultaneously
//! 6. **Error Recovery**: Retry failed uploads with exponential backoff
//!
//! ## Draft Management
//!
//! The module supports full lifecycle management of WeChat article drafts:
//!
//! - **Create**: Upload new article content as a draft
//! - **Read**: Retrieve draft information and content
//! - **Update**: Modify existing draft content
//! - **Delete**: Remove drafts
//! - **List**: Paginated listing of all drafts
//!
//! ## Usage Examples
//!
//! ### Image Upload
//!
//! ```rust
//! use wechat_pub_rs::upload::ImageUploader;
//! use wechat_pub_rs::markdown::ImageRef;
//! use std::path::Path;
//! # use std::sync::Arc;
//! # use wechat_pub_rs::auth::TokenManager;
//! # use wechat_pub_rs::http::WeChatHttpClient;
//!
//! # async fn example() -> wechat_pub_rs::Result<()> {
//! # let http_client = Arc::new(WeChatHttpClient::new()?);
//! # let token_manager = Arc::new(TokenManager::new("id".to_string(), "secret".to_string(), http_client.clone()));
//! let uploader = ImageUploader::new(http_client, token_manager);
//!
//! let image_ref = ImageRef::new(
//!     "Alt text".to_string(),
//!     "images/photo.jpg".to_string(),
//!     (0, 0)
//! );
//!
//! let results = uploader.upload_images(vec![image_ref], Path::new(".")).await?;
//! for result in results {
//!     println!("Uploaded: {} -> {}", result.image_ref.original_url, result.url);
//! }
//! # Ok(())
//! # }
//! ```
//!
//! ### Draft Management
//!
//! ```rust
//! use wechat_pub_rs::upload::{DraftManager, Article};
//! # use std::sync::Arc;
//! # use wechat_pub_rs::auth::TokenManager;
//! # use wechat_pub_rs::http::WeChatHttpClient;
//!
//! # async fn example() -> wechat_pub_rs::Result<()> {
//! # let http_client = Arc::new(WeChatHttpClient::new()?);
//! # let token_manager = Arc::new(TokenManager::new("id".to_string(), "secret".to_string(), http_client.clone()));
//! let draft_manager = DraftManager::new(http_client, token_manager);
//!
//! // Create a new article
//! let article = Article::new(
//!     "Article Title".to_string(),
//!     "Author Name".to_string(),
//!     "<h1>Article Content</h1>".to_string(),
//! ).with_digest("Article summary".to_string());
//!
//! // Create draft
//! let draft_id = draft_manager.create_draft(vec![article]).await?;
//! println!("Created draft: {}", draft_id);
//!
//! // List drafts
//! let drafts = draft_manager.list_drafts(0, 10).await?;
//! println!("Found {} drafts", drafts.len());
//! # Ok(())
//! # }
//! ```
//!
//! ## Performance Characteristics
//!
//! - **Concurrent Uploads**: Maximum 5 simultaneous image uploads
//! - **Memory Efficiency**: Streaming file operations, no full file buffering
//! - **Deduplication**: O(1) hash-based duplicate detection
//! - **Error Recovery**: Exponential backoff with jitter for failed requests

use crate::auth::TokenManager;
use crate::error::{Result, WeChatError};
use crate::http::{DraftResponse, MaterialUploadResponse, WeChatHttpClient, WeChatResponse};
use crate::markdown::ImageRef;
use blake3;
use futures::future::try_join_all;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::fs;
use tokio::sync::{RwLock, Semaphore};
use tracing::{debug, info, warn};

/// Maximum concurrent image uploads to prevent overwhelming the server
const MAX_CONCURRENT_UPLOADS: usize = 5;

/// Cache TTL for material lookups (5 minutes)
const MATERIAL_CACHE_TTL: Duration = Duration::from_secs(300);

/// Maximum number of cached materials
const MAX_CACHE_SIZE: usize = 1000;

/// Cached material entry with timestamp
#[derive(Debug, Clone)]
struct CachedMaterial {
    material: MaterialItem,
    cached_at: Instant,
}

impl CachedMaterial {
    fn new(material: MaterialItem) -> Self {
        Self {
            material,
            cached_at: Instant::now(),
        }
    }

    fn is_expired(&self) -> bool {
        self.cached_at.elapsed() > MATERIAL_CACHE_TTL
    }
}

/// Maximum file size for images (10 MB)
const MAX_IMAGE_SIZE: u64 = 10 * 1024 * 1024;

/// Maximum file size for streaming downloads (50 MB)
const MAX_DOWNLOAD_SIZE: u64 = 50 * 1024 * 1024;

/// Represents the result of an image upload operation.
#[derive(Debug, Clone)]
pub struct UploadResult {
    /// Original image reference
    pub image_ref: ImageRef,
    /// WeChat media ID for the uploaded image
    pub media_id: String,
    /// WeChat URL for the uploaded image
    pub url: String,
}

/// Represents a WeChat article for draft creation.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Article {
    /// Article title
    pub title: String,
    /// Article author
    pub author: String,
    /// Article content (HTML)
    pub content: String,
    /// Content source URL (optional)
    pub content_source_url: Option<String>,
    /// Digest (summary) of the article
    pub digest: String,
    /// Show cover picture in content (0: no, 1: yes)
    pub show_cover_pic: u8,
    /// Thumb media ID for cover image
    pub thumb_media_id: Option<String>,
    /// Need open comment (0: no, 1: yes)
    pub need_open_comment: u8,
    /// Only fans can comment (0: no, 1: yes)
    pub only_fans_can_comment: u8,
}

impl Article {
    /// Creates a new article with required fields.
    pub fn new(title: String, author: String, content: String) -> Self {
        Self {
            title,
            author,
            content,
            content_source_url: None,
            digest: String::new(),
            show_cover_pic: 1,
            thumb_media_id: None,
            need_open_comment: 0,
            only_fans_can_comment: 0,
        }
    }

    /// Sets the article digest (summary).
    pub fn with_digest(mut self, digest: String) -> Self {
        self.digest = digest;
        self
    }

    /// Sets the cover image media ID.
    pub fn with_cover_image(mut self, thumb_media_id: String) -> Self {
        self.thumb_media_id = Some(thumb_media_id);
        self
    }

    /// Sets whether to show cover image in content.
    pub fn with_show_cover(mut self, show: bool) -> Self {
        self.show_cover_pic = if show { 1 } else { 0 };
        self
    }

    /// Sets comment settings.
    pub fn with_comments(mut self, enable_comments: bool, fans_only: bool) -> Self {
        self.need_open_comment = if enable_comments { 1 } else { 0 };
        self.only_fans_can_comment = if fans_only { 1 } else { 0 };
        self
    }

    /// Sets the content source URL.
    pub fn with_source_url(mut self, url: String) -> Self {
        self.content_source_url = Some(url);
        self
    }
}

/// Request body for creating a draft.
#[derive(Debug, Serialize)]
struct DraftRequest {
    articles: Vec<Article>,
}

/// Draft information from WeChat API.
#[derive(Debug, Deserialize)]
pub struct DraftInfo {
    pub media_id: String,
    pub content: DraftContent,
    pub update_time: u64,
}

/// Content of a draft.
#[derive(Debug, Deserialize)]
pub struct DraftContent {
    pub news_item: Vec<Article>,
}

/// List drafts response.
#[derive(Debug, Deserialize)]
pub struct DraftListResponse {
    pub total_count: u32,
    pub item_count: u32,
    pub item: Vec<DraftInfo>,
}

/// Material item in the list response.
#[derive(Debug, Deserialize, Clone)]
pub struct MaterialItem {
    pub media_id: String,
    pub name: String,
    pub update_time: u64,
    pub url: String,
}

/// List materials response.
#[derive(Debug, Deserialize)]
pub struct MaterialListResponse {
    pub total_count: u32,
    pub item_count: u32,
    pub item: Vec<MaterialItem>,
}

/// Image uploader with concurrent upload capabilities and intelligent caching.
#[derive(Debug)]
pub struct ImageUploader {
    http_client: Arc<WeChatHttpClient>,
    token_manager: Arc<TokenManager>,
    semaphore: Arc<Semaphore>,
    /// Cache for material lookups by hash to avoid redundant API calls
    material_cache: Arc<RwLock<HashMap<String, CachedMaterial>>>,
}

impl ImageUploader {
    /// Creates a new image uploader.
    pub fn new(http_client: Arc<WeChatHttpClient>, token_manager: Arc<TokenManager>) -> Self {
        Self {
            http_client,
            token_manager,
            semaphore: Arc::new(Semaphore::new(MAX_CONCURRENT_UPLOADS)),
            material_cache: Arc::new(RwLock::new(HashMap::new())),
        }
    }

    /// Uploads multiple images concurrently.
    pub async fn upload_images(
        &self,
        images: Vec<ImageRef>,
        base_path: &Path,
    ) -> Result<Vec<UploadResult>> {
        if images.is_empty() {
            return Ok(Vec::new());
        }

        debug!("Uploading {} images concurrently", images.len());

        // Create upload tasks
        let tasks: Vec<_> = images
            .into_iter()
            .map(|image_ref| {
                let uploader = self.clone();
                let base_path = base_path.to_owned();

                tokio::spawn(
                    async move { uploader.upload_single_image(image_ref, &base_path).await },
                )
            })
            .collect();

        // Execute all tasks and collect results
        let results = try_join_all(tasks)
            .await
            .map_err(|e| WeChatError::Internal {
                message: format!("Task join error: {e}"),
            })?;

        // Convert task results to upload results
        let upload_results: Result<Vec<_>> = results.into_iter().collect();
        let uploads = upload_results?;

        info!("Successfully uploaded {} images", uploads.len());
        Ok(uploads)
    }

    /// Uploads a single image as permanent material.
    async fn upload_single_image(
        &self,
        image_ref: ImageRef,
        base_path: &Path,
    ) -> Result<UploadResult> {
        // Acquire semaphore permit to limit concurrency
        let _permit = self
            .semaphore
            .acquire()
            .await
            .map_err(|e| WeChatError::Internal {
                message: format!("Semaphore error: {e}"),
            })?;

        debug!("Processing image: {}", image_ref.original_url);

        // Load image data
        let image_data = if image_ref.is_local {
            let image_path = image_ref.resolve_path(base_path)?;
            self.load_local_image(&image_path).await?
        } else {
            self.download_remote_image(&image_ref.original_url).await?
        };

        // Use unified upload method
        let (media_id, url) = self
            .upload_image_as_material(image_data, &image_ref.original_url)
            .await?;

        info!(
            "Successfully uploaded image: {} -> {} (media_id: {})",
            image_ref.original_url, url, media_id
        );

        Ok(UploadResult {
            image_ref,
            media_id,
            url,
        })
    }

    /// Unified method to upload image data as permanent material with deduplication and caching.
    async fn upload_image_as_material(
        &self,
        image_data: Vec<u8>,
        original_path: &str,
    ) -> Result<(String, String)> {
        // Calculate BLAKE3 hash of the image content
        let hash = blake3::hash(&image_data);
        let hash_str = hash.to_hex().to_string();
        debug!("Image hash: {hash_str}");

        // Check cache first for performance optimization
        {
            let cache = self.material_cache.read().await;
            if let Some(cached) = cache.get(&hash_str) {
                if !cached.is_expired() {
                    debug!("Cache hit for hash: {hash_str}");
                    return Ok((
                        cached.material.media_id.clone(),
                        cached.material.url.clone(),
                    ));
                } else {
                    debug!("Cache entry expired for hash: {hash_str}");
                }
            }
        }

        // Check if this image already exists by searching materials (with cache update)
        debug!("Checking for existing material with hash: {}", hash_str);
        if let Some((existing_url, media_id)) = self.find_material_by_hash(&hash_str).await? {
            info!("Image already exists with hash {hash_str}, reusing media_id: {media_id}");

            // Cache the found material for future lookups
            {
                let mut cache = self.material_cache.write().await;
                let material_item = MaterialItem {
                    media_id: media_id.clone(),
                    name: hash_str.clone(),
                    update_time: std::time::SystemTime::now()
                        .duration_since(std::time::UNIX_EPOCH)
                        .unwrap()
                        .as_secs(),
                    url: existing_url.clone(),
                };
                cache.insert(hash_str.clone(), CachedMaterial::new(material_item));
                debug!("Cached found material for hash: {hash_str}");
            }

            return Ok((media_id, existing_url));
        }

        // Use hash as filename with appropriate extension
        let extension = self.get_image_extension(original_path, &image_data);
        let filename = format!("{hash_str}.{extension}");
        debug!("Uploading new image as permanent material with filename: {filename}");

        // Upload as permanent material
        let access_token = self.token_manager.get_access_token().await?;
        let response = self
            .http_client
            .upload_material(&access_token, "image", image_data, &filename)
            .await?;

        // Parse response - handle both direct and wrapped response formats
        let response_text = response.text().await?;
        let material = if let Ok(direct_response) =
            serde_json::from_str::<MaterialUploadResponse>(&response_text)
        {
            direct_response
        } else {
            // If that fails, try parsing as standard WeChat error response
            let upload_response: WeChatResponse<MaterialUploadResponse> =
                serde_json::from_str(&response_text)?;
            upload_response.into_result()?
        };

        info!(
            "Successfully uploaded new material: {} -> media_id: {} (hash: {})",
            original_path, material.media_id, hash_str
        );

        // Cache the successful upload for future lookups
        {
            let mut cache = self.material_cache.write().await;

            // Implement LRU eviction if cache is full
            if cache.len() >= MAX_CACHE_SIZE {
                // Remove 10% of oldest entries
                let remove_count = MAX_CACHE_SIZE / 10;
                let mut to_remove = Vec::with_capacity(remove_count);

                for (hash, cached) in cache.iter() {
                    to_remove.push((hash.clone(), cached.cached_at));
                    if to_remove.len() >= remove_count {
                        break;
                    }
                }

                // Sort by timestamp and remove oldest
                to_remove.sort_by_key(|(_, timestamp)| *timestamp);
                for (hash, _) in to_remove.into_iter().take(remove_count) {
                    cache.remove(&hash);
                }

                debug!("Evicted {} old cache entries", remove_count);
            }

            // Add new material to cache
            let material_item = MaterialItem {
                media_id: material.media_id.clone(),
                name: hash_str.clone(),
                update_time: std::time::SystemTime::now()
                    .duration_since(std::time::UNIX_EPOCH)
                    .unwrap()
                    .as_secs(),
                url: material.url.clone(),
            };

            cache.insert(hash_str.clone(), CachedMaterial::new(material_item));
            debug!("Cached material for hash: {hash_str}");
        }

        Ok((material.media_id, material.url))
    }

    /// Clears expired entries from the material cache.
    pub async fn clear_expired_cache(&self) {
        let mut cache = self.material_cache.write().await;
        let initial_size = cache.len();

        cache.retain(|hash, cached| {
            let keep = !cached.is_expired();
            if !keep {
                debug!("Removing expired cache entry: {}", hash);
            }
            keep
        });

        let removed = initial_size - cache.len();
        if removed > 0 {
            info!("Cleared {} expired cache entries", removed);
        }
    }

    /// Gets cache statistics for monitoring.
    pub async fn get_cache_stats(&self) -> (usize, usize) {
        let cache = self.material_cache.read().await;
        let total = cache.len();
        let expired = cache.values().filter(|c| c.is_expired()).count();
        (total, expired)
    }

    /// Loads image data from local file with streaming and size validation.
    async fn load_local_image(&self, path: &Path) -> Result<Vec<u8>> {
        // Check file size before loading
        let metadata = fs::metadata(path)
            .await
            .map_err(|e| WeChatError::ImageUpload {
                path: path.display().to_string(),
                reason: format!("Failed to get file metadata: {e}"),
            })?;

        let file_size = metadata.len();
        if file_size > MAX_IMAGE_SIZE {
            return Err(WeChatError::ImageUpload {
                path: path.display().to_string(),
                reason: format!("File too large: {file_size} bytes (max: {MAX_IMAGE_SIZE} bytes)"),
            });
        }

        debug!(
            "Loading local image: {} ({} bytes)",
            path.display(),
            file_size
        );

        fs::read(path).await.map_err(|e| WeChatError::ImageUpload {
            path: path.display().to_string(),
            reason: format!("Failed to read local file: {e}"),
        })
    }

    /// Downloads image data from remote URL with optimized streaming and size validation.
    async fn download_remote_image(&self, url: &str) -> Result<Vec<u8>> {
        debug!("Downloading remote image: {url}");

        self.http_client
            .download_with_limit(url, MAX_DOWNLOAD_SIZE)
            .await
            .map_err(|e| WeChatError::ImageUpload {
                path: url.to_string(),
                reason: format!("Failed to download remote image: {e}"),
            })
    }

    /// Gets the image extension based on URL and content.
    fn get_image_extension(&self, url: &str, image_data: &[u8]) -> String {
        // First try to get from URL
        if let Some(ext) = Path::new(url)
            .extension()
            .and_then(|e| e.to_str())
            .filter(|e| matches!(*e, "jpg" | "jpeg" | "png" | "gif" | "bmp" | "webp"))
        {
            return ext.to_string();
        }

        // Otherwise, detect from content
        if image_data.len() >= 4 {
            match &image_data[0..4] {
                [0xFF, 0xD8, 0xFF, _] => return "jpg".to_string(),
                [0x89, 0x50, 0x4E, 0x47] => return "png".to_string(),
                [0x47, 0x49, 0x46, _] => return "gif".to_string(),
                [0x42, 0x4D, _, _] => return "bmp".to_string(),
                _ => {}
            }
        }

        // Check for WebP
        if image_data.len() >= 12 && &image_data[0..4] == b"RIFF" && &image_data[8..12] == b"WEBP" {
            return "webp".to_string();
        }

        // Default to jpg
        "jpg".to_string()
    }

    /// Searches for an existing material by hash and returns both URL and media_id.
    async fn find_material_by_hash(&self, hash_str: &str) -> Result<Option<(String, String)>> {
        debug!("Checking for existing material with hash: {hash_str}");

        // Check the most recent 20 materials
        let access_token = self.token_manager.get_access_token().await?;

        let request = serde_json::json!({
            "type": "image",
            "offset": 0,
            "count": 20
        });

        let response = self
            .http_client
            .post_json_with_token(
                "/cgi-bin/material/batchget_material",
                &access_token,
                &request,
            )
            .await
            .map_err(|e| {
                warn!("Failed to list materials: {e}");
                e
            });

        // If we can't list materials, just proceed with upload
        let response = match response {
            Ok(resp) => resp,
            Err(_) => return Ok(None),
        };

        let response_text = response
            .text()
            .await
            .unwrap_or_else(|_| "Unable to read response".to_string());

        let materials_result =
            serde_json::from_str::<WeChatResponse<MaterialListResponse>>(&response_text);

        match materials_result {
            Ok(materials_response) => {
                if let Ok(material_list) = materials_response.into_result() {
                    // Check if any material name starts with our hash
                    for item in material_list.item {
                        if item.name.starts_with(hash_str) {
                            info!(
                                "Found existing material with hash {}: URL {} (media_id: {})",
                                hash_str, item.url, item.media_id
                            );
                            return Ok(Some((item.url, item.media_id)));
                        }
                    }
                }
            }
            Err(e) => {
                warn!("Failed to parse material list response: {e}");
            }
        }

        debug!("No existing material found with hash: {hash_str}");
        Ok(None)
    }

    /// Uploads a cover image as permanent material.
    pub async fn upload_cover_material(&self, cover_path: &Path) -> Result<String> {
        info!(
            "Uploading cover image as permanent material: {}",
            cover_path.display()
        );

        // Load image data
        let image_data = self.load_local_image(cover_path).await?;

        // Use unified upload method
        let (media_id, _url) = self
            .upload_image_as_material(image_data, &cover_path.to_string_lossy())
            .await?;

        info!(
            "Successfully uploaded cover image: {} -> media_id: {}",
            cover_path.display(),
            media_id
        );

        Ok(media_id)
    }
}

impl Clone for ImageUploader {
    fn clone(&self) -> Self {
        Self {
            http_client: Arc::clone(&self.http_client),
            token_manager: Arc::clone(&self.token_manager),
            semaphore: Arc::clone(&self.semaphore),
            material_cache: Arc::clone(&self.material_cache),
        }
    }
}

/// Draft manager for creating and managing article drafts.
#[derive(Debug)]
pub struct DraftManager {
    http_client: Arc<WeChatHttpClient>,
    token_manager: Arc<TokenManager>,
}

impl DraftManager {
    /// Creates a new draft manager.
    pub fn new(http_client: Arc<WeChatHttpClient>, token_manager: Arc<TokenManager>) -> Self {
        Self {
            http_client,
            token_manager,
        }
    }

    /// Creates a new draft with articles, or updates existing if title matches.
    pub async fn create_draft(&self, articles: Vec<Article>) -> Result<String> {
        if articles.is_empty() {
            return Err(WeChatError::config_error(
                "At least one article is required",
            ));
        }

        let title = &articles[0].title;
        info!("Processing draft with title: {title}");

        // Check recent drafts for matching title
        if let Some(existing_media_id) = self.find_draft_by_title(title).await? {
            info!(
                "Found existing draft with title '{title}', updating media_id: {existing_media_id}"
            );

            // Update existing draft
            self.update_draft(&existing_media_id, articles).await?;
            return Ok(existing_media_id);
        }

        // No existing draft found, create new one
        info!("No existing draft found, creating new draft");

        let request = DraftRequest { articles };
        let access_token = self.token_manager.get_access_token().await?;

        let response = self
            .http_client
            .post_json_with_token("/cgi-bin/draft/add", &access_token, &request)
            .await?;

        let draft_response: WeChatResponse<DraftResponse> = response.json().await?;
        let draft = draft_response.into_result()?;

        info!(
            "Successfully created new draft with media_id: {}",
            draft.media_id
        );
        Ok(draft.media_id)
    }

    /// Gets a draft by media ID.
    pub async fn get_draft(&self, media_id: &str) -> Result<DraftInfo> {
        debug!("Getting draft: {media_id}");

        let access_token = self.token_manager.get_access_token().await?;
        let request = serde_json::json!({ "media_id": media_id });

        let response = self
            .http_client
            .post_json_with_token("/cgi-bin/draft/get", &access_token, &request)
            .await?;

        let draft_response: WeChatResponse<DraftInfo> = response.json().await?;
        draft_response.into_result()
    }

    /// Updates a draft.
    pub async fn update_draft(&self, media_id: &str, articles: Vec<Article>) -> Result<()> {
        if articles.is_empty() {
            return Err(WeChatError::config_error(
                "At least one article is required",
            ));
        }

        info!(
            "Updating draft {} with {} articles",
            media_id,
            articles.len()
        );

        // According to WeChat API, the update request structure should wrap articles differently
        let request = serde_json::json!({
            "media_id": media_id,
            "index": 0,
            "articles": articles[0]  // WeChat expects a single article object, not an array
        });

        let access_token = self.token_manager.get_access_token().await?;

        let response = self
            .http_client
            .post_json_with_token("/cgi-bin/draft/update", &access_token, &request)
            .await?;

        let update_response: WeChatResponse<serde_json::Value> = response.json().await?;
        update_response.into_result()?;

        info!("Successfully updated draft: {media_id}");
        Ok(())
    }

    /// Deletes a draft.
    pub async fn delete_draft(&self, media_id: &str) -> Result<()> {
        info!("Deleting draft: {media_id}");

        let request = serde_json::json!({ "media_id": media_id });
        let access_token = self.token_manager.get_access_token().await?;

        let response = self
            .http_client
            .post_json_with_token("/cgi-bin/draft/delete", &access_token, &request)
            .await?;

        let delete_response: WeChatResponse<serde_json::Value> = response.json().await?;
        delete_response.into_result()?;

        info!("Successfully deleted draft: {media_id}");
        Ok(())
    }

    /// Lists drafts with pagination.
    pub async fn list_drafts(&self, offset: u32, count: u32) -> Result<Vec<DraftInfo>> {
        debug!("Listing drafts: offset={offset}, count={count}");

        let request = serde_json::json!({
            "offset": offset,
            "count": count,
            "no_content": 0
        });

        let access_token = self.token_manager.get_access_token().await?;

        let response = self
            .http_client
            .post_json_with_token("/cgi-bin/draft/batchget", &access_token, &request)
            .await?;

        let response_text = response.text().await?;

        let list_response: WeChatResponse<DraftListResponse> =
            serde_json::from_str(&response_text)?;

        let drafts = list_response.into_result()?;
        Ok(drafts.item)
    }

    /// Creates URL mapping from upload results.
    pub fn create_url_mapping(&self, upload_results: &[UploadResult]) -> HashMap<String, String> {
        upload_results
            .iter()
            .map(|result| (result.image_ref.original_url.clone(), result.url.clone()))
            .collect()
    }

    /// Finds a draft by title in recent drafts.
    async fn find_draft_by_title(&self, title: &str) -> Result<Option<String>> {
        debug!("Searching for draft with title: {title}");

        // List recent 20 drafts
        let drafts = match self.list_drafts(0, 20).await {
            Ok(drafts) => drafts,
            Err(e) => {
                warn!("Failed to list drafts: {e}");
                return Ok(None);
            }
        };

        // Search for matching title
        for draft in drafts {
            if let Some(first_article) = draft.content.news_item.first() {
                if first_article.title == title {
                    info!("Found existing draft with matching title");
                    return Ok(Some(draft.media_id));
                }
            }
        }

        debug!("No draft found with title: {title}");
        Ok(None)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::auth::TokenManager;
    use std::sync::Arc;

    #[tokio::test]
    async fn test_article_creation() {
        let article = Article::new(
            "Test Title".to_string(),
            "Test Author".to_string(),
            "<h1>Test Content</h1>".to_string(),
        );

        assert_eq!(article.title, "Test Title");
        assert_eq!(article.author, "Test Author");
        assert_eq!(article.content, "<h1>Test Content</h1>");
        assert_eq!(article.show_cover_pic, 1);
        assert_eq!(article.need_open_comment, 0);
    }

    #[tokio::test]
    async fn test_article_builder_methods() {
        let article = Article::new(
            "Title".to_string(),
            "Author".to_string(),
            "Content".to_string(),
        )
        .with_digest("Test digest".to_string())
        .with_cover_image("cover_media_id".to_string())
        .with_show_cover(false)
        .with_comments(true, true)
        .with_source_url("https://example.com".to_string());

        assert_eq!(article.digest, "Test digest");
        assert_eq!(article.thumb_media_id, Some("cover_media_id".to_string()));
        assert_eq!(article.show_cover_pic, 0);
        assert_eq!(article.need_open_comment, 1);
        assert_eq!(article.only_fans_can_comment, 1);
        assert_eq!(
            article.content_source_url,
            Some("https://example.com".to_string())
        );
    }

    #[tokio::test]
    async fn test_image_uploader_creation() {
        let http_client = Arc::new(WeChatHttpClient::new().unwrap());
        let token_manager = Arc::new(TokenManager::new(
            "test_app_id",
            "test_secret",
            Arc::clone(&http_client),
        ));

        let uploader = ImageUploader::new(http_client, token_manager);
        assert_eq!(
            uploader.semaphore.available_permits(),
            MAX_CONCURRENT_UPLOADS
        );
    }

    #[tokio::test]
    async fn test_draft_manager_creation() {
        let http_client = Arc::new(WeChatHttpClient::new().unwrap());
        let token_manager = Arc::new(TokenManager::new(
            "test_app_id",
            "test_secret",
            Arc::clone(&http_client),
        ));

        let _manager = DraftManager::new(http_client, token_manager);
    }

    #[test]
    fn test_image_extension_detection() {
        let http_client = Arc::new(WeChatHttpClient::new().unwrap());
        let token_manager = Arc::new(TokenManager::new(
            "test_app_id",
            "test_secret",
            Arc::clone(&http_client),
        ));
        let uploader = ImageUploader::new(http_client, token_manager);

        // Test URL-based extension detection
        assert_eq!(uploader.get_image_extension("test.jpg", &[]), "jpg");
        assert_eq!(uploader.get_image_extension("test.png", &[]), "png");
        assert_eq!(uploader.get_image_extension("test.webp", &[]), "webp");

        // Test content-based detection for JPEG
        let jpeg_header = vec![0xFF, 0xD8, 0xFF, 0xE0];
        assert_eq!(uploader.get_image_extension("noext", &jpeg_header), "jpg");

        // Test content-based detection for PNG
        let png_header = vec![0x89, 0x50, 0x4E, 0x47];
        assert_eq!(uploader.get_image_extension("noext", &png_header), "png");
    }

    #[test]
    fn test_url_mapping_creation() {
        let http_client = Arc::new(WeChatHttpClient::new().unwrap());
        let token_manager = Arc::new(TokenManager::new(
            "test_app_id",
            "test_secret",
            Arc::clone(&http_client),
        ));
        let manager = DraftManager::new(http_client, token_manager);

        let image_ref = ImageRef::new("Alt".to_string(), "./test.jpg".to_string(), (0, 10));
        let upload_result = UploadResult {
            image_ref,
            media_id: "media123".to_string(),
            url: "https://wechat.com/image123".to_string(),
        };

        let mapping = manager.create_url_mapping(&[upload_result]);
        assert_eq!(
            mapping.get("./test.jpg"),
            Some(&"https://wechat.com/image123".to_string())
        );
    }
}