ngdp_client/commands/
download.rs

1use crate::pattern_extraction::{PatternConfig, PatternExtractor};
2use crate::{DownloadCommands, OutputFormat};
3use ngdp_bpsv::{BpsvBuilder, BpsvFieldType, BpsvValue};
4use ngdp_cache::cached_cdn_client::CachedCdnClient;
5use ngdp_cache::cached_ribbit_client::CachedRibbitClient;
6use ribbit_client::Region;
7use std::path::{Path, PathBuf};
8use tact_client::resumable::{DownloadProgress, ResumableDownload, find_resumable_downloads};
9use tact_client::{HttpClient, ProtocolVersion as TactProtocolVersion, Region as TactRegion};
10use tracing::{debug, error, info, warn};
11
12pub async fn handle(
13    cmd: DownloadCommands,
14    _format: OutputFormat,
15) -> Result<(), Box<dyn std::error::Error>> {
16    match cmd {
17        DownloadCommands::Build {
18            product,
19            build,
20            output,
21            region,
22            dry_run,
23            tags,
24        } => {
25            info!(
26                "Build download requested: product={}, build={}, region={}",
27                product, build, region
28            );
29            info!("Output directory: {:?}", output);
30
31            // Parse region or use US as default
32            let region = region.parse::<Region>().unwrap_or(Region::US);
33
34            match download_build(&product, &build, &output, region, dry_run, tags).await {
35                Ok(_) => info!("✅ Build download completed successfully!"),
36                Err(e) => {
37                    error!("❌ Build download failed: {}", e);
38                    return Err(e);
39                }
40            }
41        }
42        DownloadCommands::Files {
43            product,
44            patterns,
45            output,
46            build,
47            dry_run,
48            tags,
49            limit,
50        } => {
51            info!(
52                "File download requested: product={}, patterns={:?}",
53                product, patterns
54            );
55            info!("Output directory: {:?}", output);
56
57            match download_files(&product, &patterns, &output, build, dry_run, tags, limit).await {
58                Ok(_) => info!("✅ File download completed successfully!"),
59                Err(e) => {
60                    error!("❌ File download failed: {}", e);
61                    return Err(e);
62                }
63            }
64        }
65        DownloadCommands::Resume { session } => {
66            info!("Resuming download: session={}", session);
67
68            match resume_download(&session).await {
69                Ok(_) => info!("✅ Resume download completed successfully!"),
70                Err(e) => {
71                    error!("❌ Resume download failed: {}", e);
72                    return Err(e);
73                }
74            }
75        }
76        DownloadCommands::TestResume {
77            hash,
78            host,
79            output,
80            resumable,
81        } => {
82            info!(
83                "Testing resumable download: hash={}, host={}, output={:?}, resumable={}",
84                hash, host, output, resumable
85            );
86
87            match test_resumable_download(&hash, &host, &output, resumable).await {
88                Ok(_) => info!("✅ Test download completed successfully!"),
89                Err(e) => {
90                    error!("❌ Test download failed: {}", e);
91                    return Err(e);
92                }
93            }
94        }
95    }
96    Ok(())
97}
98
99/// Download build files (encoding, root, install manifests)
100async fn download_build(
101    product: &str,
102    build: &str,
103    output: &Path,
104    region: Region,
105    dry_run: bool,
106    tags: Option<String>,
107) -> Result<(), Box<dyn std::error::Error>> {
108    info!(
109        "📋 Initializing build download for {} build {}",
110        product, build
111    );
112
113    if dry_run {
114        info!("🔍 DRY RUN mode - no files will be downloaded");
115    }
116
117    if let Some(tags) = &tags {
118        info!("🏷️ Filtering by tags: {}", tags);
119    }
120
121    // Create output directory
122    tokio::fs::create_dir_all(output).await?;
123    info!("📁 Created output directory: {:?}", output);
124
125    // Initialize clients
126    let ribbit_client = CachedRibbitClient::new(region).await?;
127    let cdn_client = CachedCdnClient::new().await?;
128
129    info!("🌐 Getting product versions from Ribbit...");
130    let versions = ribbit_client.get_product_versions(product).await?;
131
132    // Find the specific build or use latest
133    let version_entry = if build.is_empty() || build == "latest" {
134        versions
135            .entries
136            .first()
137            .ok_or("No versions available for product")?
138    } else {
139        versions
140            .entries
141            .iter()
142            .find(|v| v.build_id.to_string() == build || v.versions_name == build)
143            .ok_or_else(|| format!("Build '{build}' not found for product '{product}'"))?
144    };
145
146    info!(
147        "📦 Found build: {} ({})",
148        version_entry.versions_name, version_entry.build_id
149    );
150
151    // Get CDN configuration
152    info!("🌐 Getting CDN configuration...");
153    let cdns = ribbit_client.get_product_cdns(product).await?;
154    let cdn_entry = cdns.entries.first().ok_or("No CDN servers available")?;
155
156    let cdn_host = cdn_entry.hosts.first().ok_or("No CDN hosts available")?;
157
158    info!("🔗 Using CDN host: {}", cdn_host);
159
160    // Download build configuration
161    info!("⬇️ Downloading BuildConfig...");
162    if dry_run {
163        info!(
164            "🔍 Would download BuildConfig: {}",
165            version_entry.build_config
166        );
167    } else {
168        let build_config_response = cdn_client
169            .download_build_config(cdn_host, &cdn_entry.path, &version_entry.build_config)
170            .await?;
171
172        let build_config_data = build_config_response.bytes().await?;
173
174        // Save build config using .build.info compatible structure
175        let config_dir = output.join("Data/config");
176        tokio::fs::create_dir_all(&config_dir).await?;
177
178        // Save with CDN-style subdirectory structure
179        let build_config_hash = &version_entry.build_config;
180        let build_config_subdir =
181            format!("{}/{}", &build_config_hash[0..2], &build_config_hash[2..4]);
182        let build_config_subdir_path = config_dir.join(&build_config_subdir);
183        tokio::fs::create_dir_all(&build_config_subdir_path).await?;
184        let build_config_path = build_config_subdir_path.join(build_config_hash);
185        tokio::fs::write(&build_config_path, &build_config_data).await?;
186        info!(
187            "💾 Saved BuildConfig to: {}/{}",
188            build_config_subdir, build_config_hash
189        );
190
191        // Also save legacy flat file for backwards compatibility
192        let legacy_path = output.join("build_config");
193        tokio::fs::write(&legacy_path, &build_config_data).await?;
194        info!("💾 Saved BuildConfig (legacy) to: {:?}", legacy_path);
195    }
196
197    // Download CDN configuration
198    info!("⬇️ Downloading CDNConfig...");
199    if dry_run {
200        info!("🔍 Would download CDNConfig: {}", version_entry.cdn_config);
201    } else {
202        let cdn_config_response = cdn_client
203            .download_cdn_config(cdn_host, &cdn_entry.path, &version_entry.cdn_config)
204            .await?;
205
206        let cdn_config_data = cdn_config_response.bytes().await?;
207
208        // Save CDN config using .build.info compatible structure
209        let config_dir = output.join("Data/config");
210        tokio::fs::create_dir_all(&config_dir).await?;
211
212        // Save with CDN-style subdirectory structure
213        let cdn_config_hash = &version_entry.cdn_config;
214        let cdn_config_subdir = format!("{}/{}", &cdn_config_hash[0..2], &cdn_config_hash[2..4]);
215        let cdn_config_subdir_path = config_dir.join(&cdn_config_subdir);
216        tokio::fs::create_dir_all(&cdn_config_subdir_path).await?;
217        let cdn_config_path = cdn_config_subdir_path.join(cdn_config_hash);
218        tokio::fs::write(&cdn_config_path, &cdn_config_data).await?;
219        info!(
220            "💾 Saved CDNConfig to: {}/{}",
221            cdn_config_subdir, cdn_config_hash
222        );
223
224        // Also save legacy flat file for backwards compatibility
225        let legacy_path = output.join("cdn_config");
226        tokio::fs::write(&legacy_path, &cdn_config_data).await?;
227        info!("💾 Saved CDNConfig (legacy) to: {:?}", legacy_path);
228    }
229
230    // Download product configuration
231    info!("⬇️ Downloading ProductConfig...");
232    if dry_run {
233        info!(
234            "🔍 Would download ProductConfig: {}",
235            version_entry.product_config
236        );
237    } else {
238        let product_config_response = cdn_client
239            .download_product_config(
240                cdn_host,
241                &cdn_entry.config_path,
242                &version_entry.product_config,
243            )
244            .await?;
245
246        let product_config_path = output.join("product_config");
247        tokio::fs::write(&product_config_path, product_config_response.bytes().await?).await?;
248        info!("💾 Saved ProductConfig to: {:?}", product_config_path);
249    }
250
251    // Download keyring if available
252    if let Some(keyring_hash) = &version_entry.key_ring {
253        info!("⬇️ Downloading KeyRing...");
254        if dry_run {
255            info!("🔍 Would download KeyRing: {}", keyring_hash);
256        } else {
257            let keyring_response = cdn_client
258                .download_key_ring(cdn_host, &cdn_entry.path, keyring_hash)
259                .await?;
260
261            let keyring_path = output.join("keyring");
262            tokio::fs::write(&keyring_path, keyring_response.bytes().await?).await?;
263            info!("💾 Saved KeyRing to: {:?}", keyring_path);
264        }
265    }
266
267    if dry_run {
268        info!("✅ Dry run completed - showed what would be downloaded");
269    } else {
270        // Generate .build.info file for compatibility with install commands
271        info!("📄 Writing .build.info file...");
272
273        let region_enum = match region {
274            Region::US => ribbit_client::Region::US,
275            Region::EU => ribbit_client::Region::EU,
276            Region::KR => ribbit_client::Region::KR,
277            Region::TW => ribbit_client::Region::TW,
278            _ => ribbit_client::Region::US,
279        };
280
281        write_build_info_for_download(
282            output,
283            product,
284            version_entry,
285            &version_entry.build_config,
286            &version_entry.cdn_config,
287            cdn_entry,
288            region_enum,
289        )
290        .await?;
291
292        info!("✓ .build.info file written");
293        info!("✅ Build download completed successfully!");
294        info!("📂 Files saved to: {:?}", output);
295        info!(
296            "💡 Use 'ngdp download resume {}' to continue incomplete installations",
297            output.display()
298        );
299    }
300
301    Ok(())
302}
303
304/// Download specific files by patterns (content keys, encoding keys, or paths)
305async fn download_files(
306    product: &str,
307    patterns: &[String],
308    output: &Path,
309    build: Option<String>,
310    dry_run: bool,
311    tags: Option<String>,
312    limit: Option<usize>,
313) -> Result<(), Box<dyn std::error::Error>> {
314    info!(
315        "📋 Initializing pattern-based file download for {} with {} patterns",
316        product,
317        patterns.len()
318    );
319
320    if dry_run {
321        info!("🔍 DRY RUN mode - analyzing patterns and showing matches");
322    }
323
324    if let Some(tags) = &tags {
325        info!("🏷️ Filtering by tags: {}", tags);
326    }
327
328    if let Some(limit) = limit {
329        info!("📊 Limiting to {} files per pattern", limit);
330    }
331
332    // Create output directory
333    tokio::fs::create_dir_all(output).await?;
334    info!("📁 Created output directory: {:?}", output);
335
336    // Initialize pattern extractor with configuration
337    let pattern_config = PatternConfig {
338        max_matches_per_pattern: limit,
339        ..Default::default()
340    };
341
342    let mut extractor = PatternExtractor::with_config(pattern_config);
343
344    // Add all patterns to the extractor
345    for pattern in patterns {
346        match extractor.add_pattern(pattern) {
347            Ok(()) => info!("✅ Added pattern: {}", pattern),
348            Err(e) => {
349                error!("❌ Invalid pattern '{}': {}", pattern, e);
350                return Err(format!("Invalid pattern '{pattern}': {e}").into());
351            }
352        }
353    }
354
355    // Show pattern statistics
356    let stats = extractor.get_stats();
357    info!("📊 Pattern Analysis:");
358    info!("  • Total patterns: {}", stats.total_patterns);
359    info!("  • Glob patterns: {}", stats.glob_patterns);
360    info!("  • Regex patterns: {}", stats.regex_patterns);
361    info!("  • Content keys: {}", stats.content_keys);
362    info!("  • Encoding keys: {}", stats.encoding_keys);
363    info!("  • File paths: {}", stats.file_paths);
364
365    if dry_run {
366        // For dry run, demonstrate pattern matching with sample data
367        info!("🔍 DRY RUN: Demonstrating pattern matching with sample file list");
368
369        let sample_files = get_sample_file_list();
370        let matches = extractor.match_files(&sample_files);
371
372        if matches.is_empty() {
373            info!("📝 No matches found in sample data");
374            info!("💡 Sample files available for testing:");
375            for (i, file) in sample_files.iter().take(10).enumerate() {
376                info!("  {}: {}", i + 1, file);
377            }
378        } else {
379            info!("🎯 Found {} pattern matches in sample data:", matches.len());
380
381            for (i, pattern_match) in matches.iter().take(20).enumerate() {
382                info!(
383                    "  {}: {} (pattern: {}, priority: {})",
384                    i + 1,
385                    pattern_match.file_path,
386                    pattern_match.pattern,
387                    pattern_match.metadata.priority_score
388                );
389            }
390
391            if matches.len() > 20 {
392                info!("  ... and {} more matches", matches.len() - 20);
393            }
394        }
395
396        info!("✅ Dry run completed - patterns would be applied to real manifest data");
397        return Ok(());
398    }
399
400    // Initialize clients for actual download
401    let region = Region::US; // Default region, could be parameterized
402    let ribbit_client = CachedRibbitClient::new(region).await?;
403    let cdn_client = CachedCdnClient::new().await?;
404
405    info!("🌐 Getting product versions from Ribbit...");
406    let versions = ribbit_client.get_product_versions(product).await?;
407
408    // Find the specific build or use latest
409    let version_entry = if let Some(build_id) = build {
410        versions
411            .entries
412            .iter()
413            .find(|v| v.build_id.to_string() == build_id || v.versions_name == build_id)
414            .ok_or_else(|| format!("Build '{build_id}' not found for product '{product}'"))?
415    } else {
416        versions
417            .entries
418            .first()
419            .ok_or("No versions available for product")?
420    };
421
422    info!(
423        "📦 Found build: {} ({})",
424        version_entry.versions_name, version_entry.build_id
425    );
426
427    // Get CDN configuration
428    info!("🌐 Getting CDN configuration...");
429    let cdns = ribbit_client.get_product_cdns(product).await?;
430    let cdn_entry = cdns.entries.first().ok_or("No CDN servers available")?;
431    let cdn_host = cdn_entry.hosts.first().ok_or("No CDN hosts available")?;
432
433    info!("🔗 Using CDN host: {}", cdn_host);
434
435    // Download and parse build configuration to get manifest hashes
436    info!("⬇️ Downloading BuildConfig...");
437    let build_config_response = cdn_client
438        .download_build_config(cdn_host, &cdn_entry.path, &version_entry.build_config)
439        .await?;
440
441    let build_config_data = build_config_response.bytes().await?;
442
443    // Parse build configuration to extract manifest file hashes
444    let build_config_text = String::from_utf8_lossy(&build_config_data);
445
446    info!("📋 Parsing BuildConfig to extract manifest hashes...");
447    let (encoding_hash, root_hash, install_hash) = parse_build_config_hashes(&build_config_text)?;
448
449    info!("🔑 Found manifest hashes:");
450    info!("  • Encoding: {}", encoding_hash);
451    info!("  • Root: {}", root_hash.as_deref().unwrap_or("None"));
452    info!("  • Install: {}", install_hash.as_deref().unwrap_or("None"));
453
454    // For now, demonstrate what would happen with real manifest integration
455    info!("🚧 Next steps for full implementation:");
456    info!("  1. Download and decompress BLTE-encoded encoding file");
457    info!("  2. Parse encoding file to build CKey → EKey mapping");
458    info!("  3. Download and decompress root file if available");
459    info!("  4. Parse root file to build path → CKey mapping");
460    info!("  5. Apply patterns to real file list from manifest");
461    info!("  6. Download matched files from CDN data endpoint");
462    info!("  7. Decompress BLTE data and save with directory structure");
463
464    // Apply patterns to mock data for demonstration
465    let mock_file_list = get_comprehensive_file_list();
466    let matches = extractor.match_files(&mock_file_list);
467
468    if matches.is_empty() {
469        warn!("📝 No pattern matches found");
470        return Ok(());
471    }
472
473    info!(
474        "🎯 Pattern matching results: {} files matched",
475        matches.len()
476    );
477
478    // Show what files would be downloaded
479    for (i, pattern_match) in matches.iter().take(limit.unwrap_or(10)).enumerate() {
480        info!(
481            "  {}: {} (pattern: '{}', priority: {})",
482            i + 1,
483            pattern_match.file_path,
484            pattern_match.pattern,
485            pattern_match.metadata.priority_score
486        );
487
488        // Show file type if detected
489        if let Some(file_type) = &pattern_match.metadata.file_type {
490            debug!("    File type: {}", file_type);
491        }
492    }
493
494    info!("✅ Pattern-based file extraction analysis completed!");
495    info!("💡 Use --dry-run to see pattern matching without attempting downloads");
496
497    warn!(
498        "🚧 Full manifest integration and download implementation pending TACT parser integration"
499    );
500
501    Ok(())
502}
503
504type BuildConfigResult =
505    Result<(String, Option<String>, Option<String>), Box<dyn std::error::Error>>;
506
507/// Parse build configuration to extract manifest file hashes
508fn parse_build_config_hashes(build_config: &str) -> BuildConfigResult {
509    let mut encoding_hash = None;
510    let mut root_hash = None;
511    let mut install_hash = None;
512
513    for line in build_config.lines() {
514        let line = line.trim();
515        if line.starts_with("encoding = ") {
516            encoding_hash = Some(
517                line.split_whitespace()
518                    .nth(2)
519                    .unwrap_or_default()
520                    .to_string(),
521            );
522        } else if line.starts_with("root = ") {
523            root_hash = Some(
524                line.split_whitespace()
525                    .nth(2)
526                    .unwrap_or_default()
527                    .to_string(),
528            );
529        } else if line.starts_with("install = ") {
530            install_hash = Some(
531                line.split_whitespace()
532                    .nth(2)
533                    .unwrap_or_default()
534                    .to_string(),
535            );
536        }
537    }
538
539    let encoding = encoding_hash.ok_or("No encoding hash found in build config")?;
540
541    Ok((encoding, root_hash, install_hash))
542}
543
544/// Get sample file list for pattern testing
545fn get_sample_file_list() -> Vec<String> {
546    vec![
547        "achievement.dbc".to_string(),
548        "spell.dbc".to_string(),
549        "item.db2".to_string(),
550        "world/maps/azeroth/azeroth.wdt".to_string(),
551        "interface/framexml/uiparent.lua".to_string(),
552        "interface/addons/blizzard_auctionui/blizzard_auctionui.lua".to_string(),
553        "sound/music/zonemusic/stormwind.ogg".to_string(),
554        "sound/spells/frostbolt.ogg".to_string(),
555        "textures/interface/buttons/ui-button.blp".to_string(),
556        "creature/human/male/humanmale.m2".to_string(),
557        "world/wmo/stormwind/stormwind_keep.wmo".to_string(),
558    ]
559}
560
561/// Get comprehensive file list for pattern testing
562fn get_comprehensive_file_list() -> Vec<String> {
563    vec![
564        // Database files
565        "achievement.dbc".to_string(),
566        "spell.dbc".to_string(),
567        "item.db2".to_string(),
568        "creature.dbc".to_string(),
569        "gameobject.dbc".to_string(),
570        // Interface files
571        "interface/framexml/uiparent.lua".to_string(),
572        "interface/framexml/worldframe.lua".to_string(),
573        "interface/framexml/chatframe.lua".to_string(),
574        "interface/addons/blizzard_auctionui/blizzard_auctionui.lua".to_string(),
575        "interface/addons/blizzard_raidui/blizzard_raidui.lua".to_string(),
576        "interface/framexml/uiparent.xml".to_string(),
577        // Sound files
578        "sound/music/zonemusic/stormwind.ogg".to_string(),
579        "sound/music/zonemusic/ironforge.ogg".to_string(),
580        "sound/spells/frostbolt.ogg".to_string(),
581        "sound/spells/fireball.ogg".to_string(),
582        "sound/creature/human/humanvoicemale01.ogg".to_string(),
583        // Texture files
584        "textures/interface/buttons/ui-button.blp".to_string(),
585        "textures/interface/icons/spell_frost_frostbolt.blp".to_string(),
586        "textures/world/azeroth/stormwind/stormwind_cobblestone.blp".to_string(),
587        "textures/character/human/male/humanmale_face00_00.blp".to_string(),
588        // 3D Models
589        "creature/human/male/humanmale.m2".to_string(),
590        "creature/orc/male/orcmale.m2".to_string(),
591        "item/weapon/sword/2h_sword_01.m2".to_string(),
592        // World files
593        "world/maps/azeroth/azeroth.wdt".to_string(),
594        "world/maps/azeroth/azeroth_31_49.adt".to_string(),
595        "world/wmo/stormwind/stormwind_keep.wmo".to_string(),
596        "world/wmo/ironforge/ironforge_main.wmo".to_string(),
597        // Misc files
598        "fonts/frizqt__.ttf".to_string(),
599        "tileset/generic/dirt.blp".to_string(),
600        "character/human/male/humanmale.skin".to_string(),
601        "character/bloodelf/female/bloodelffemale.skin".to_string(),
602    ]
603}
604
605/// Resume a download from a progress file, directory, or installation with .build.info
606async fn resume_download(session: &str) -> Result<(), Box<dyn std::error::Error>> {
607    let session_path = PathBuf::from(session);
608
609    if session_path.is_dir() {
610        // Check if this is an installation directory with .build.info
611        let build_info_path = session_path.join(".build.info");
612        if build_info_path.exists() {
613            info!(
614                "🏗️ Detected installation directory with .build.info: {:?}",
615                session_path
616            );
617            return resume_from_installation(&session_path).await;
618        }
619
620        // Find all resumable downloads in the directory (existing behavior)
621        info!(
622            "🔍 Searching for resumable downloads in: {:?}",
623            session_path
624        );
625        let downloads = find_resumable_downloads(&session_path).await?;
626
627        if downloads.is_empty() {
628            warn!("No resumable downloads found in directory");
629            return Ok(());
630        }
631
632        info!("Found {} resumable download(s):", downloads.len());
633        for (i, progress) in downloads.iter().enumerate() {
634            info!(
635                "  {}: {} - {}",
636                i + 1,
637                progress.file_hash,
638                progress.progress_string()
639            );
640        }
641
642        // Resume the first one (in a real CLI, you'd prompt for choice)
643        let progress = &downloads[0];
644        info!("Resuming first download: {}", progress.file_hash);
645
646        let client = create_tact_client().await?;
647        let mut resumable_download = ResumableDownload::new(client, progress.clone());
648        resumable_download.start_or_resume().await?;
649        resumable_download.cleanup_completed().await?;
650    } else if session_path.extension().and_then(|s| s.to_str()) == Some("download") {
651        // Resume specific progress file
652        info!("📂 Loading progress from: {:?}", session_path);
653        let progress = DownloadProgress::load_from_file(&session_path).await?;
654
655        info!(
656            "Resuming: {} - {}",
657            progress.file_hash,
658            progress.progress_string()
659        );
660
661        let client = create_tact_client().await?;
662        let mut resumable_download = ResumableDownload::new(client, progress);
663        resumable_download.start_or_resume().await?;
664        resumable_download.cleanup_completed().await?;
665    } else {
666        return Err(format!(
667            "Invalid session path: {session}. Must be a directory, .download file, or installation with .build.info"
668        )
669        .into());
670    }
671
672    Ok(())
673}
674
675/// Resume download from an existing installation with .build.info and Data/config structure
676async fn resume_from_installation(install_path: &Path) -> Result<(), Box<dyn std::error::Error>> {
677    info!("📋 Reading installation metadata from .build.info...");
678
679    // Read and parse .build.info file
680    let build_info_path = install_path.join(".build.info");
681    let build_info_content = tokio::fs::read_to_string(&build_info_path).await?;
682
683    // Parse BPSV format to extract product, version, and CDN information
684    let build_info = ngdp_bpsv::BpsvDocument::parse(&build_info_content)?;
685
686    // Extract key information from .build.info
687    let rows = build_info.rows();
688    if rows.is_empty() {
689        return Err("No entries found in .build.info file".into());
690    }
691
692    let schema = build_info.schema();
693    let row = &rows[0]; // Use first entry
694    let product = row
695        .get_raw_by_name("Product", schema)
696        .ok_or("Product not found in .build.info")?;
697    let version = row
698        .get_raw_by_name("Version", schema)
699        .ok_or("Version not found in .build.info")?;
700    let branch = row
701        .get_raw_by_name("Branch", schema)
702        .ok_or("Branch not found in .build.info")?;
703    let build_key = row
704        .get_raw_by_name("Build Key", schema)
705        .ok_or("Build Key not found in .build.info")?;
706    let cdn_path = row
707        .get_raw_by_name("CDN Path", schema)
708        .ok_or("CDN Path not found in .build.info")?;
709    let cdn_hosts_str = row
710        .get_raw_by_name("CDN Hosts", schema)
711        .ok_or("CDN Hosts not found in .build.info")?;
712
713    // Parse CDN hosts (space-separated)
714    let cdn_hosts: Vec<&str> = cdn_hosts_str.split_whitespace().collect();
715    let cdn_host = cdn_hosts.first().ok_or("No CDN hosts available")?;
716
717    info!("📦 Installation details:");
718    info!("  • Product: {}", product);
719    info!("  • Version: {}", version);
720    info!("  • Branch: {}", branch);
721    info!("  • Build Key: {}", build_key);
722    info!("  • CDN Host: {}", cdn_host);
723    info!("  • CDN Path: {}", cdn_path);
724
725    // Read build configuration from Data/config/ structure
726    let build_config_subdir = format!("{}/{}", &build_key[0..2], &build_key[2..4]);
727    let build_config_path = install_path
728        .join("Data/config")
729        .join(&build_config_subdir)
730        .join(build_key);
731
732    if !build_config_path.exists() {
733        return Err(format!(
734            "Build configuration not found at: {}. Run metadata-only installation first.",
735            build_config_path.display()
736        )
737        .into());
738    }
739
740    let build_config_data = tokio::fs::read_to_string(&build_config_path).await?;
741    let build_config = tact_parser::config::BuildConfig::parse(&build_config_data)?;
742
743    info!("✓ Loaded build configuration from local cache");
744
745    // Initialize CDN client for downloading
746    let cdn_client = CachedCdnClient::new().await?;
747
748    // Get install manifest information
749    let install_value = build_config
750        .config
751        .get_value("install")
752        .ok_or("Missing install field in build config")?;
753    let install_parts: Vec<&str> = install_value.split_whitespace().collect();
754
755    // Use encoding key if available, otherwise use content key
756    let install_ekey = if install_parts.len() >= 2 {
757        install_parts[1].to_string()
758    } else {
759        // Need to look up content key in encoding file first
760        return Err("Install manifest content key lookup not yet implemented for resume. Use direct encoding key.".into());
761    };
762
763    info!(
764        "📥 Resuming installation using install manifest: {}",
765        install_ekey
766    );
767
768    // Download and parse install manifest
769    let install_data = cdn_client
770        .download_data(cdn_host, cdn_path, &install_ekey)
771        .await?
772        .bytes()
773        .await?;
774
775    let install_data = if install_data.starts_with(b"BLTE") {
776        blte::decompress_blte(install_data.to_vec(), None)?
777    } else {
778        install_data.to_vec()
779    };
780
781    let install_manifest = tact_parser::install::InstallManifest::parse(&install_data)?;
782
783    info!(
784        "📋 Install manifest loaded: {} files",
785        install_manifest.entries.len()
786    );
787
788    // Check which files are missing from Data/data/
789    let data_dir = install_path.join("Data/data");
790    tokio::fs::create_dir_all(&data_dir).await?;
791
792    let mut missing_files = Vec::new();
793    let mut total_missing_size = 0u64;
794
795    info!("🔍 Checking for missing files...");
796    for entry in &install_manifest.entries {
797        // For install manifest, we need encoding key to download
798        // For now, assume the path contains the encoding key (simplified)
799        let ckey_hex = hex::encode(&entry.ckey);
800        let expected_path = data_dir.join(&ckey_hex);
801
802        if !expected_path.exists() {
803            missing_files.push(entry);
804            total_missing_size += entry.size as u64;
805        }
806    }
807
808    if missing_files.is_empty() {
809        info!("✅ No missing files found - installation appears complete!");
810        return Ok(());
811    }
812
813    info!(
814        "📊 Found {} missing files ({} bytes total)",
815        missing_files.len(),
816        format_bytes(total_missing_size)
817    );
818
819    // For now, just report what would be downloaded
820    info!("🚧 Resume functionality implementation in progress");
821    info!("📋 Missing files that would be downloaded:");
822    for (i, entry) in missing_files.iter().take(10).enumerate() {
823        info!("  {}: {} ({} bytes)", i + 1, entry.path, entry.size);
824    }
825
826    if missing_files.len() > 10 {
827        info!("  ... and {} more files", missing_files.len() - 10);
828    }
829
830    info!(
831        "💡 Use 'ngdp install game {} --path {} --resume' for full resume functionality",
832        product,
833        install_path.display()
834    );
835
836    Ok(())
837}
838
839/// Format bytes to human-readable string
840fn format_bytes(bytes: u64) -> String {
841    const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
842    let mut size = bytes as f64;
843    let mut unit_index = 0;
844
845    while size >= 1024.0 && unit_index < UNITS.len() - 1 {
846        size /= 1024.0;
847        unit_index += 1;
848    }
849
850    format!("{:.2} {}", size, UNITS[unit_index])
851}
852
853/// Test resumable download functionality
854async fn test_resumable_download(
855    hash: &str,
856    _host: &str,
857    output: &Path,
858    resumable: bool,
859) -> Result<(), Box<dyn std::error::Error>> {
860    // Validate hash format
861    if hash.len() != 32 || !hash.chars().all(|c| c.is_ascii_hexdigit()) {
862        return Err("Invalid hash format. Expected 32 hex characters.".into());
863    }
864
865    info!("🚀 Starting test download");
866    info!("📋 Hash: {}", hash);
867    info!("📁 Output: {:?}", output);
868    info!("🔄 Resumable: {}", resumable);
869
870    if resumable {
871        // Use resumable download
872        info!("📥 Starting resumable download...");
873
874        let progress = DownloadProgress::new(
875            hash.to_string(),
876            "blzddist1-a.akamaihd.net".to_string(),
877            "/tpr/wow/data".to_string(),
878            output.to_path_buf(),
879        );
880
881        let client = create_tact_client().await?;
882        let mut resumable_download = ResumableDownload::new(client, progress);
883
884        resumable_download.start_or_resume().await?;
885        resumable_download.cleanup_completed().await?;
886    } else {
887        // Use CDN client with fallback for regular download
888        info!("📥 Starting regular CDN download with fallback...");
889
890        let cdn_client = CachedCdnClient::new().await?;
891        // Add fallback hosts for better reliability
892        cdn_client.add_fallback_host("cdn.arctium.tools");
893        cdn_client.add_fallback_host("tact.mirror.reliquaryhq.com");
894        let response = cdn_client
895            .download_data("cdn.blizzard.com", "/tpr/wow", hash)
896            .await?;
897        let bytes = response.bytes().await?;
898
899        tokio::fs::write(output, bytes).await?;
900        info!("💾 Saved to: {:?}", output);
901    }
902
903    // Show file info
904    if let Ok(metadata) = tokio::fs::metadata(output).await {
905        info!("📊 Downloaded {} bytes", metadata.len());
906    }
907
908    Ok(())
909}
910
911/// Create a TACT HTTP client configured for downloads
912async fn create_tact_client() -> Result<HttpClient, Box<dyn std::error::Error>> {
913    let client = HttpClient::new(TactRegion::US, TactProtocolVersion::V2)?
914        .with_max_retries(3)
915        .with_initial_backoff_ms(1000)
916        .with_user_agent("ngdp-client/0.3.1");
917
918    Ok(client)
919}
920
921/// Write .build.info file for downloaded build configurations
922async fn write_build_info_for_download(
923    output_path: &Path,
924    product: &str,
925    version_entry: &ribbit_client::VersionEntry,
926    build_config_hash: &str,
927    cdn_config_hash: &str,
928    cdn_entry: &ribbit_client::CdnEntry,
929    region: ribbit_client::Region,
930) -> Result<(), Box<dyn std::error::Error>> {
931    // For download command, we may not have parsed the build config yet
932    // Use placeholder values and let the user know
933    let install_key = ""; // Empty - not available without parsing build config
934
935    // Create CDN hosts string (space-separated)
936    let cdn_hosts = cdn_entry.hosts.join(" ");
937
938    // Create CDN servers string (space-separated with parameters)
939    let cdn_servers = if cdn_entry.servers.is_empty() {
940        // Generate default server URLs from hosts if servers list is empty
941        cdn_entry
942            .hosts
943            .iter()
944            .flat_map(|host| {
945                vec![
946                    format!("http://{}/?maxhosts=4", host),
947                    format!("https://{}/?maxhosts=4&fallback=1", host),
948                ]
949            })
950            .collect::<Vec<_>>()
951            .join(" ")
952    } else {
953        cdn_entry.servers.join(" ")
954    };
955
956    // Generate basic tags (platform/architecture)
957    let tags = format!(
958        "Windows x86_64 {}? acct-{}?",
959        region.as_str().to_uppercase(),
960        region.as_str().to_uppercase()
961    );
962
963    // Build .build.info using BPSV builder
964    let mut builder = BpsvBuilder::new();
965
966    // Add fields according to .build.info schema
967    builder.add_field("Branch", BpsvFieldType::String(0))?;
968    builder.add_field("Active", BpsvFieldType::Decimal(1))?;
969    builder.add_field("Build Key", BpsvFieldType::Hex(16))?;
970    builder.add_field("CDN Key", BpsvFieldType::Hex(16))?;
971    builder.add_field("Install Key", BpsvFieldType::Hex(16))?;
972    builder.add_field("IM Size", BpsvFieldType::Decimal(4))?;
973    builder.add_field("CDN Path", BpsvFieldType::String(0))?;
974    builder.add_field("CDN Hosts", BpsvFieldType::String(0))?;
975    builder.add_field("CDN Servers", BpsvFieldType::String(0))?;
976    builder.add_field("Tags", BpsvFieldType::String(0))?;
977    builder.add_field("Armadillo", BpsvFieldType::String(0))?;
978    builder.add_field("Last Activated", BpsvFieldType::String(0))?;
979    builder.add_field("Version", BpsvFieldType::String(0))?;
980    builder.add_field("KeyRing", BpsvFieldType::Hex(16))?;
981    builder.add_field("Product", BpsvFieldType::String(0))?;
982
983    // Add the data row
984    builder.add_row(vec![
985        BpsvValue::String(region.as_str().to_string()), // Branch
986        BpsvValue::Decimal(1),                          // Active (always 1)
987        BpsvValue::Hex(build_config_hash.to_string()),  // Build Key
988        BpsvValue::Hex(cdn_config_hash.to_string()),    // CDN Key
989        BpsvValue::Hex(install_key.to_string()),        // Install Key (empty)
990        BpsvValue::Decimal(0),                          // IM Size (empty)
991        BpsvValue::String(cdn_entry.path.clone()),      // CDN Path
992        BpsvValue::String(cdn_hosts),                   // CDN Hosts
993        BpsvValue::String(cdn_servers),                 // CDN Servers
994        BpsvValue::String(tags),                        // Tags
995        BpsvValue::String(String::new()),               // Armadillo (empty)
996        BpsvValue::String(String::new()),               // Last Activated (empty)
997        BpsvValue::String(version_entry.versions_name.clone()), // Version
998        BpsvValue::Hex(version_entry.key_ring.as_deref().unwrap_or("").to_string()), // KeyRing
999        BpsvValue::String(product.to_string()),         // Product
1000    ])?;
1001
1002    // Build the BPSV content
1003    let build_info_content = builder.build_string()?;
1004
1005    // Write .build.info file to output directory
1006    let build_info_path = output_path.join(".build.info");
1007    tokio::fs::write(&build_info_path, build_info_content).await?;
1008
1009    debug!("Written .build.info to: {}", build_info_path.display());
1010    Ok(())
1011}