// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements.  See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership.  The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License.  You may obtain a copy of the License at
//
//   http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied.  See the License for the
// specific language governing permissions and limitations
// under the License.

//! Collect deduplicated referenced file size summaries for all snapshots of a table.
//!
//! Reference: [LocalOrphanFilesClean](https://github.com/apache/paimon/blob/master/paimon-core/src/main/java/org/apache/paimon/operation/LocalOrphanFilesClean.java)

use std::collections::HashMap;
use std::sync::Mutex;

use crate::io::FileIO;
use crate::spec::{
    bucket_dir_name, BinaryRow, DataField, DataFileMeta, IndexManifest, Manifest, ManifestEntry,
    ManifestFileMeta, PartitionComputer,
};
use crate::table::{BranchManager, SnapshotManager, TagManager};
use futures::future::try_join_all;
use futures::stream::{self, StreamExt, TryStreamExt};

/// Per-scope aggregated summary of referenced files (deduplicated).
///
/// Each row represents the unique referenced files for a scope:
/// - `"total"`: all snapshots across all branches and tags
/// - `"branch:main"`: main branch snapshots + tags
/// - `"branch:<name>"`: a specific branch
///
/// Files are deduplicated by file name within each scope, so the sum
/// represents actual disk usage that is still referenced (protected from cleanup).
/// Both ADD and DELETE manifest entries are included since both reference
/// physical files that cannot be removed until the snapshot expires.
#[derive(Debug, Clone, Default)]
pub struct ReferencedFilesSummary {
    pub source: String,
    pub manifest_file_count: i64,
    pub manifest_file_size: i64,
    pub data_file_count: i64,
    pub data_file_size: i64,
    pub index_file_count: i64,
    pub index_file_size: i64,
}

/// Deduplicated file set for a scope, keyed by file name.
#[derive(Default)]
struct ScopeFileSet {
    manifest_files: HashMap<String, i64>,
    data_files: HashMap<String, i64>,
    index_files: HashMap<String, i64>,
}

impl ScopeFileSet {
    fn to_summary(&self, source: &str) -> ReferencedFilesSummary {
        ReferencedFilesSummary {
            source: source.to_string(),
            manifest_file_count: self.manifest_files.len() as i64,
            manifest_file_size: self.manifest_files.values().sum(),
            data_file_count: self.data_files.len() as i64,
            data_file_size: self.data_files.values().sum(),
            index_file_count: self.index_files.len() as i64,
            index_file_size: self.index_files.values().sum(),
        }
    }

    fn merge(&mut self, other: &ScopeFileSet) {
        for (k, v) in &other.manifest_files {
            self.manifest_files.entry(k.clone()).or_insert(*v);
        }
        for (k, v) in &other.data_files {
            self.data_files.entry(k.clone()).or_insert(*v);
        }
        for (k, v) in &other.index_files {
            self.index_files.entry(k.clone()).or_insert(*v);
        }
    }
}

const SNAPSHOT_CONCURRENCY: usize = 32;

/// Cached data file entries (file_name, file_size) per manifest file full path.
type ManifestCache = Mutex<HashMap<String, Vec<(String, i64)>>>;

/// Resolves extra file paths for stat-ing their real sizes.
struct ExtraFileResolver {
    table_location: String,
    partition_computer: Option<PartitionComputer>,
}

impl ExtraFileResolver {
    fn new(table_location: &str, partition_keys: &[String], schema_fields: &[DataField]) -> Self {
        let partition_computer = if partition_keys.is_empty() {
            None
        } else {
            PartitionComputer::new(
                partition_keys,
                schema_fields,
                "__DEFAULT_PARTITION__",
                false,
            )
            .ok()
        };
        Self {
            table_location: table_location.to_string(),
            partition_computer,
        }
    }

    fn resolve_bucket_path(&self, partition_bytes: &[u8], bucket: i32) -> Option<String> {
        let partition_path = if let Some(ref computer) = self.partition_computer {
            let row = BinaryRow::from_serialized_bytes(partition_bytes).ok()?;
            computer.generate_partition_path(&row).ok()?
        } else {
            String::new()
        };
        let bucket_dir = bucket_dir_name(bucket);
        Some(format!(
            "{}/{}{}",
            self.table_location, partition_path, bucket_dir
        ))
    }

    fn resolve_extra_file_path(
        &self,
        partition_bytes: &[u8],
        bucket: i32,
        data_file: &DataFileMeta,
        extra_file_name: &str,
    ) -> Option<String> {
        let bucket_path = self.resolve_bucket_path(partition_bytes, bucket)?;
        Some(data_file.aligned_file_path(&bucket_path, extra_file_name))
    }
}

/// Collect per-scope deduplicated referenced file size summaries for a table.
///
/// Returns rows:
/// 1. `"total"` — union of all referenced files from main branch, tags, and branches
/// 2. `"branch:main"` — main branch snapshots + tag snapshots
/// 3. `"branch:<name>"` — one row per branch
///
/// Snapshots are processed concurrently (up to 32 at a time). Within each
/// snapshot, manifest list and manifest file reads are also concurrent.
/// A shared cache avoids re-reading the same manifest file across snapshots.
///
/// Files are deduplicated by name within each scope to produce an accurate
/// count of unique referenced files. Both ADD and DELETE entries are included
/// since both reference physical files protected from cleanup.
///
/// Manifest list files and index manifest files are counted as manifest files,
/// consistent with `physical_files_size` classification.
///
/// Extra files referenced by data file entries are stat-ed to obtain their
/// real sizes, using partition/bucket info to construct full paths.
pub async fn collect_referenced_files_summary(
    file_io: &FileIO,
    table_location: &str,
    partition_keys: &[String],
    schema_fields: &[DataField],
) -> crate::Result<Vec<ReferencedFilesSummary>> {
    let manifest_cache: ManifestCache = Mutex::new(HashMap::new());
    let manifest_cache_ref = &manifest_cache;
    let extra_resolver = ExtraFileResolver::new(table_location, partition_keys, schema_fields);
    let extra_resolver_ref = &extra_resolver;

    let sm = SnapshotManager::new(file_io.clone(), table_location.to_string());
    let tm = TagManager::new(file_io.clone(), table_location.to_string());

    // 1. Main branch snapshots + tags (concurrently)
    // For main branch, snapshot reading and manifest resolution both use root SM.
    let (main_files, tag_files) = tokio::try_join!(
        collect_scope_files(file_io, &sm, &sm, manifest_cache_ref, extra_resolver_ref),
        collect_tag_files(
            file_io,
            &sm,
            &sm,
            &tm,
            manifest_cache_ref,
            extra_resolver_ref
        ),
    )?;
    let mut main_files = main_files;
    main_files.merge(&tag_files);

    // 2. Branch file sets (all branches concurrently)
    let bm = BranchManager::new(file_io.clone(), table_location.to_string());
    let branch_names = bm.list_all().await?;

    let sm_ref = &sm;
    let branch_futures: Vec<_> = branch_names
        .iter()
        .map(|branch_name| {
            let branch_sm = sm.with_branch(branch_name);
            let branch_tm = tm.with_branch(branch_name);
            async move {
                // Branch SM reads snapshot/tag files from branch path,
                // but manifest paths are always resolved from the table root.
                let (mut branch_files, branch_tag_files) = tokio::try_join!(
                    collect_scope_files(
                        file_io,
                        &branch_sm,
                        sm_ref,
                        manifest_cache_ref,
                        extra_resolver_ref
                    ),
                    collect_tag_files(
                        file_io,
                        &branch_sm,
                        sm_ref,
                        &branch_tm,
                        manifest_cache_ref,
                        extra_resolver_ref
                    ),
                )?;
                branch_files.merge(&branch_tag_files);
                Ok::<_, crate::Error>(branch_files)
            }
        })
        .collect();
    let branch_results = try_join_all(branch_futures).await?;

    // 3. Assemble output: total, main, branches
    let mut total_files = ScopeFileSet::default();
    total_files.merge(&main_files);
    for bs in &branch_results {
        total_files.merge(bs);
    }

    let mut result = vec![
        total_files.to_summary("total"),
        main_files.to_summary("branch:main"),
    ];
    for (name, files) in branch_names.iter().zip(&branch_results) {
        result.push(files.to_summary(&format!("branch:{name}")));
    }
    Ok(result)
}

async fn collect_scope_files(
    file_io: &FileIO,
    sm: &SnapshotManager,
    manifest_sm: &SnapshotManager,
    manifest_cache: &ManifestCache,
    extra_resolver: &ExtraFileResolver,
) -> crate::Result<ScopeFileSet> {
    let snapshot_ids = sm.list_all_ids().await?;

    let per_snapshot: Vec<Option<ScopeFileSet>> = stream::iter(snapshot_ids)
        .map(|snapshot_id| {
            let sm = sm.clone();
            async move {
                collect_single_snapshot_files(
                    file_io,
                    &sm,
                    manifest_sm,
                    snapshot_id,
                    manifest_cache,
                    extra_resolver,
                )
                .await
            }
        })
        .buffer_unordered(SNAPSHOT_CONCURRENCY)
        .try_collect()
        .await?;

    let mut merged = ScopeFileSet::default();
    for fs in per_snapshot.into_iter().flatten() {
        merged.merge(&fs);
    }
    Ok(merged)
}

async fn collect_tag_files(
    file_io: &FileIO,
    _sm: &SnapshotManager,
    manifest_sm: &SnapshotManager,
    tm: &TagManager,
    manifest_cache: &ManifestCache,
    extra_resolver: &ExtraFileResolver,
) -> crate::Result<ScopeFileSet> {
    let tag_names = tm.list_all_names().await?;

    let tag_futures: Vec<_> = tag_names
        .iter()
        .map(|tag_name| async move {
            let snapshot = match tm.get(tag_name).await? {
                Some(s) => s,
                None => return Ok(None),
            };
            collect_snapshot_files(
                file_io,
                manifest_sm,
                &snapshot,
                manifest_cache,
                extra_resolver,
            )
            .await
        })
        .collect();
    let tag_results = try_join_all(tag_futures).await?;

    let mut merged = ScopeFileSet::default();
    for fs in tag_results.into_iter().flatten() {
        merged.merge(&fs);
    }
    Ok(merged)
}

async fn collect_single_snapshot_files(
    file_io: &FileIO,
    sm: &SnapshotManager,
    manifest_sm: &SnapshotManager,
    snapshot_id: i64,
    manifest_cache: &ManifestCache,
    extra_resolver: &ExtraFileResolver,
) -> crate::Result<Option<ScopeFileSet>> {
    let snapshot = match try_get_snapshot(sm, snapshot_id).await? {
        Some(s) => s,
        None => return Ok(None),
    };

    collect_snapshot_files(
        file_io,
        manifest_sm,
        &snapshot,
        manifest_cache,
        extra_resolver,
    )
    .await
}

async fn collect_snapshot_files(
    file_io: &FileIO,
    manifest_sm: &SnapshotManager,
    snapshot: &crate::spec::Snapshot,
    manifest_cache: &ManifestCache,
    extra_resolver: &ExtraFileResolver,
) -> crate::Result<Option<ScopeFileSet>> {
    let mut file_set = ScopeFileSet::default();

    // Collect manifest list file names (these are manifest-type files themselves)
    let mut manifest_list_names = vec![
        snapshot.base_manifest_list().to_string(),
        snapshot.delta_manifest_list().to_string(),
    ];
    if let Some(cl) = snapshot.changelog_manifest_list() {
        manifest_list_names.push(cl.to_string());
    }

    // Pre-compute paths (always resolved from table root)
    let manifest_list_paths: Vec<String> = manifest_list_names
        .iter()
        .map(|name| manifest_sm.manifest_path(name))
        .collect();

    // Read all manifest lists concurrently and record their sizes
    let manifest_list_futures: Vec<_> = manifest_list_paths
        .iter()
        .map(|path| try_read_manifest_list_with_size(file_io, path))
        .collect();
    let manifest_list_results = try_join_all(manifest_list_futures).await?;

    // Register manifest list files themselves as manifest files
    for (name, (_, size)) in manifest_list_names.iter().zip(&manifest_list_results) {
        if *size > 0 {
            file_set.manifest_files.entry(name.clone()).or_insert(*size);
        }
    }

    // Flatten all manifest file metas from all manifest lists
    let all_manifest_metas: Vec<&ManifestFileMeta> = manifest_list_results
        .iter()
        .flat_map(|(metas, _)| metas.iter())
        .collect();

    // Register manifest files
    for meta in &all_manifest_metas {
        file_set
            .manifest_files
            .entry(meta.file_name().to_string())
            .or_insert(meta.file_size());
    }

    // Read manifest files to get data file entries, using cache by full path
    let manifest_paths: Vec<String> = all_manifest_metas
        .iter()
        .map(|meta| manifest_sm.manifest_path(meta.file_name()))
        .collect();

    let uncached_indices: Vec<usize> = manifest_paths
        .iter()
        .enumerate()
        .filter(|(_, path)| {
            let cache = manifest_cache.lock().unwrap();
            !cache.contains_key(path.as_str())
        })
        .map(|(i, _)| i)
        .collect();

    if !uncached_indices.is_empty() {
        let uncached_paths: Vec<&str> = uncached_indices
            .iter()
            .map(|&i| manifest_paths[i].as_str())
            .collect();

        let manifest_futures: Vec<_> = uncached_paths
            .iter()
            .map(|path| try_read_manifest(file_io, path))
            .collect();
        let results = try_join_all(manifest_futures).await?;

        // Collect extra files that need stat-ing
        let mut extra_file_stat_tasks: Vec<(usize, usize, String)> = Vec::new();
        let mut all_file_entries: Vec<Vec<(String, i64)>> = Vec::with_capacity(results.len());

        for (manifest_idx, entries) in results.iter().enumerate() {
            let mut file_entries: Vec<(String, i64)> = Vec::new();
            for e in entries {
                file_entries.push((e.file().file_name.clone(), e.file().file_size));
                for extra in &e.file().extra_files {
                    let entry_idx = file_entries.len();
                    let full_path = extra_resolver.resolve_extra_file_path(
                        e.partition(),
                        e.bucket(),
                        e.file(),
                        extra,
                    );
                    if let Some(path) = full_path {
                        extra_file_stat_tasks.push((manifest_idx, entry_idx, path));
                    }
                    file_entries.push((extra.clone(), 0));
                }
            }
            all_file_entries.push(file_entries);
        }

        // Batch stat extra files concurrently
        if !extra_file_stat_tasks.is_empty() {
            let stat_futures: Vec<_> = extra_file_stat_tasks
                .iter()
                .map(|(_, _, path)| try_stat_file_size(file_io, path))
                .collect();
            let stat_results = try_join_all(stat_futures).await?;

            for ((manifest_idx, entry_idx, _), size) in
                extra_file_stat_tasks.iter().zip(stat_results)
            {
                if size > 0 {
                    all_file_entries[*manifest_idx][*entry_idx].1 = size;
                }
            }
        }

        let mut cache = manifest_cache.lock().unwrap();
        for (path, file_entries) in uncached_paths.into_iter().zip(all_file_entries) {
            cache.insert(path.to_string(), file_entries);
        }
    }

    // Collect data files from cache (deduplicated by HashMap key)
    {
        let cache = manifest_cache.lock().unwrap();
        for path in &manifest_paths {
            if let Some(entries) = cache.get(path.as_str()) {
                for (name, size) in entries {
                    file_set.data_files.entry(name.clone()).or_insert(*size);
                }
            }
        }
    }

    // Read index manifest if present
    if let Some(index_manifest_name) = snapshot.index_manifest() {
        // The index manifest file itself is a manifest-type file
        let index_manifest_path = manifest_sm.manifest_path(index_manifest_name);
        let index_entries =
            try_read_index_manifest_with_size(file_io, &index_manifest_path).await?;

        if index_entries.1 > 0 {
            file_set
                .manifest_files
                .entry(index_manifest_name.to_string())
                .or_insert(index_entries.1);
        }

        for entry in &index_entries.0 {
            file_set
                .index_files
                .entry(entry.index_file.file_name.clone())
                .or_insert(entry.index_file.file_size);
        }
    }

    // Collect statistics file if present
    if let Some(statistics_name) = snapshot.statistics() {
        let statistics_path = format!(
            "{}/statistics/{}",
            extra_resolver.table_location, statistics_name
        );
        let size = try_stat_file_size(file_io, &statistics_path).await?;
        if size > 0 {
            file_set
                .manifest_files
                .entry(statistics_name.to_string())
                .or_insert(size);
        }
    }

    Ok(Some(file_set))
}

async fn try_get_snapshot(
    sm: &SnapshotManager,
    snapshot_id: i64,
) -> crate::Result<Option<crate::spec::Snapshot>> {
    match sm.get_snapshot(snapshot_id).await {
        Ok(s) => Ok(Some(s)),
        Err(crate::Error::IoUnexpected { ref source, .. })
            if source.kind() == opendal::ErrorKind::NotFound =>
        {
            Ok(None)
        }
        Err(crate::Error::DataInvalid { ref message, .. })
            if message.contains("does not exist") =>
        {
            Ok(None)
        }
        Err(e) => Err(e),
    }
}

/// Read a manifest list file. Returns (entries, file_size_in_bytes).
async fn try_read_manifest_list_with_size(
    file_io: &FileIO,
    path: &str,
) -> crate::Result<(Vec<ManifestFileMeta>, i64)> {
    let input = file_io.new_input(path)?;
    match input.read().await {
        Ok(bytes) => {
            let size = bytes.len() as i64;
            let metas = crate::spec::avro::from_avro_bytes_fast(&bytes)?;
            Ok((metas, size))
        }
        Err(crate::Error::IoUnexpected { ref source, .. })
            if source.kind() == opendal::ErrorKind::NotFound =>
        {
            Ok((Vec::new(), 0))
        }
        Err(e) => Err(e),
    }
}

async fn try_read_manifest(file_io: &FileIO, path: &str) -> crate::Result<Vec<ManifestEntry>> {
    match Manifest::read(file_io, path).await {
        Ok(entries) => Ok(entries),
        Err(crate::Error::IoUnexpected { ref source, .. })
            if source.kind() == opendal::ErrorKind::NotFound =>
        {
            Ok(Vec::new())
        }
        Err(e) => Err(e),
    }
}

/// Stat a file to get its size. Returns 0 if the file is not found.
async fn try_stat_file_size(file_io: &FileIO, path: &str) -> crate::Result<i64> {
    let input = file_io.new_input(path)?;
    match input.metadata().await {
        Ok(status) => Ok(status.size as i64),
        Err(crate::Error::IoUnexpected { ref source, .. })
            if source.kind() == opendal::ErrorKind::NotFound =>
        {
            Ok(0)
        }
        Err(e) => Err(e),
    }
}

/// Read an index manifest file. Returns (entries, file_size_in_bytes).
async fn try_read_index_manifest_with_size(
    file_io: &FileIO,
    path: &str,
) -> crate::Result<(Vec<crate::spec::IndexManifestEntry>, i64)> {
    match IndexManifest::read_with_size(file_io, path).await {
        Ok(result) => Ok(result),
        Err(crate::Error::IoUnexpected { ref source, .. })
            if source.kind() == opendal::ErrorKind::NotFound =>
        {
            Ok((Vec::new(), 0))
        }
        Err(e) => Err(e),
    }
}

/// Summary of all physical files in the table directory, categorized by file type.
#[derive(Debug, Clone, Default)]
pub struct PhysicalFilesSummary {
    pub manifest_file_count: i64,
    pub manifest_file_size: i64,
    pub data_file_count: i64,
    pub data_file_size: i64,
    pub index_file_count: i64,
    pub index_file_size: i64,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PhysicalFileKind {
    Manifest,
    Statistics,
    Data,
    Index,
    Other,
}

fn table_relative_path<'a>(table_location: &str, path: &'a str) -> Option<&'a str> {
    let table_location = table_location.trim_end_matches('/');
    if path == table_location {
        Some("")
    } else {
        path.strip_prefix(table_location)
            .and_then(|rest| rest.strip_prefix('/'))
    }
}

fn is_manifest_file_name(file_name: &str) -> bool {
    file_name.starts_with("manifest-")
        || file_name.starts_with("manifest-list-")
        || file_name.starts_with("index-manifest-")
}

fn is_bucket_dir_name(segment: &str) -> bool {
    segment == "bucket-postpone"
        || segment
            .strip_prefix("bucket-")
            .is_some_and(|bucket| !bucket.is_empty() && bucket.chars().all(|c| c.is_ascii_digit()))
}

fn is_partition_segment(segment: &str) -> bool {
    let Some((key, _value)) = segment.split_once('=') else {
        return false;
    };
    !key.is_empty()
}

fn is_data_file_in_bucket(segments: &[&str], partition_depth: usize) -> bool {
    if segments.len() != partition_depth + 2 {
        return false;
    }

    segments[..partition_depth]
        .iter()
        .all(|segment| is_partition_segment(segment))
        && is_bucket_dir_name(segments[partition_depth])
        && !segments[partition_depth + 1].starts_with("index-")
}

fn is_data_file_in_data_dir(
    relative_path: &str,
    data_dir_relative_path: &str,
    partition_depth: usize,
) -> bool {
    let data_dir_relative_path = data_dir_relative_path.trim_matches('/');
    let data_relative_path = if data_dir_relative_path.is_empty() {
        relative_path
    } else {
        let Some(rest) = relative_path
            .strip_prefix(data_dir_relative_path)
            .and_then(|rest| rest.strip_prefix('/'))
        else {
            return false;
        };
        rest
    };
    let segments = data_relative_path.split('/').collect::<Vec<_>>();
    is_data_file_in_bucket(&segments, partition_depth)
}

fn classify_physical_path(
    table_location: &str,
    path: &str,
    partition_depth: usize,
    data_file_path_directory: Option<&str>,
) -> PhysicalFileKind {
    let Some(relative_path) = table_relative_path(table_location, path) else {
        return PhysicalFileKind::Other;
    };
    let relative_path = relative_path.trim_matches('/');
    if relative_path.is_empty() {
        return PhysicalFileKind::Other;
    }

    let segments = relative_path.split('/').collect::<Vec<_>>();

    match segments.as_slice() {
        ["manifest", name] if is_manifest_file_name(name) => PhysicalFileKind::Manifest,
        ["statistics", _] => PhysicalFileKind::Statistics,
        ["index", _] => PhysicalFileKind::Index,
        _ => {
            if let Some(data_dir) = data_file_path_directory {
                let data_dir = table_relative_path(table_location, data_dir).unwrap_or(data_dir);
                if is_data_file_in_data_dir(relative_path, data_dir, partition_depth) {
                    PhysicalFileKind::Data
                } else {
                    PhysicalFileKind::Other
                }
            } else if is_data_file_in_bucket(&segments, partition_depth) {
                PhysicalFileKind::Data
            } else {
                PhysicalFileKind::Other
            }
        }
    }
}

const DIR_LIST_CONCURRENCY: usize = 32;

/// Scan the table directory and compute total file sizes grouped by type.
///
/// First lists top-level subdirectories, then concurrently lists each
/// subdirectory recursively (up to 32 in parallel) to maximize throughput
/// on object stores with many partition directories.
///
/// Files are classified by their table-relative path. Only recognized Paimon
/// metadata directories and partition/bucket data paths are counted; unknown
/// files are ignored by this summary.
pub async fn collect_physical_files_summary(
    file_io: &FileIO,
    table_location: &str,
    partition_depth: usize,
) -> crate::Result<PhysicalFilesSummary> {
    // List top-level entries to discover subdirectories and top-level files
    let top_entries = match file_io.list_status(table_location).await {
        Ok(s) => s,
        Err(crate::Error::IoUnexpected { ref source, .. })
            if source.kind() == opendal::ErrorKind::NotFound =>
        {
            return Ok(PhysicalFilesSummary::default());
        }
        Err(e) => return Err(e),
    };

    let mut summary = PhysicalFilesSummary::default();

    // Classify top-level files directly
    let mut sub_dirs = Vec::new();
    for entry in &top_entries {
        if entry.is_dir {
            sub_dirs.push(entry.path.clone());
        } else {
            accumulate_file(
                &mut summary,
                table_location,
                &entry.path,
                partition_depth,
                entry.size,
            );
        }
    }

    // Concurrently list each subdirectory recursively
    let dir_results: Vec<crate::Result<Vec<crate::io::FileStatus>>> = stream::iter(sub_dirs)
        .map(|dir_path| async move {
            match file_io.list_status_recursive(&dir_path).await {
                Ok(s) => Ok(s),
                Err(crate::Error::IoUnexpected { ref source, .. })
                    if source.kind() == opendal::ErrorKind::NotFound =>
                {
                    Ok(Vec::new())
                }
                Err(e) => Err(e),
            }
        })
        .buffer_unordered(DIR_LIST_CONCURRENCY)
        .collect()
        .await;

    for result in dir_results {
        let statuses = result?;
        for status in &statuses {
            accumulate_file(
                &mut summary,
                table_location,
                &status.path,
                partition_depth,
                status.size,
            );
        }
    }

    Ok(summary)
}

fn accumulate_file(
    summary: &mut PhysicalFilesSummary,
    table_location: &str,
    path: &str,
    partition_depth: usize,
    size: u64,
) {
    match classify_physical_path(table_location, path, partition_depth, None) {
        PhysicalFileKind::Manifest | PhysicalFileKind::Statistics => {
            summary.manifest_file_count += 1;
            summary.manifest_file_size += size as i64;
        }
        PhysicalFileKind::Data => {
            summary.data_file_count += 1;
            summary.data_file_size += size as i64;
        }
        PhysicalFileKind::Index => {
            summary.index_file_count += 1;
            summary.index_file_size += size as i64;
        }
        PhysicalFileKind::Other => {}
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::io::FileIOBuilder;
    use crate::spec::{CommitKind, Snapshot};
    use crate::table::{BranchManager, SnapshotManager, TagManager};
    use bytes::Bytes;

    fn test_file_io() -> FileIO {
        FileIOBuilder::new("memory").build().unwrap()
    }

    async fn write_test_file(file_io: &FileIO, path: &str, content: &str) {
        file_io
            .new_output(path)
            .unwrap()
            .write(Bytes::from(content.to_string()))
            .await
            .unwrap();
    }

    #[test]
    fn test_extra_file_resolver_uses_external_path_parent() {
        use crate::spec::stats::BinaryTableStats;

        let resolver = ExtraFileResolver::new("s3://warehouse/table", &[], &[]);
        let stats = BinaryTableStats::empty();
        let file = DataFileMeta {
            file_name: "data-0.row".to_string(),
            file_size: 1,
            row_count: 1,
            min_key: vec![],
            max_key: vec![],
            key_stats: stats.clone(),
            value_stats: stats,
            min_sequence_number: 0,
            max_sequence_number: 0,
            schema_id: 0,
            level: 0,
            extra_files: vec!["data-0.row.index".to_string()],
            creation_time: None,
            delete_row_count: None,
            embedded_index: None,
            file_source: None,
            value_stats_cols: None,
            external_path: Some("s3://bucket/external/data-0.row".to_string()),
            first_row_id: None,
            write_cols: None,
        };

        assert_eq!(
            resolver.resolve_extra_file_path(&[], 0, &file, "data-0.row.index"),
            Some("s3://bucket/external/data-0.row.index".to_string())
        );
    }

    #[tokio::test]
    async fn test_referenced_files_stats_external_sidecar_from_parent() {
        use crate::spec::stats::BinaryTableStats;
        use crate::spec::{DataFileMeta, FileKind, Manifest, ManifestFileMeta, ManifestList};

        let table_path = "memory:/test_external_sidecar_references";
        let external_dir = "memory:/external_sidecar_references";
        let sidecar_name = "data-0.row.index";
        let sidecar_content = "sidecar-bytes";
        let file_io = test_file_io();

        file_io
            .mkdirs(&format!("{table_path}/snapshot/"))
            .await
            .unwrap();
        file_io
            .mkdirs(&format!("{table_path}/manifest/"))
            .await
            .unwrap();
        write_test_file(
            &file_io,
            &format!("{external_dir}/{sidecar_name}"),
            sidecar_content,
        )
        .await;

        let manifest_name = "manifest-external-sidecar-0";
        let manifest_path = format!("{table_path}/manifest/{manifest_name}");
        let data_file = DataFileMeta {
            file_name: "data-0.row".to_string(),
            file_size: 100,
            row_count: 10,
            min_key: vec![],
            max_key: vec![],
            key_stats: BinaryTableStats::empty(),
            value_stats: BinaryTableStats::empty(),
            min_sequence_number: 0,
            max_sequence_number: 0,
            schema_id: 0,
            level: 0,
            extra_files: vec![sidecar_name.to_string()],
            creation_time: None,
            delete_row_count: Some(0),
            embedded_index: None,
            file_source: None,
            value_stats_cols: None,
            external_path: Some(format!("{external_dir}/data-0.row")),
            first_row_id: None,
            write_cols: None,
        };
        let entry = ManifestEntry::new(FileKind::Add, vec![0u8; 12], 0, 1, data_file, 0);
        Manifest::write(&file_io, &manifest_path, &[entry])
            .await
            .unwrap();

        let manifest_list_name = "manifest-list-external-sidecar";
        let manifest_list_path = format!("{table_path}/manifest/{manifest_list_name}");
        let manifest_meta = ManifestFileMeta::new(
            manifest_name.to_string(),
            512,
            1,
            0,
            BinaryTableStats::empty(),
            0,
        );
        ManifestList::write(&file_io, &manifest_list_path, &[manifest_meta])
            .await
            .unwrap();

        let delta_list_name = "manifest-list-external-sidecar-delta";
        let delta_list_path = format!("{table_path}/manifest/{delta_list_name}");
        ManifestList::write(&file_io, &delta_list_path, &[])
            .await
            .unwrap();

        let sm = SnapshotManager::new(file_io.clone(), table_path.to_string());
        let snapshot = Snapshot::builder()
            .version(3)
            .id(1)
            .schema_id(0)
            .base_manifest_list(manifest_list_name.to_string())
            .delta_manifest_list(delta_list_name.to_string())
            .commit_user("test".to_string())
            .commit_identifier(0)
            .commit_kind(CommitKind::APPEND)
            .time_millis(1000)
            .build();
        sm.commit_snapshot(&snapshot).await.unwrap();

        let result = collect_referenced_files_summary(&file_io, table_path, &[], &[])
            .await
            .unwrap();
        let total = result.iter().find(|r| r.source == "total").unwrap();
        assert_eq!(total.data_file_count, 2);
        assert_eq!(total.data_file_size, 100 + sidecar_content.len() as i64);
    }

    #[tokio::test]
    async fn test_collect_empty_table() {
        let file_io = test_file_io();
        let result =
            collect_referenced_files_summary(&file_io, "memory:/test_empty_table", &[], &[])
                .await
                .unwrap();
        // total + branch:main
        assert_eq!(result.len(), 2);
        assert_eq!(result[0].source, "total");
        assert_eq!(result[0].data_file_count, 0);
        assert_eq!(result[1].source, "branch:main");
        assert_eq!(result[1].data_file_count, 0);
    }

    #[tokio::test]
    async fn test_collect_with_missing_manifest() {
        let table_path = "memory:/test_missing_manifest";
        let file_io = test_file_io();
        file_io
            .mkdirs(&format!("{table_path}/snapshot/"))
            .await
            .unwrap();
        file_io
            .mkdirs(&format!("{table_path}/manifest/"))
            .await
            .unwrap();

        let sm = SnapshotManager::new(file_io.clone(), table_path.to_string());

        // Create a snapshot that references non-existent manifest lists
        let snapshot = Snapshot::builder()
            .version(3)
            .id(1)
            .schema_id(0)
            .base_manifest_list("non-existent-base".to_string())
            .delta_manifest_list("non-existent-delta".to_string())
            .commit_user("test".to_string())
            .commit_identifier(0)
            .commit_kind(CommitKind::APPEND)
            .time_millis(1000)
            .build();
        sm.commit_snapshot(&snapshot).await.unwrap();

        let result = collect_referenced_files_summary(&file_io, table_path, &[], &[])
            .await
            .unwrap();
        // total + branch:main
        assert_eq!(result.len(), 2);
        assert_eq!(result[0].source, "total");
        assert_eq!(result[0].manifest_file_count, 0);
        assert_eq!(result[0].data_file_count, 0);
        assert_eq!(result[1].source, "branch:main");
        assert_eq!(result[1].manifest_file_count, 0);
        assert_eq!(result[1].data_file_count, 0);
    }

    #[tokio::test]
    async fn test_physical_files_summary_uses_path_context_for_unpartitioned_table() {
        let table_path = "memory:/test_physical_files_summary_path_context";
        let file_io = test_file_io();

        write_test_file(
            &file_io,
            &format!("{table_path}/manifest/manifest-list-0"),
            "manifest list",
        )
        .await;
        write_test_file(
            &file_io,
            &format!("{table_path}/manifest/manifest-0"),
            "manifest",
        )
        .await;
        write_test_file(
            &file_io,
            &format!("{table_path}/manifest/index-manifest-0"),
            "index manifest",
        )
        .await;
        write_test_file(&file_io, &format!("{table_path}/index/index-0"), "index").await;
        write_test_file(&file_io, &format!("{table_path}/bucket-0/data-0"), "data").await;
        write_test_file(
            &file_io,
            &format!("{table_path}/bucket-0/part-0.parquet"),
            "data without prefix",
        )
        .await;
        write_test_file(
            &file_io,
            &format!("{table_path}/bucket-0/index-should-not-be-data"),
            "bucket index",
        )
        .await;
        write_test_file(
            &file_io,
            &format!("{table_path}/bucket-postpone/data-u-0"),
            "postpone data",
        )
        .await;
        write_test_file(
            &file_io,
            &format!("{table_path}/nested/bucket-0/data-too-deep"),
            "not a data bucket",
        )
        .await;
        write_test_file(
            &file_io,
            &format!("{table_path}/data-root-file.parquet"),
            "root data prefix",
        )
        .await;
        write_test_file(
            &file_io,
            &format!("{table_path}/snapshot/snapshot-1"),
            "snapshot",
        )
        .await;
        write_test_file(&file_io, &format!("{table_path}/schema/schema-0"), "schema").await;
        write_test_file(&file_io, &format!("{table_path}/tag/tag-v1"), "tag").await;
        write_test_file(&file_io, &format!("{table_path}/_SUCCESS"), "success").await;
        write_test_file(&file_io, &format!("{table_path}/random-file"), "random").await;
        write_test_file(
            &file_io,
            &format!("{table_path}/statistics/stat-0"),
            "statistics",
        )
        .await;
        write_test_file(
            &file_io,
            &format!("{table_path}/manifest/stat-0"),
            "not classified by statistics prefix",
        )
        .await;

        let result = collect_physical_files_summary(&file_io, table_path, 0)
            .await
            .unwrap();

        assert_eq!(result.manifest_file_count, 4);
        assert_eq!(result.index_file_count, 1);
        assert_eq!(result.data_file_count, 3);
    }

    #[tokio::test]
    async fn test_physical_files_summary_uses_partition_depth() {
        let table_path = "memory:/test_physical_files_summary_partitioned";
        let file_io = test_file_io();

        write_test_file(
            &file_io,
            &format!("{table_path}/dt=2026-05-21/bucket-0/part-0.parquet"),
            "partition data",
        )
        .await;
        write_test_file(
            &file_io,
            &format!("{table_path}/dt=2026-05-21/bucket-0/index-0"),
            "partition bucket index",
        )
        .await;
        write_test_file(
            &file_io,
            &format!("{table_path}/dt=2026-05-21/not-bucket/data-0"),
            "not bucket",
        )
        .await;
        write_test_file(
            &file_io,
            &format!("{table_path}/not_partition/bucket-0/data-0"),
            "not partition",
        )
        .await;
        write_test_file(
            &file_io,
            &format!("{table_path}/bucket-0/root-bucket-file"),
            "wrong depth",
        )
        .await;

        let result = collect_physical_files_summary(&file_io, table_path, 1)
            .await
            .unwrap();

        assert_eq!(result.data_file_count, 1);
        assert_eq!(result.index_file_count, 0);
    }

    #[tokio::test]
    async fn test_branch_tag_referenced_files() {
        use crate::spec::stats::BinaryTableStats;
        use crate::spec::{DataFileMeta, FileKind, Manifest, ManifestFileMeta, ManifestList};

        let table_path = "memory:/test_branch_tag";
        let file_io = test_file_io();

        file_io
            .mkdirs(&format!("{table_path}/snapshot/"))
            .await
            .unwrap();
        file_io
            .mkdirs(&format!("{table_path}/manifest/"))
            .await
            .unwrap();

        let sm = SnapshotManager::new(file_io.clone(), table_path.to_string());
        let empty_stats = BinaryTableStats::new(vec![0u8; 8], vec![0u8; 8], vec![Some(0)]);

        // Write a manifest file (referenced by branch tag only) at the TABLE ROOT
        let manifest_name = "manifest-branch-only-1";
        let manifest_path = format!("{table_path}/manifest/{manifest_name}");
        let data_file = DataFileMeta {
            file_name: "data-branch-tag-file-1.parquet".to_string(),
            file_size: 4096,
            row_count: 100,
            min_key: vec![],
            max_key: vec![],
            key_stats: BinaryTableStats::empty(),
            value_stats: BinaryTableStats::empty(),
            min_sequence_number: 0,
            max_sequence_number: 0,
            schema_id: 0,
            level: 0,
            extra_files: vec![],
            creation_time: None,
            delete_row_count: Some(0),
            embedded_index: None,
            file_source: None,
            value_stats_cols: None,
            external_path: None,
            first_row_id: None,
            write_cols: None,
        };
        let entry = ManifestEntry::new(FileKind::Add, vec![0u8; 12], 0, 1, data_file, 2);
        Manifest::write(&file_io, &manifest_path, &[entry])
            .await
            .unwrap();

        // Write a manifest list that references the above manifest (at the table root)
        let manifest_list_name = "manifest-list-branch-tag-base";
        let manifest_list_path = format!("{table_path}/manifest/{manifest_list_name}");
        let manifest_meta =
            ManifestFileMeta::new(manifest_name.to_string(), 512, 1, 0, empty_stats.clone(), 0);
        ManifestList::write(&file_io, &manifest_list_path, &[manifest_meta])
            .await
            .unwrap();

        // Write an empty delta manifest list at the table root
        let delta_list_name = "manifest-list-branch-tag-delta";
        let delta_list_path = format!("{table_path}/manifest/{delta_list_name}");
        ManifestList::write(&file_io, &delta_list_path, &[])
            .await
            .unwrap();

        // Create a main branch snapshot (with non-existent manifest lists)
        let main_snapshot = Snapshot::builder()
            .version(3)
            .id(1)
            .schema_id(0)
            .base_manifest_list("manifest-list-main-base".to_string())
            .delta_manifest_list("manifest-list-main-delta".to_string())
            .commit_user("test".to_string())
            .commit_identifier(0)
            .commit_kind(CommitKind::APPEND)
            .time_millis(1000)
            .build();
        sm.commit_snapshot(&main_snapshot).await.unwrap();

        // Create branch b1 with NO snapshots
        let bm = BranchManager::new(file_io.clone(), table_path.to_string());
        bm.create_branch("b1").await.unwrap();

        // Create a tag under branch b1 that references the readable manifest lists
        let branch_tm = TagManager::new(file_io.clone(), table_path.to_string()).with_branch("b1");
        let branch_tag_snapshot = Snapshot::builder()
            .version(3)
            .id(100)
            .schema_id(0)
            .base_manifest_list(manifest_list_name.to_string())
            .delta_manifest_list(delta_list_name.to_string())
            .commit_user("test".to_string())
            .commit_identifier(0)
            .commit_kind(CommitKind::APPEND)
            .time_millis(2000)
            .build();
        branch_tm.create("v1", &branch_tag_snapshot).await.unwrap();

        let result = collect_referenced_files_summary(&file_io, table_path, &[], &[])
            .await
            .unwrap();

        // Should have: total, branch:main, branch:b1
        assert_eq!(result.len(), 3);
        assert_eq!(result[0].source, "total");
        assert_eq!(result[1].source, "branch:main");
        assert_eq!(result[2].source, "branch:b1");

        // branch:b1 must have non-zero counts from the branch tag's readable manifests.
        // The manifest list + manifest file + delta manifest list = 3 manifest files.
        assert!(
            result[2].manifest_file_count > 0,
            "branch:b1 must have manifest files from branch tag, got {}",
            result[2].manifest_file_count
        );
        assert!(
            result[2].manifest_file_size > 0,
            "branch:b1 must have non-zero manifest file size, got {}",
            result[2].manifest_file_size
        );
        // The manifest references one data file
        assert_eq!(result[2].data_file_count, 1);
        assert_eq!(result[2].data_file_size, 4096);

        // total should include branch:b1's files
        assert!(result[0].data_file_count >= 1);
        assert!(result[0].data_file_size >= 4096);
    }
}
