//! Storage-owned remote activity and pull-to-refresh models for iPhone Home.

use std::{
    collections::BTreeSet,
    error::Error,
    fmt,
    path::Path,
    sync::{Arc, Mutex},
    time::{SystemTime, UNIX_EPOCH},
};

use crate::{
    config::{Config, ConfigError, GitRemote},
    git::{
        GitChangeKind, GitCommitActivity, GitDivergence, GitError, GitOperationControl,
        GitProgressPhase, GitRepository, PullOutcome,
    },
    repository::{Repository, RepositoryError},
    secret_store::{
        NativeSecretStore, SecretCachePolicy, SecretProtectionPolicy, SecretStoreError,
    },
};

const ACTIVITY_LIMIT: usize = 50;
const STALE_AFTER_SECONDS: i64 = 5 * 60;

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MobileHomeFreshness {
    NeverRefreshed,
    Cached,
    Current,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MobileHomeChangeKind {
    PasswordEntry,
    RecipientPolicy,
    RecipientSignature,
    RepositoryFile,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MobileHomeChangeStatus {
    Added,
    Modified,
    Deleted,
}

impl From<GitChangeKind> for MobileHomeChangeStatus {
    fn from(kind: GitChangeKind) -> Self {
        match kind {
            GitChangeKind::Added => Self::Added,
            GitChangeKind::Modified => Self::Modified,
            GitChangeKind::Deleted => Self::Deleted,
        }
    }
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MobileHomeSummaryRow {
    id: String,
    title: String,
    detail: String,
    system_image: String,
}

impl MobileHomeSummaryRow {
    pub fn id(&self) -> &str {
        &self.id
    }

    pub fn title(&self) -> &str {
        &self.title
    }

    pub fn detail(&self) -> &str {
        &self.detail
    }

    pub fn system_image(&self) -> &str {
        &self.system_image
    }
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MobileHomeChange {
    id: String,
    title: String,
    detail: String,
    system_image: String,
    kind: MobileHomeChangeKind,
    status: MobileHomeChangeStatus,
}

impl MobileHomeChange {
    pub fn id(&self) -> &str {
        &self.id
    }

    pub fn title(&self) -> &str {
        &self.title
    }

    pub fn detail(&self) -> &str {
        &self.detail
    }

    pub fn system_image(&self) -> &str {
        &self.system_image
    }

    pub fn kind(&self) -> MobileHomeChangeKind {
        self.kind
    }

    pub fn status(&self) -> MobileHomeChangeStatus {
        self.status
    }
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MobileHomeCommit {
    id: String,
    title: String,
    detail: String,
    system_image: String,
    timestamp: i64,
    changes: Vec<MobileHomeChange>,
}

impl MobileHomeCommit {
    pub fn id(&self) -> &str {
        &self.id
    }

    pub fn title(&self) -> &str {
        &self.title
    }

    pub fn detail(&self) -> &str {
        &self.detail
    }

    pub fn system_image(&self) -> &str {
        &self.system_image
    }

    pub fn timestamp(&self) -> i64 {
        self.timestamp
    }

    pub fn changes(&self) -> &[MobileHomeChange] {
        &self.changes
    }
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MobileHomeNotice {
    title: String,
    detail: String,
    system_image: String,
}

impl MobileHomeNotice {
    pub fn title(&self) -> &str {
        &self.title
    }

    pub fn detail(&self) -> &str {
        &self.detail
    }

    pub fn system_image(&self) -> &str {
        &self.system_image
    }
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MobileHomePage {
    freshness: MobileHomeFreshness,
    refreshed_at: Option<i64>,
    summaries: Vec<MobileHomeSummaryRow>,
    incoming: Vec<MobileHomeCommit>,
    outgoing: Vec<MobileHomeCommit>,
    incoming_total: usize,
    outgoing_total: usize,
    notice: Option<MobileHomeNotice>,
}

impl MobileHomePage {
    pub fn freshness(&self) -> MobileHomeFreshness {
        self.freshness
    }

    pub fn refreshed_at(&self) -> Option<i64> {
        self.refreshed_at
    }

    pub fn summaries(&self) -> &[MobileHomeSummaryRow] {
        &self.summaries
    }

    pub fn incoming(&self) -> &[MobileHomeCommit] {
        &self.incoming
    }

    pub fn outgoing(&self) -> &[MobileHomeCommit] {
        &self.outgoing
    }

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

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

    pub fn notice(&self) -> Option<&MobileHomeNotice> {
        self.notice.as_ref()
    }
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MobileHomePhase {
    Validating,
    Authenticating,
    Receiving,
    Integrating,
    Finishing,
}

impl From<GitProgressPhase> for MobileHomePhase {
    fn from(phase: GitProgressPhase) -> Self {
        match phase {
            GitProgressPhase::Validating => Self::Validating,
            GitProgressPhase::Authenticating => Self::Authenticating,
            GitProgressPhase::Receiving => Self::Receiving,
            GitProgressPhase::Integrating | GitProgressPhase::Sending => Self::Integrating,
            GitProgressPhase::Refreshing => Self::Finishing,
        }
    }
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MobileHomeProgress {
    phase: MobileHomePhase,
    title: String,
    detail: String,
}

impl MobileHomeProgress {
    pub fn phase(&self) -> MobileHomePhase {
        self.phase
    }

    pub fn title(&self) -> &str {
        &self.title
    }

    pub fn detail(&self) -> &str {
        &self.detail
    }
}

pub struct MobileHomeOperation {
    control: GitOperationControl,
    phase: Arc<Mutex<MobileHomePhase>>,
}

impl Default for MobileHomeOperation {
    fn default() -> Self {
        let phase = Arc::new(Mutex::new(MobileHomePhase::Validating));
        let observed = Arc::clone(&phase);
        Self {
            control: GitOperationControl::new(move |phase| {
                if let Ok(mut current) = observed.lock() {
                    *current = phase.into();
                }
            }),
            phase,
        }
    }
}

impl MobileHomeOperation {
    pub fn cancel(&self) {
        self.control.cancel();
    }

    pub fn progress(&self) -> MobileHomeProgress {
        progress_copy(
            self.phase
                .lock()
                .map_or(MobileHomePhase::Validating, |phase| *phase),
        )
    }

    pub fn cached(&self) -> Result<MobileHomePage, MobileHomeError> {
        let storage = MobileHomeStorage::load()?;
        let refreshed_at = storage.config.mobile_home_refreshed_at();
        storage.page(
            if refreshed_at.is_some() {
                MobileHomeFreshness::Cached
            } else {
                MobileHomeFreshness::NeverRefreshed
            },
            refreshed_at,
            None,
        )
    }

    pub fn refresh_if_stale(&self) -> Result<MobileHomePage, MobileHomeError> {
        let storage = MobileHomeStorage::load()?;
        let now = unix_seconds()?;
        if !is_stale(storage.config.mobile_home_refreshed_at(), now) {
            return storage.page(
                MobileHomeFreshness::Cached,
                storage.config.mobile_home_refreshed_at(),
                None,
            );
        }
        storage.refresh(now, &self.control)
    }

    pub fn refresh(&self) -> Result<MobileHomePage, MobileHomeError> {
        MobileHomeStorage::load()?.refresh(unix_seconds()?, &self.control)
    }

    pub fn pull(&self) -> Result<MobileHomePage, MobileHomeError> {
        MobileHomeStorage::load()?.pull(unix_seconds()?, &self.control)
    }
}

struct MobileHomeStorage {
    config: Config,
    git: GitRepository,
    remote: GitRemote,
}

impl MobileHomeStorage {
    fn load() -> Result<Self, MobileHomeError> {
        let config = Config::load(None).map_err(MobileHomeError::from_config)?;
        let repository =
            Repository::open(config.vault()).map_err(MobileHomeError::from_repository)?;
        let git = GitRepository::open(&repository, config.git_identity().clone())
            .map_err(MobileHomeError::from_git)?;
        let remote = config
            .git_remote(None)
            .cloned()
            .ok_or_else(MobileHomeError::missing_remote)?;
        Ok(Self {
            config,
            git,
            remote,
        })
    }

    fn refresh(
        &self,
        now: i64,
        control: &GitOperationControl,
    ) -> Result<MobileHomePage, MobileHomeError> {
        let store = self.credentials()?;
        let refreshed = self.git.fetch_with_transport_controlled(
            &self.remote,
            &store,
            &crate::git::EmbeddedFetchTransport,
            control,
        );
        let locked = store.lock();
        refreshed.map_err(MobileHomeError::from_git)?;
        if let Err(error) = locked {
            return Err(MobileHomeError::partial_secret(error));
        }
        control
            .report(GitProgressPhase::Refreshing)
            .map_err(MobileHomeError::from_git)?;
        self.current_page(now, None)
    }

    fn pull(
        &self,
        now: i64,
        control: &GitOperationControl,
    ) -> Result<MobileHomePage, MobileHomeError> {
        let store = self.credentials()?;
        let pulled = self.git.pull_with_transport_controlled(
            &self.remote,
            None,
            &store,
            &crate::git::EmbeddedFetchTransport,
            control,
        );
        let locked = store.lock();
        let outcome = pulled.map_err(MobileHomeError::from_git)?;
        if let Err(error) = locked {
            return Err(MobileHomeError::partial_secret(error));
        }
        control
            .report(GitProgressPhase::Refreshing)
            .map_err(MobileHomeError::from_git)?;
        self.current_page(now, Some(pull_notice(outcome)))
            .map_err(MobileHomeError::after_pull)
    }

    fn credentials(&self) -> Result<NativeSecretStore, MobileHomeError> {
        let store = NativeSecretStore::system(
            SecretCachePolicy::Disabled,
            SecretProtectionPolicy::device_unlocked(),
        )
        .map_err(MobileHomeError::from_secret)?;
        store.unlock().map_err(MobileHomeError::from_secret)?;
        Ok(store)
    }

    fn current_page(
        &self,
        now: i64,
        notice: Option<MobileHomeNotice>,
    ) -> Result<MobileHomePage, MobileHomeError> {
        let persisted = self.config.update_mobile_home_refresh(now).is_ok();
        let notice = if persisted {
            notice
        } else {
            Some(MobileHomeNotice {
                title: notice
                    .as_ref()
                    .map_or("Status Refreshed", MobileHomeNotice::title)
                    .to_owned(),
                detail: "The repository was updated, but the refresh time could not be cached."
                    .to_owned(),
                system_image: "exclamationmark.triangle".to_owned(),
            })
        };
        self.page(MobileHomeFreshness::Current, Some(now), notice)
            .map_err(MobileHomeError::after_refresh)
    }

    fn page(
        &self,
        freshness: MobileHomeFreshness,
        refreshed_at: Option<i64>,
        notice: Option<MobileHomeNotice>,
    ) -> Result<MobileHomePage, MobileHomeError> {
        let divergence = self
            .git
            .divergence(&self.remote, ACTIVITY_LIMIT)
            .map_err(MobileHomeError::from_git)?;
        Ok(page_from_divergence(
            &divergence,
            freshness,
            refreshed_at,
            notice,
        ))
    }
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MobileHomeErrorKind {
    MissingConfiguration,
    Configuration,
    Authentication,
    Conflict,
    DirtyLocalChanges,
    Offline,
    Interrupted,
    SecureStorage,
    Repository,
    PartialProgress,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MobileHomeError {
    kind: MobileHomeErrorKind,
    title: &'static str,
    detail: String,
}

impl MobileHomeError {
    pub fn kind(&self) -> MobileHomeErrorKind {
        self.kind
    }

    pub fn title(&self) -> &str {
        self.title
    }

    pub fn detail(&self) -> &str {
        &self.detail
    }

    fn missing_remote() -> Self {
        Self {
            kind: MobileHomeErrorKind::Configuration,
            title: "Remote Is Not Configured",
            detail: "Set up an HTTPS password-store remote before refreshing Home.".to_owned(),
        }
    }

    fn from_config(error: ConfigError) -> Self {
        match error {
            ConfigError::NotFound { .. } => Self {
                kind: MobileHomeErrorKind::MissingConfiguration,
                title: "Set Up Password Store",
                detail: "Complete first-run setup before loading remote activity.".to_owned(),
            },
            _ => Self {
                kind: MobileHomeErrorKind::Configuration,
                title: "Configuration Is Unavailable",
                detail: "Check the local IronStorage configuration and try again.".to_owned(),
            },
        }
    }

    fn from_repository(_error: RepositoryError) -> Self {
        Self {
            kind: MobileHomeErrorKind::Repository,
            title: "Password Store Is Unavailable",
            detail: "The local password-store clone could not be opened.".to_owned(),
        }
    }

    fn from_git(error: GitError) -> Self {
        match error {
            GitError::AuthenticationFailed
            | GitError::CredentialsUnavailable
            | GitError::CredentialAccessDenied
            | GitError::CredentialCancelled => Self {
                kind: MobileHomeErrorKind::Authentication,
                title: "Authentication Failed",
                detail: "Update the application token in Preferences and try again.".to_owned(),
            },
            GitError::MergeConflicts { conflicts } => {
                let names = conflicts
                    .iter()
                    .take(3)
                    .map(|conflict| display_path(conflict.path()))
                    .collect::<Vec<_>>();
                let suffix = if conflicts.len() > names.len() {
                    format!(" and {} more", conflicts.len() - names.len())
                } else {
                    String::new()
                };
                Self {
                    kind: MobileHomeErrorKind::Conflict,
                    title: "Pull Needs Conflict Resolution",
                    detail: format!(
                        "Remote changes were fetched, but the local clone was not changed. Resolve {}{} and pull again.",
                        names.join(", "),
                        suffix
                    ),
                }
            }
            GitError::DirtyWorktree => Self {
                kind: MobileHomeErrorKind::DirtyLocalChanges,
                title: "Local Changes Need Attention",
                detail: "Commit or discard local changes before pulling remote activity.".to_owned(),
            },
            GitError::NetworkUnavailable => Self {
                kind: MobileHomeErrorKind::Offline,
                title: "Server Is Offline",
                detail: "Cached activity remains available. Check the network and try again."
                    .to_owned(),
            },
            GitError::TlsFailed => Self {
                kind: MobileHomeErrorKind::Offline,
                title: "Secure Connection Failed",
                detail: "The HTTPS server identity could not be verified. Cached activity was not replaced."
                    .to_owned(),
            },
            GitError::Cancelled => Self {
                kind: MobileHomeErrorKind::Interrupted,
                title: "Refresh Interrupted",
                detail: "The local clone remains recoverable. Pull or refresh again when ready."
                    .to_owned(),
            },
            GitError::ForbiddenRemoteUrl => Self {
                kind: MobileHomeErrorKind::Configuration,
                title: "HTTPS Remote Required",
                detail: "The configured credential-free HTTPS remote does not match the local clone."
                    .to_owned(),
            },
            _ => Self {
                kind: MobileHomeErrorKind::Repository,
                title: "Remote Activity Is Unavailable",
                detail: "The storage-owned Git state could not be loaded.".to_owned(),
            },
        }
    }

    fn from_secret(error: SecretStoreError) -> Self {
        let detail = match error {
            SecretStoreError::Denied => "Access to the application token was denied.",
            SecretStoreError::Cancelled => "Application-token access was cancelled.",
            SecretStoreError::Missing => {
                "No application token is stored. Update it in Preferences."
            }
            _ => "Protected application-token storage is unavailable.",
        };
        Self {
            kind: MobileHomeErrorKind::SecureStorage,
            title: "Token Is Unavailable",
            detail: detail.to_owned(),
        }
    }

    fn partial_secret(_error: SecretStoreError) -> Self {
        Self {
            kind: MobileHomeErrorKind::PartialProgress,
            title: "Remote Updated With a Warning",
            detail: "Remote data was received, but protected credential state could not be closed cleanly. Restart IronStorage before retrying."
                .to_owned(),
        }
    }

    fn after_refresh(_error: Self) -> Self {
        Self {
            kind: MobileHomeErrorKind::PartialProgress,
            title: "Remote Refreshed, Activity Unavailable",
            detail: "Remote tracking data was updated, but the refreshed activity page could not be built. The local clone remains unchanged."
                .to_owned(),
        }
    }

    fn after_pull(_error: Self) -> Self {
        Self {
            kind: MobileHomeErrorKind::PartialProgress,
            title: "Pull Completed, Activity Unavailable",
            detail: "The local clone was updated, but the refreshed activity page could not be built. Reload Home to retry."
                .to_owned(),
        }
    }
}

impl fmt::Display for MobileHomeError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(formatter, "{}: {}", self.title, self.detail)
    }
}

impl Error for MobileHomeError {}

fn page_from_divergence(
    divergence: &GitDivergence,
    freshness: MobileHomeFreshness,
    refreshed_at: Option<i64>,
    notice: Option<MobileHomeNotice>,
) -> MobileHomePage {
    let ahead = divergence.remote().ahead();
    let behind = divergence.remote().behind();
    let local_paths = divergence
        .status()
        .staged()
        .iter()
        .chain(divergence.status().unstaged())
        .map(|change| change.path().to_owned())
        .collect::<BTreeSet<_>>();
    let divergence_title = match (behind, ahead) {
        (0, 0) => "Up to Date".to_owned(),
        (behind, 0) => format!("{behind} Incoming"),
        (0, ahead) => format!("{ahead} Outgoing"),
        (behind, ahead) => format!("{behind} Incoming · {ahead} Outgoing"),
    };
    let divergence_detail = format!(
        "{} to pull · {} to push",
        commit_count(behind),
        commit_count(ahead)
    );
    let local_count = local_paths.len();
    let summaries = vec![
        MobileHomeSummaryRow {
            id: "tracked-branch".to_owned(),
            title: format!("{}/{}", divergence.remote().name(), divergence.branch()),
            detail: "Tracked HTTPS branch".to_owned(),
            system_image: "point.3.connected.trianglepath.dotted".to_owned(),
        },
        MobileHomeSummaryRow {
            id: "divergence".to_owned(),
            title: divergence_title,
            detail: divergence_detail,
            system_image: if ahead == 0 && behind == 0 {
                "checkmark.circle".to_owned()
            } else {
                "arrow.triangle.2.circlepath".to_owned()
            },
        },
        MobileHomeSummaryRow {
            id: "working-tree".to_owned(),
            title: if local_count == 0 {
                "Working Tree Clean".to_owned()
            } else {
                format!("{} Local", change_count(local_count))
            },
            detail: if local_count == 0 {
                "No uncommitted password-store changes".to_owned()
            } else {
                "Commit or discard these changes before pulling".to_owned()
            },
            system_image: if local_count == 0 {
                "checkmark.shield".to_owned()
            } else {
                "exclamationmark.triangle".to_owned()
            },
        },
    ];
    MobileHomePage {
        freshness,
        refreshed_at,
        summaries,
        incoming: divergence
            .incoming()
            .iter()
            .map(|activity| mobile_commit(activity, true))
            .collect(),
        outgoing: divergence
            .outgoing()
            .iter()
            .map(|activity| mobile_commit(activity, false))
            .collect(),
        incoming_total: behind,
        outgoing_total: ahead,
        notice,
    }
}

fn mobile_commit(activity: &GitCommitActivity, incoming: bool) -> MobileHomeCommit {
    let commit = activity.commit();
    let title = commit
        .message()
        .lines()
        .find(|line| !line.trim().is_empty())
        .map(|line| display_text(line.trim(), 160))
        .filter(|line| !line.is_empty())
        .unwrap_or_else(|| "Untitled Commit".to_owned());
    let author = display_text(commit.author_name(), 80);
    let changes = activity
        .changes()
        .iter()
        .enumerate()
        .map(|(index, change)| {
            mobile_change(
                format!("{}:{index}", commit.id()),
                change.path(),
                change.kind(),
            )
        })
        .collect::<Vec<_>>();
    MobileHomeCommit {
        id: commit.id().to_owned(),
        title,
        detail: format!(
            "{} · {}",
            if author.is_empty() {
                "Unknown author"
            } else {
                &author
            },
            change_count(changes.len())
        ),
        system_image: if incoming {
            "arrow.down.circle".to_owned()
        } else {
            "arrow.up.circle".to_owned()
        },
        timestamp: commit.timestamp(),
        changes,
    }
}

fn mobile_change(id: String, path: &Path, kind: GitChangeKind) -> MobileHomeChange {
    let status = MobileHomeChangeStatus::from(kind);
    let status_name = match status {
        MobileHomeChangeStatus::Added => "Added",
        MobileHomeChangeStatus::Modified => "Modified",
        MobileHomeChangeStatus::Deleted => "Deleted",
    };
    let file_name = path.file_name().and_then(|name| name.to_str());
    let (title, detail, system_image, change_kind) =
        if path.extension().is_some_and(|extension| extension == "gpg") {
            (
                display_path(&path.with_extension("")),
                format!("{status_name} password entry"),
                "key".to_owned(),
                MobileHomeChangeKind::PasswordEntry,
            )
        } else if file_name == Some(".gpg-id") {
            (
                policy_title(path, "recipients"),
                format!("{status_name} recipient policy"),
                "person.2".to_owned(),
                MobileHomeChangeKind::RecipientPolicy,
            )
        } else if file_name == Some(".gpg-id.sig") {
            (
                policy_title(path, "recipient signature"),
                format!("{status_name} recipient signature"),
                "signature".to_owned(),
                MobileHomeChangeKind::RecipientSignature,
            )
        } else {
            (
                display_path(path),
                format!("{status_name} repository file"),
                "doc".to_owned(),
                MobileHomeChangeKind::RepositoryFile,
            )
        };
    MobileHomeChange {
        id,
        title,
        detail,
        system_image,
        kind: change_kind,
        status,
    }
}

fn policy_title(path: &Path, suffix: &str) -> String {
    path.parent()
        .filter(|parent| !parent.as_os_str().is_empty())
        .map_or_else(
            || format!("Root {suffix}"),
            |parent| format!("{} {suffix}", display_path(parent)),
        )
}

fn display_path(path: &Path) -> String {
    display_text(&path.to_string_lossy(), 240)
}

fn display_text(value: &str, maximum: usize) -> String {
    let mut output = value
        .chars()
        .map(|character| {
            if character.is_control() {
                '\u{fffd}'
            } else {
                character
            }
        })
        .take(maximum + 1)
        .collect::<String>();
    if output.chars().count() > maximum {
        output = output.chars().take(maximum.saturating_sub(1)).collect();
        output.push('…');
    }
    output
}

fn commit_count(count: usize) -> String {
    format!("{count} commit{}", if count == 1 { "" } else { "s" })
}

fn change_count(count: usize) -> String {
    format!("{count} change{}", if count == 1 { "" } else { "s" })
}

fn pull_notice(outcome: PullOutcome) -> MobileHomeNotice {
    let (title, detail, system_image) = match outcome {
        PullOutcome::UpToDate => (
            "Already Up to Date",
            "The local password store already contains every fetched commit.",
            "checkmark.circle",
        ),
        PullOutcome::FastForward => (
            "Password Store Updated",
            "Remote commits were applied without changing local history.",
            "arrow.down.circle",
        ),
        PullOutcome::Merged => (
            "Remote Changes Merged",
            "Remote and local commits were merged by storage.",
            "arrow.triangle.merge",
        ),
    };
    MobileHomeNotice {
        title: title.to_owned(),
        detail: detail.to_owned(),
        system_image: system_image.to_owned(),
    }
}

fn progress_copy(phase: MobileHomePhase) -> MobileHomeProgress {
    let (title, detail) = match phase {
        MobileHomePhase::Validating => ("Checking Home", "Validating local and remote Git state."),
        MobileHomePhase::Authenticating => (
            "Authenticating",
            "Reading the application token from protected storage.",
        ),
        MobileHomePhase::Receiving => ("Refreshing Remote", "Receiving remote Git objects."),
        MobileHomePhase::Integrating => (
            "Updating Password Store",
            "Integrating fetched commits into the local clone.",
        ),
        MobileHomePhase::Finishing => {
            ("Updating Home", "Building current activity and divergence.")
        }
    };
    MobileHomeProgress {
        phase,
        title: title.to_owned(),
        detail: detail.to_owned(),
    }
}

fn unix_seconds() -> Result<i64, MobileHomeError> {
    let seconds = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map_err(|_| MobileHomeError {
            kind: MobileHomeErrorKind::Configuration,
            title: "System Time Is Invalid",
            detail: "Correct the device time before refreshing remote activity.".to_owned(),
        })?
        .as_secs();
    i64::try_from(seconds).map_err(|_| MobileHomeError {
        kind: MobileHomeErrorKind::Configuration,
        title: "System Time Is Invalid",
        detail: "Correct the device time before refreshing remote activity.".to_owned(),
    })
}

fn is_stale(refreshed_at: Option<i64>, now: i64) -> bool {
    !matches!(
        refreshed_at,
        Some(refreshed_at)
            if refreshed_at <= now && now.saturating_sub(refreshed_at) < STALE_AFTER_SECONDS
    )
}

#[cfg(test)]
mod tests {
    use std::path::Path;

    use crate::git::{GitChangeKind, GitError};

    use super::{
        MobileHomeChangeKind, MobileHomeChangeStatus, MobileHomeErrorKind, display_text, is_stale,
        mobile_change,
    };

    #[test]
    fn freshness_and_display_safety_are_storage_owned() {
        assert!(is_stale(None, 1_000));
        assert!(!is_stale(Some(900), 1_000));
        assert!(is_stale(Some(699), 1_000));
        assert!(is_stale(Some(1_001), 1_000));
        assert_eq!(display_text("line\nsecret", 20), "line�secret");
        assert_eq!(display_text("abcdef", 4), "abc…");
    }

    #[test]
    fn changed_password_paths_are_classified_before_presentation() {
        let entry = mobile_change(
            "commit:0".to_owned(),
            Path::new("work/mail.gpg"),
            GitChangeKind::Modified,
        );
        assert_eq!(entry.title(), "work/mail");
        assert_eq!(entry.kind(), MobileHomeChangeKind::PasswordEntry);
        assert_eq!(entry.status(), MobileHomeChangeStatus::Modified);
        let policy = mobile_change(
            "commit:1".to_owned(),
            Path::new("team/.gpg-id"),
            GitChangeKind::Added,
        );
        assert_eq!(policy.title(), "team recipients");
        assert_eq!(policy.kind(), MobileHomeChangeKind::RecipientPolicy);
    }

    #[test]
    fn failures_are_typed_and_secret_safe() {
        let offline = super::MobileHomeError::from_git(GitError::NetworkUnavailable);
        assert_eq!(offline.kind(), MobileHomeErrorKind::Offline);
        let dirty = super::MobileHomeError::from_git(GitError::DirtyWorktree);
        assert_eq!(dirty.kind(), MobileHomeErrorKind::DirtyLocalChanges);
        assert!(!offline.to_string().contains("token-value"));
    }
}
