//! Persistent dead-repo transport blacklist (#141).
//!
//! Tracks per-(repo, transport) consecutive transport-layer fetch failures
//! across runs. Once an entry reaches the configured failure threshold it is
//! admitted to the blacklist; the live transport scheduler then skips the
//! dead transport (rrdp-blacklisted -> straight to rsync, dual-blacklisted ->
//! immediate terminal failure). Entries are removed either by a successful
//! fetch (self-heal) or by the daemon-side health probe.

use std::collections::HashMap;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};

use crate::parallel::types::{RepoTransportErrorClass, RepoTransportMode};

pub const DEAD_REPO_BLACKLIST_SCHEMA_VERSION: u32 = 1;
pub const DEAD_REPO_BLACKLIST_DEFAULT_CAPACITY: usize = 256;
/// Probe backoff is capped at interval * 2^PROBE_BACKOFF_CAP_SHIFT.
pub const PROBE_BACKOFF_CAP_SHIFT: u32 = 4;

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DeadRepoBlacklistConfig {
    pub path: PathBuf,
    pub fail_threshold: u32,
    pub capacity: usize,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DeadRepoBlacklistEntry {
    pub transport: RepoTransportMode,
    pub uri: String,
    /// Consecutive runs with transport-class fetch failure. Reset on success.
    pub consecutive_failures: u32,
    /// True once `consecutive_failures` reached the admission threshold.
    #[serde(default)]
    pub blacklisted: bool,
    #[serde(default)]
    pub first_failure_at_unix: u64,
    #[serde(default)]
    pub last_failure_at_unix: u64,
    /// When the entry was admitted to the blacklist.
    #[serde(default)]
    pub added_at_unix: u64,
    #[serde(default)]
    pub last_probe_at_unix: Option<u64>,
    #[serde(default)]
    pub probe_failures: u32,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DeadRepoFailureOutcome {
    /// Failure counted, entry not (or not yet) blacklisted.
    Counted { consecutive_failures: u32 },
    /// Entry just reached the threshold and was admitted.
    Admitted,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DeadRepoSuccessOutcome {
    /// A counting (not yet admitted) entry was reset.
    CounterReset,
    /// A blacklisted entry was removed (repo revived).
    BlacklistRemoved,
}

#[derive(Serialize, Deserialize)]
struct DeadRepoBlacklistFile {
    schema_version: u32,
    updated_at_unix: u64,
    #[serde(default)]
    entries: Vec<DeadRepoBlacklistEntry>,
}

/// In-memory blacklist state for one writer at a time (child run, or daemon
/// supervisor between runs). Lookups go through a HashMap keyed by
/// (transport, uri).
#[derive(Clone, Debug, Default)]
pub struct DeadRepoBlacklist {
    entries: HashMap<(RepoTransportMode, String), DeadRepoBlacklistEntry>,
}

impl DeadRepoBlacklist {
    pub fn new() -> Self {
        Self::default()
    }

    /// Load from disk. A missing file or an unreadable/incompatible file
    /// degrades to an empty blacklist (logged by the caller via `warning`).
    pub fn load(path: &Path) -> (Self, Option<String>) {
        let bytes = match fs::read(path) {
            Ok(bytes) => bytes,
            Err(err) if err.kind() == io::ErrorKind::NotFound => return (Self::new(), None),
            Err(err) => {
                return (
                    Self::new(),
                    Some(format!(
                        "dead repo blacklist {} unreadable ({err}); starting empty",
                        path.display()
                    )),
                );
            }
        };
        match serde_json::from_slice::<DeadRepoBlacklistFile>(&bytes) {
            Ok(file) if file.schema_version == DEAD_REPO_BLACKLIST_SCHEMA_VERSION => {
                let entries = file
                    .entries
                    .into_iter()
                    .map(|entry| ((entry.transport, entry.uri.clone()), entry))
                    .collect();
                (Self { entries }, None)
            }
            Ok(file) => (
                Self::new(),
                Some(format!(
                    "dead repo blacklist {} schema_version {} != {}; starting empty",
                    path.display(),
                    file.schema_version,
                    DEAD_REPO_BLACKLIST_SCHEMA_VERSION
                )),
            ),
            Err(err) => (
                Self::new(),
                Some(format!(
                    "dead repo blacklist {} unparsable ({err}); starting empty",
                    path.display()
                )),
            ),
        }
    }

    /// Persist atomically (tmp file + rename).
    pub fn store_atomic(&self, path: &Path, now_unix: u64) -> io::Result<()> {
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent)?;
        }
        let mut entries: Vec<&DeadRepoBlacklistEntry> = self.entries.values().collect();
        entries.sort_by(|a, b| a.uri.cmp(&b.uri).then(a.transport.cmp(&b.transport)));
        let file = DeadRepoBlacklistFile {
            schema_version: DEAD_REPO_BLACKLIST_SCHEMA_VERSION,
            updated_at_unix: now_unix,
            entries: entries.into_iter().cloned().collect(),
        };
        let bytes = serde_json::to_vec_pretty(&file)
            .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
        let mut tmp_name = path.file_name().map(|name| name.to_os_string()).unwrap_or_default();
        tmp_name.push(".tmp");
        let tmp_path = path.with_file_name(tmp_name);
        fs::write(&tmp_path, &bytes)?;
        // Best-effort durability before the rename.
        if let Ok(file) = fs::File::open(&tmp_path) {
            let _ = file.sync_all();
        }
        fs::rename(&tmp_path, path)?;
        Ok(())
    }

    pub fn is_blacklisted(&self, transport: RepoTransportMode, uri: &str) -> bool {
        self.entries
            .get(&(transport, uri.to_string()))
            .map(|entry| entry.blacklisted)
            .unwrap_or(false)
    }

    /// Record a transport-class fetch failure. Returns whether the failure
    /// newly admitted the entry to the blacklist.
    pub fn record_transport_failure(
        &mut self,
        transport: RepoTransportMode,
        uri: &str,
        now_unix: u64,
        fail_threshold: u32,
        capacity: usize,
    ) -> DeadRepoFailureOutcome {
        let key = (transport, uri.to_string());
        if !self.entries.contains_key(&key) {
            self.evict_if_full(capacity);
        }
        let threshold = fail_threshold.max(1);
        let entry = self.entries.entry(key).or_insert_with(|| DeadRepoBlacklistEntry {
            transport,
            uri: uri.to_string(),
            consecutive_failures: 0,
            blacklisted: false,
            first_failure_at_unix: now_unix,
            last_failure_at_unix: now_unix,
            added_at_unix: 0,
            last_probe_at_unix: None,
            probe_failures: 0,
        });
        entry.consecutive_failures = entry.consecutive_failures.saturating_add(1);
        entry.last_failure_at_unix = now_unix;
        if entry.first_failure_at_unix == 0 {
            entry.first_failure_at_unix = now_unix;
        }
        if !entry.blacklisted && entry.consecutive_failures >= threshold {
            entry.blacklisted = true;
            entry.added_at_unix = now_unix;
            // A fresh admission restarts probe bookkeeping.
            entry.last_probe_at_unix = None;
            entry.probe_failures = 0;
            return DeadRepoFailureOutcome::Admitted;
        }
        DeadRepoFailureOutcome::Counted {
            consecutive_failures: entry.consecutive_failures,
        }
    }

    /// Record a successful fetch: reset a counting entry, or remove a
    /// blacklisted entry entirely (self-heal).
    pub fn record_transport_success(
        &mut self,
        transport: RepoTransportMode,
        uri: &str,
    ) -> Option<DeadRepoSuccessOutcome> {
        match self.entries.remove(&(transport, uri.to_string())) {
            Some(entry) if entry.blacklisted => Some(DeadRepoSuccessOutcome::BlacklistRemoved),
            Some(_) => Some(DeadRepoSuccessOutcome::CounterReset),
            None => None,
        }
    }

    /// Blacklisted entries whose next probe is due at `now_unix`.
    pub fn probe_due_entries(
        &self,
        now_unix: u64,
        probe_interval_secs: u64,
    ) -> Vec<DeadRepoBlacklistEntry> {
        let mut due: Vec<DeadRepoBlacklistEntry> = self
            .entries
            .values()
            .filter(|entry| entry.blacklisted)
            .filter(|entry| {
                let interval = probe_backoff_secs(probe_interval_secs, entry.probe_failures);
                match entry.last_probe_at_unix {
                    None => true,
                    Some(last) => now_unix.saturating_sub(last) >= interval,
                }
            })
            .cloned()
            .collect();
        due.sort_by(|a, b| a.uri.cmp(&b.uri));
        due
    }

    /// Probe succeeded: remove the entry. Returns true if it was present.
    pub fn record_probe_success(&mut self, transport: RepoTransportMode, uri: &str) -> bool {
        self.entries.remove(&(transport, uri.to_string())).is_some()
    }

    pub fn record_probe_failure(&mut self, transport: RepoTransportMode, uri: &str, now_unix: u64) {
        if let Some(entry) = self.entries.get_mut(&(transport, uri.to_string())) {
            entry.last_probe_at_unix = Some(now_unix);
            entry.probe_failures = entry.probe_failures.saturating_add(1);
        }
    }

    pub fn len(&self) -> usize {
        self.entries.len()
    }

    pub fn is_empty(&self) -> bool {
        self.entries.is_empty()
    }

    pub fn blacklisted_len(&self) -> usize {
        self.entries.values().filter(|entry| entry.blacklisted).count()
    }

    /// Deterministic snapshot of all entries (for status/observability).
    pub fn entries_sorted(&self) -> Vec<DeadRepoBlacklistEntry> {
        let mut entries: Vec<DeadRepoBlacklistEntry> = self.entries.values().cloned().collect();
        entries.sort_by(|a, b| a.uri.cmp(&b.uri).then(a.transport.cmp(&b.transport)));
        entries
    }

    fn evict_if_full(&mut self, capacity: usize) {
        let capacity = capacity.max(1);
        if self.entries.len() < capacity {
            return;
        }
        // Prefer evicting the stalest counting entry; fall back to the oldest
        // blacklisted entry.
        let victim = self
            .entries
            .values()
            .filter(|entry| !entry.blacklisted)
            .min_by_key(|entry| entry.last_failure_at_unix)
            .map(|entry| (entry.transport, entry.uri.clone()))
            .or_else(|| {
                self.entries
                    .values()
                    .min_by_key(|entry| entry.added_at_unix)
                    .map(|entry| (entry.transport, entry.uri.clone()))
            });
        if let Some(key) = victim {
            self.entries.remove(&key);
        }
    }
}

pub fn probe_backoff_secs(base_interval_secs: u64, probe_failures: u32) -> u64 {
    // First retry after a probe failure still uses the base interval;
    // exponential backoff kicks in from the second consecutive failure.
    let shift = probe_failures.saturating_sub(1).min(PROBE_BACKOFF_CAP_SHIFT);
    base_interval_secs.saturating_mul(1u64 << shift)
}

/// Classify an HTTP fetch error string (formats from
/// `fetch/http.rs::BlockingHttpFetcher::fetch_bytes`).
pub fn classify_http_fetch_error(detail: &str) -> RepoTransportErrorClass {
    if detail.starts_with("http status") {
        // The peer answered: transport is fine, the failure is protocol-level.
        RepoTransportErrorClass::Protocol
    } else if detail.starts_with("http request failed") || detail.starts_with("http read body failed") {
        RepoTransportErrorClass::TransportFetch
    } else {
        RepoTransportErrorClass::Unknown
    }
}

/// Classify an rsync fetch error string using the same host-level substring
/// set as the run-local rsync failure scope short-circuit.
pub fn classify_rsync_fetch_error(detail: &str) -> RepoTransportErrorClass {
    if crate::parallel::repo_scheduler::is_host_level_rsync_failure(detail)
        // Stall failures are transport death too: the fail-fast mechanism
        // gives up when wall-clock windows pass with no (additional) progress
        // (#141 M9). Content-level errors fail immediately and never carry
        // this marker, so they still do not count.
        || detail.contains("rsync fail-fast gave up")
    {
        RepoTransportErrorClass::TransportFetch
    } else {
        // Content/protocol-level rsync failures (digest mismatch, vanished
        // files, partial transfers) never count toward the blacklist.
        RepoTransportErrorClass::Unknown
    }
}

/// Map a structured RRDP sync error to a transport error class.
pub fn classify_rrdp_sync_error(err: &crate::sync::rrdp::RrdpSyncError) -> RepoTransportErrorClass {
    match err {
        crate::sync::rrdp::RrdpSyncError::Fetch(detail) => classify_http_fetch_error(detail),
        crate::sync::rrdp::RrdpSyncError::Rrdp(_) => RepoTransportErrorClass::Protocol,
        crate::sync::rrdp::RrdpSyncError::Storage(_) => RepoTransportErrorClass::Storage,
    }
}

/// Map a structured rsync-side repo sync error to a transport error class.
pub fn classify_rsync_repo_sync_error(err: &crate::sync::repo::RepoSyncError) -> RepoTransportErrorClass {
    match err {
        crate::sync::repo::RepoSyncError::Rsync(crate::fetch::rsync::RsyncFetchError::Fetch(detail)) => {
            classify_rsync_fetch_error(detail)
        }
        crate::sync::repo::RepoSyncError::Storage(_) => RepoTransportErrorClass::Storage,
        _ => RepoTransportErrorClass::Protocol,
    }
}

impl RepoTransportMode {
    pub fn as_str(self) -> &'static str {
        match self {
            RepoTransportMode::Rrdp => "rrdp",
            RepoTransportMode::Rsync => "rsync",
        }
    }
}

// Keep the public surface free of internal key types; the (transport, uri)
// tuple key is an implementation detail of the lookup map.

#[cfg(test)]
#[path = "dead_repo_blacklist_tests.rs"]
mod tests;
