//! Storage-owned first-run setup for the native iPhone application.

use std::{
    error::Error,
    fmt, fs,
    path::{Path, PathBuf},
    sync::{Arc, Mutex},
};

use sha2::{Digest as _, Sha256};
use url::Url;

use crate::{
    config::{Config, ConfigError, ConfigLoader, GitRemote},
    git::{
        GitCredential, GitCredentialProvider, GitError, GitIdentity, GitOperationControl,
        GitProgressPhase, GitRepository,
    },
    repository::{DirectoryPath, Repository, SecretBytes},
    secret_store::{
        NativeSecretStore, SecretCachePolicy, SecretProtectionPolicy, SecretStoreError,
    },
};

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

impl From<GitProgressPhase> for MobileOnboardingPhase {
    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 MobileOnboardingProgress {
    phase: MobileOnboardingPhase,
    title: String,
    detail: String,
}

impl MobileOnboardingProgress {
    pub fn phase(&self) -> MobileOnboardingPhase {
        self.phase
    }

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

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

pub struct MobileOnboardingOperation {
    control: GitOperationControl,
    phase: Arc<Mutex<MobileOnboardingPhase>>,
}

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

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

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

    pub fn discover(
        &self,
        request: &MobileOnboardingRequest,
    ) -> Result<MobileOnboardingDiscovery, MobileOnboardingError> {
        let paths = MobileOnboardingPaths::system()?;
        paths.prepare_root()?;
        let branches = GitRepository::discover_remote_branches(
            &paths.root,
            GitIdentity::ironstorage(),
            &request.remote,
            request,
            &self.control,
        )
        .map_err(MobileOnboardingError::from_git)?;
        let selected_branch = branches
            .iter()
            .position(|branch| branch == "main")
            .unwrap_or_default();
        Ok(MobileOnboardingDiscovery {
            branches,
            selected_branch,
        })
    }

    pub fn setup(
        &self,
        request: &MobileOnboardingRequest,
        branch: &str,
        use_existing: bool,
    ) -> Result<MobileOnboardingOutcome, MobileOnboardingError> {
        match Config::load(None) {
            Ok(_) => return Err(MobileOnboardingError::already_configured()),
            Err(ConfigError::NotFound { .. }) => {}
            Err(error) => return Err(MobileOnboardingError::from_config(error)),
        }
        let paths = MobileOnboardingPaths::system()?;
        paths.prepare_root()?;
        let repository = if use_existing {
            let repository = Repository::open(&paths.vault)
                .map_err(|_| MobileOnboardingError::inaccessible_repository())?;
            let git = GitRepository::open(&repository, GitIdentity::ironstorage())
                .map_err(MobileOnboardingError::from_git)?;
            let actual = git
                .remote_url(request.remote.name().as_str())
                .map_err(MobileOnboardingError::from_git)?;
            if actual != request.remote.url().as_str()
                || git.current_branch().ok().as_deref() != Some(branch)
            {
                return Err(MobileOnboardingError::different_existing_clone());
            }
            repository
        } else {
            if paths
                .vault
                .read_dir()
                .ok()
                .is_some_and(|mut entries| entries.next().is_some())
            {
                return Err(MobileOnboardingError::existing_clone());
            }
            GitRepository::clone_into_controlled(
                &paths.vault,
                GitIdentity::ironstorage(),
                &request.remote,
                branch,
                request,
                &self.control,
            )
            .map_err(MobileOnboardingError::from_git)?;
            Repository::open(&paths.vault)
                .map_err(|_| MobileOnboardingError::inaccessible_repository())?
        };
        self.control
            .report(GitProgressPhase::Refreshing)
            .map_err(MobileOnboardingError::from_git)?;
        fs::create_dir_all(&paths.keys).map_err(|_| MobileOnboardingError::configuration())?;
        set_private_directory(&paths.keys).map_err(|_| MobileOnboardingError::configuration())?;
        let default_key = default_key(&repository)?;
        store_application_token(request)?;
        Config::create_mobile_clone(
            paths.config,
            &paths.vault,
            &paths.keys,
            &default_key,
            &request.remote,
        )
        .map_err(MobileOnboardingError::from_config)?;
        Ok(MobileOnboardingOutcome {
            title: "Password Store Ready".to_owned(),
            detail: format!("The {} branch is available locally.", branch),
        })
    }
}

pub struct MobileOnboardingRequest {
    account: String,
    token: SecretBytes,
    remote: GitRemote,
}

impl MobileOnboardingRequest {
    pub fn new(
        server_url: String,
        account: String,
        repository_path: String,
        token: Vec<u8>,
    ) -> Result<Self, MobileOnboardingError> {
        let token = SecretBytes::new(token);
        GitCredential::new(account.clone(), token.expose().to_vec())
            .map_err(|_| MobileOnboardingError::invalid("Account or application token"))?;
        let remote_url = repository_url(&server_url, &repository_path)?;
        let server_id = stable_identifier("server", remote_origin(&remote_url).as_bytes());
        let application_id = stable_identifier("repository", remote_url.as_str().as_bytes());
        let remote = GitRemote::https("origin", remote_url.to_string(), server_id, application_id)
            .map_err(MobileOnboardingError::from_config)?;
        Ok(Self {
            account,
            token,
            remote,
        })
    }

    pub fn remote(&self) -> &GitRemote {
        &self.remote
    }
}

pub fn replace_configured_application_token(
    account: String,
    token: Vec<u8>,
) -> Result<(), MobileOnboardingError> {
    let credential = GitCredential::new(account, token)
        .map_err(|_| MobileOnboardingError::invalid("Account or application token"))?;
    let config = Config::load(None).map_err(MobileOnboardingError::for_token_update)?;
    let remote = config
        .git_remotes()
        .iter()
        .find(|remote| remote.name().as_str() == "origin")
        .ok_or_else(MobileOnboardingError::token_configuration)?;
    store_git_credential(remote, &credential)
}

impl fmt::Debug for MobileOnboardingRequest {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter
            .debug_struct("MobileOnboardingRequest")
            .field("account", &self.account)
            .field("remote", &self.remote)
            .field("token", &"[REDACTED]")
            .finish()
    }
}

impl GitCredentialProvider for MobileOnboardingRequest {
    fn credential(
        &self,
        server: &crate::config::ServerId,
        application: &crate::config::ApplicationId,
    ) -> Result<GitCredential, GitError> {
        if server != self.remote.server_id() || application != self.remote.application_id() {
            return Err(GitError::CredentialsUnavailable);
        }
        GitCredential::new(self.account.clone(), self.token.expose().to_vec())
    }
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MobileOnboardingDiscovery {
    branches: Vec<String>,
    selected_branch: usize,
}

impl MobileOnboardingDiscovery {
    pub fn branches(&self) -> &[String] {
        &self.branches
    }

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

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

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

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

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MobileOnboardingErrorKind {
    InvalidInput,
    UnsupportedRemote,
    Authentication,
    Repository,
    ExistingClone,
    Interrupted,
    SecureStorage,
    Configuration,
    AlreadyConfigured,
}

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

impl MobileOnboardingError {
    pub fn kind(&self) -> MobileOnboardingErrorKind {
        self.kind
    }

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

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

    fn invalid(field: &'static str) -> Self {
        Self {
            kind: MobileOnboardingErrorKind::InvalidInput,
            title: "Check Setup Details",
            detail: format!("{field} is invalid."),
        }
    }

    fn existing_clone() -> Self {
        Self {
            kind: MobileOnboardingErrorKind::ExistingClone,
            title: "Local Clone Already Exists",
            detail: "Choose Use Existing to open it without replacing any files.".to_owned(),
        }
    }

    fn different_existing_clone() -> Self {
        Self {
            kind: MobileOnboardingErrorKind::ExistingClone,
            title: "Different Local Clone",
            detail: "The existing clone uses a different remote or branch and was not changed."
                .to_owned(),
        }
    }

    fn inaccessible_repository() -> Self {
        Self {
            kind: MobileOnboardingErrorKind::Repository,
            title: "Password Store Is Inaccessible",
            detail: "The local password-store repository could not be opened.".to_owned(),
        }
    }

    fn configuration() -> Self {
        Self {
            kind: MobileOnboardingErrorKind::Configuration,
            title: "Setup Was Not Saved",
            detail: "IronStorage could not save its local configuration.".to_owned(),
        }
    }

    fn token_configuration() -> Self {
        Self {
            kind: MobileOnboardingErrorKind::Configuration,
            title: "Token Was Not Updated",
            detail: "Set up the password store before replacing its application token.".to_owned(),
        }
    }

    fn for_token_update(_error: ConfigError) -> Self {
        Self::token_configuration()
    }

    fn already_configured() -> Self {
        Self {
            kind: MobileOnboardingErrorKind::AlreadyConfigured,
            title: "IronStorage Is Already Configured",
            detail: "The existing configuration and local clone were not replaced.".to_owned(),
        }
    }

    fn from_git(error: GitError) -> Self {
        match error {
            GitError::ForbiddenRemoteUrl => Self {
                kind: MobileOnboardingErrorKind::UnsupportedRemote,
                title: "HTTPS Required",
                detail: "Use a credential-free HTTPS server URL. SSH and helper transports are not supported."
                    .to_owned(),
            },
            GitError::AuthenticationFailed
            | GitError::CredentialsUnavailable
            | GitError::CredentialAccessDenied
            | GitError::CredentialCancelled => Self {
                kind: MobileOnboardingErrorKind::Authentication,
                title: "Authentication Failed",
                detail: "Check the account and application token, then try again.".to_owned(),
            },
            GitError::Cancelled => Self {
                kind: MobileOnboardingErrorKind::Interrupted,
                title: "Setup Interrupted",
                detail: "No existing local clone or configuration was replaced.".to_owned(),
            },
            GitError::DirtyWorktree => Self::existing_clone(),
            GitError::NetworkUnavailable | GitError::TlsFailed => Self {
                kind: MobileOnboardingErrorKind::Repository,
                title: "Server Unavailable",
                detail: "Check the HTTPS server and network connection, then try again.".to_owned(),
            },
            _ => Self {
                kind: MobileOnboardingErrorKind::Repository,
                title: "Repository Could Not Be Opened",
                detail: "Check the repository path and selected branch, then try again.".to_owned(),
            },
        }
    }

    fn from_config(error: ConfigError) -> Self {
        match error {
            ConfigError::InvalidRemoteUrl { .. } => Self::from_git(GitError::ForbiddenRemoteUrl),
            ConfigError::AlreadyConfigured { .. } => Self::already_configured(),
            _ => Self::configuration(),
        }
    }

    fn from_secret(error: SecretStoreError) -> Self {
        let detail = match error {
            SecretStoreError::Denied => "Access to secure token storage was denied.",
            SecretStoreError::Cancelled => "Secure token storage was cancelled.",
            _ => "Secure application-token storage is unavailable.",
        };
        Self {
            kind: MobileOnboardingErrorKind::SecureStorage,
            title: "Token Was Not Saved",
            detail: detail.to_owned(),
        }
    }
}

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

impl Error for MobileOnboardingError {}

struct MobileOnboardingPaths {
    root: PathBuf,
    config: PathBuf,
    vault: PathBuf,
    keys: PathBuf,
}

impl MobileOnboardingPaths {
    fn system() -> Result<Self, MobileOnboardingError> {
        let config = ConfigLoader::system()
            .map_err(MobileOnboardingError::from_config)?
            .default_path();
        let root = config
            .parent()
            .map(Path::to_owned)
            .ok_or_else(MobileOnboardingError::configuration)?;
        Ok(Self {
            vault: root.join("vault"),
            keys: root.join("keys"),
            root,
            config,
        })
    }

    fn prepare_root(&self) -> Result<(), MobileOnboardingError> {
        fs::create_dir_all(&self.root).map_err(|_| MobileOnboardingError::configuration())?;
        set_private_directory(&self.root).map_err(|_| MobileOnboardingError::configuration())
    }
}

fn repository_url(server_url: &str, repository_path: &str) -> Result<Url, MobileOnboardingError> {
    let mut server =
        Url::parse(server_url).map_err(|_| MobileOnboardingError::invalid("HTTPS server URL"))?;
    if server.scheme() != "https"
        || server.host_str().is_none()
        || !server.username().is_empty()
        || server.password().is_some()
        || server.query().is_some()
        || server.fragment().is_some()
    {
        return Err(MobileOnboardingError::from_git(
            GitError::ForbiddenRemoteUrl,
        ));
    }
    let parts = repository_path.split('/').collect::<Vec<_>>();
    if parts.len() < 2
        || parts.iter().any(|part| {
            part.is_empty()
                || matches!(*part, "." | "..")
                || !part
                    .bytes()
                    .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
        })
    {
        return Err(MobileOnboardingError::invalid("Repository path"));
    }
    let mut path = server.path().trim_end_matches('/').to_owned();
    path.push('/');
    path.push_str(&parts.join("/"));
    if !path.ends_with(".git") {
        path.push_str(".git");
    }
    server.set_path(&path);
    Ok(server)
}

fn remote_origin(url: &Url) -> String {
    format!(
        "{}://{}:{}",
        url.scheme(),
        url.host_str().unwrap_or_default(),
        url.port_or_known_default().unwrap_or(443)
    )
}

fn stable_identifier(prefix: &str, value: &[u8]) -> String {
    let digest = Sha256::digest(value);
    format!("{prefix}-{}", hex_prefix(&digest, 16))
}

fn hex_prefix(bytes: &[u8], length: usize) -> String {
    const HEX: &[u8; 16] = b"0123456789abcdef";
    bytes
        .iter()
        .flat_map(|byte| [HEX[(byte >> 4) as usize], HEX[(byte & 0x0f) as usize]])
        .take(length)
        .map(char::from)
        .collect()
}

fn default_key(repository: &Repository) -> Result<String, MobileOnboardingError> {
    let root =
        DirectoryPath::parse("").map_err(|_| MobileOnboardingError::inaccessible_repository())?;
    let contents = repository
        .read_policy_file(&root, false)
        .map_err(|_| MobileOnboardingError::inaccessible_repository())?
        .ok_or_else(MobileOnboardingError::inaccessible_repository)?;
    let text = std::str::from_utf8(&contents)
        .map_err(|_| MobileOnboardingError::inaccessible_repository())?;
    text.lines()
        .map(|line| line.split('#').next().unwrap_or_default().trim())
        .find(|identity| !identity.is_empty())
        .filter(|identity| identity.len() <= 512 && !identity.chars().any(char::is_control))
        .map(str::to_owned)
        .ok_or_else(MobileOnboardingError::inaccessible_repository)
}

fn store_application_token(request: &MobileOnboardingRequest) -> Result<(), MobileOnboardingError> {
    let credential = request
        .credential(request.remote.server_id(), request.remote.application_id())
        .map_err(MobileOnboardingError::from_git)?;
    store_git_credential(&request.remote, &credential)
}

fn store_git_credential(
    remote: &GitRemote,
    credential: &GitCredential,
) -> Result<(), MobileOnboardingError> {
    let store = NativeSecretStore::system(
        SecretCachePolicy::Disabled,
        SecretProtectionPolicy::device_unlocked(),
    )
    .map_err(MobileOnboardingError::from_secret)?;
    store.unlock().map_err(MobileOnboardingError::from_secret)?;
    store
        .store_https_git_credential(
            remote.server_id(),
            remote.application_id(),
            credential.username(),
            SecretBytes::new(credential.password().to_vec()),
        )
        .map_err(MobileOnboardingError::from_secret)?;
    store.lock().map_err(MobileOnboardingError::from_secret)
}

fn progress_copy(phase: MobileOnboardingPhase) -> MobileOnboardingProgress {
    let (title, detail) = match phase {
        MobileOnboardingPhase::Validating => ("Checking Setup", "Validating the HTTPS repository."),
        MobileOnboardingPhase::Authenticating => (
            "Authenticating",
            "Using the application token from protected memory.",
        ),
        MobileOnboardingPhase::Receiving => {
            ("Downloading Store", "Receiving Git objects securely.")
        }
        MobileOnboardingPhase::Integrating => ("Opening Store", "Preparing the selected branch."),
        MobileOnboardingPhase::Finishing => ("Finishing Setup", "Saving protected local state."),
    };
    MobileOnboardingProgress {
        phase,
        title: title.to_owned(),
        detail: detail.to_owned(),
    }
}

fn set_private_directory(path: &Path) -> std::io::Result<()> {
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt as _;
        fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
    }
    #[cfg(not(unix))]
    let _ = path;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::{
        MobileOnboardingErrorKind, MobileOnboardingOperation, MobileOnboardingPhase,
        MobileOnboardingRequest,
    };

    #[test]
    fn request_is_https_only_stable_and_secret_safe() {
        let request = MobileOnboardingRequest::new(
            "https://example.test/gitea".to_owned(),
            "alice".to_owned(),
            "team/passwords".to_owned(),
            b"DO-NOT-RENDER".to_vec(),
        )
        .expect("valid request");
        assert_eq!(
            request.remote().url().as_str(),
            "https://example.test/gitea/team/passwords.git"
        );
        assert!(!format!("{request:?}").contains("DO-NOT-RENDER"));
        let error = MobileOnboardingRequest::new(
            "ssh://example.test".to_owned(),
            "alice".to_owned(),
            "team/passwords".to_owned(),
            b"DO-NOT-RENDER".to_vec(),
        )
        .expect_err("SSH must be rejected");
        assert_eq!(error.kind(), MobileOnboardingErrorKind::UnsupportedRemote);
        assert!(!error.to_string().contains("DO-NOT-RENDER"));
    }

    #[test]
    fn operation_cancellation_is_visible_before_transport_work() {
        let operation = MobileOnboardingOperation::default();
        assert_eq!(
            operation.progress().phase(),
            MobileOnboardingPhase::Validating
        );
        operation.cancel();
        assert!(operation.control.is_cancelled());
    }
}
