use std::path::{Path, PathBuf};

#[derive(Debug, thiserror::Error)]
pub enum RsyncFetchError {
    #[error("rsync fetch error: {0}")]
    Fetch(String),
}

pub type RsyncFetchResult<T> = Result<T, RsyncFetchError>;

pub fn normalize_rsync_base_uri(s: &str) -> String {
    if s.ends_with('/') {
        s.to_string()
    } else {
        format!("{s}/")
    }
}

/// Fetch repository objects from a publication point.
///
/// v1: this is intentionally abstract so unit tests can use a mock, and later we can
/// back it by calling the system `rsync` binary (RFC 6481 §5; RFC 8182 §3.4.5).
pub trait RsyncFetcher: Send + Sync {
    /// Return a list of objects as `(rsync_uri, bytes)` pairs.
    fn fetch_objects(&self, rsync_base_uri: &str) -> RsyncFetchResult<Vec<(String, Vec<u8>)>>;

    /// Fetch one object by exact rsync URI.
    ///
    /// The default implementation fetches the parent directory and filters the exact
    /// object. Live fetchers should override this to avoid widening one-object TAL
    /// bootstrap fetches into whole publication point or module synchronizations.
    fn fetch_object(&self, rsync_uri: &str) -> RsyncFetchResult<Vec<u8>> {
        let base = parent_rsync_uri(rsync_uri).map_err(RsyncFetchError::Fetch)?;
        self.fetch_objects(&base)?
            .into_iter()
            .find(|(uri, _)| uri == rsync_uri)
            .map(|(_, bytes)| bytes)
            .ok_or_else(|| RsyncFetchError::Fetch(format!("rsync object not found: {rsync_uri}")))
    }

    /// Stream fetched objects to a visitor without requiring callers to materialize the
    /// full result vector in memory.
    fn visit_objects(
        &self,
        rsync_base_uri: &str,
        visitor: &mut dyn FnMut(String, Vec<u8>) -> Result<(), String>,
    ) -> RsyncFetchResult<(usize, u64)> {
        let objects = self.fetch_objects(rsync_base_uri)?;
        let mut count = 0usize;
        let mut bytes_total = 0u64;
        for (uri, bytes) in objects {
            bytes_total += bytes.len() as u64;
            count += 1;
            visitor(uri, bytes).map_err(RsyncFetchError::Fetch)?;
        }
        Ok((count, bytes_total))
    }

    /// Return the deduplication key used by orchestration layers.
    ///
    /// By default this is the normalized publication point base URI. Fetchers that
    /// intentionally widen their fetch scope (for example to a full rsync module)
    /// should override this so callers can safely deduplicate at the same scope.
    fn dedup_key(&self, rsync_base_uri: &str) -> String {
        normalize_rsync_base_uri(rsync_base_uri)
    }

    /// Return an optional failure-only deduplication key.
    ///
    /// This key is only used to short-circuit repeated failed rsync fallbacks. It must
    /// not be used to reuse successful fetch results unless it is identical to
    /// `dedup_key`, because a successful fetch for one publication point does not imply
    /// that another publication point under the same host has been fetched.
    fn failure_dedup_key(&self, _rsync_base_uri: &str) -> Option<String> {
        None
    }
}

fn parent_rsync_uri(rsync_uri: &str) -> Result<String, String> {
    let parsed = url::Url::parse(rsync_uri).map_err(|e| e.to_string())?;
    if parsed.scheme() != "rsync" {
        return Err(format!("not an rsync URI: {rsync_uri}"));
    }
    let host = parsed
        .host_str()
        .ok_or_else(|| format!("missing host in rsync URI: {rsync_uri}"))?;
    let segments = parsed
        .path_segments()
        .ok_or_else(|| format!("missing path in rsync URI: {rsync_uri}"))?
        .collect::<Vec<_>>();
    if segments.is_empty() || segments.last().copied().unwrap_or_default().is_empty() {
        return Err(format!(
            "rsync URI must reference a file object: {rsync_uri}"
        ));
    }
    let parent_segments = &segments[..segments.len() - 1];
    let mut parent = format!("rsync://{host}/");
    if !parent_segments.is_empty() {
        parent.push_str(&parent_segments.join("/"));
        parent.push('/');
    }
    Ok(parent)
}

/// A simple "rsync" implementation backed by a local directory.
///
/// This is primarily meant for offline tests and fixtures. The key generation mimics rsync URIs:
/// `rsync_base_uri` + relative path (with `/` separators).
#[derive(Clone, Debug)]
pub struct LocalDirRsyncFetcher {
    pub root_dir: PathBuf,
}

impl LocalDirRsyncFetcher {
    pub fn new(root_dir: impl Into<PathBuf>) -> Self {
        Self {
            root_dir: root_dir.into(),
        }
    }
}

impl RsyncFetcher for LocalDirRsyncFetcher {
    fn fetch_objects(&self, rsync_base_uri: &str) -> RsyncFetchResult<Vec<(String, Vec<u8>)>> {
        let base = normalize_rsync_base_uri(rsync_base_uri);
        let mut out = Vec::new();
        walk_dir_collect(&self.root_dir, &self.root_dir, &base, &mut out)
            .map_err(|e| RsyncFetchError::Fetch(e))?;
        Ok(out)
    }
}

fn walk_dir_collect(
    root: &Path,
    current: &Path,
    rsync_base_uri: &str,
    out: &mut Vec<(String, Vec<u8>)>,
) -> Result<(), String> {
    let rd = std::fs::read_dir(current).map_err(|e| e.to_string())?;
    for entry in rd {
        let entry = entry.map_err(|e| e.to_string())?;
        let path = entry.path();
        let meta = entry.metadata().map_err(|e| e.to_string())?;
        if meta.is_dir() {
            walk_dir_collect(root, &path, rsync_base_uri, out)?;
            continue;
        }
        if !meta.is_file() {
            continue;
        }
        let rel = path
            .strip_prefix(root)
            .map_err(|e| e.to_string())?
            .to_string_lossy()
            .replace('\\', "/");
        let uri = format!("{rsync_base_uri}{rel}");
        let bytes = std::fs::read(&path).map_err(|e| e.to_string())?;
        out.push((uri, bytes));
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn local_dir_rsync_fetcher_collects_files_and_normalizes_base_uri() {
        let tmp = tempfile::tempdir().expect("tempdir");
        std::fs::create_dir_all(tmp.path().join("nested")).expect("mkdir");
        std::fs::write(tmp.path().join("a.mft"), b"a").expect("write");
        std::fs::write(tmp.path().join("nested").join("b.roa"), b"b").expect("write");

        let f = LocalDirRsyncFetcher::new(tmp.path());
        let mut objects = f
            .fetch_objects("rsync://example.net/repo")
            .expect("fetch_objects");
        objects.sort_by(|(a, _), (b, _)| a.cmp(b));

        assert_eq!(objects.len(), 2);
        assert_eq!(objects[0].0, "rsync://example.net/repo/a.mft");
        assert_eq!(objects[0].1, b"a");
        assert_eq!(objects[1].0, "rsync://example.net/repo/nested/b.roa");
        assert_eq!(objects[1].1, b"b");
    }

    #[test]
    fn local_dir_rsync_fetcher_reports_read_dir_errors() {
        let tmp = tempfile::tempdir().expect("tempdir");
        let missing = tmp.path().join("missing");
        let f = LocalDirRsyncFetcher::new(missing);
        let err = f.fetch_objects("rsync://example.net/repo").unwrap_err();
        match err {
            RsyncFetchError::Fetch(msg) => assert!(!msg.is_empty()),
        }
    }

    #[test]
    fn default_dedup_key_is_normalized_base_uri() {
        let tmp = tempfile::tempdir().expect("tempdir");
        let fetcher = LocalDirRsyncFetcher::new(tmp.path());
        assert_eq!(
            fetcher.dedup_key("rsync://example.net/repo"),
            "rsync://example.net/repo/"
        );
    }
}
