// Copyright 2023 The Jujutsu Authors
//
// Licensed 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
//
// https://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.

//! Defines the interface for the working copy. See `LocalWorkingCopy` for the
//! default local-disk implementation.

use std::any::Any;
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::ffi::OsString;
use std::path::PathBuf;
use std::sync::Arc;

use async_trait::async_trait;
use thiserror::Error;
use tracing::instrument;

use crate::backend::BackendError;
use crate::commit::Commit;
use crate::gitignore::GitIgnoreError;
use crate::gitignore::GitIgnoreFile;
use crate::matchers::Matcher;
use crate::merged_tree::MergedTree;
use crate::op_store::OpStoreError;
use crate::op_store::OperationId;
use crate::op_walk;
use crate::operation::Operation;
use crate::ref_name::WorkspaceName;
use crate::ref_name::WorkspaceNameBuf;
use crate::repo::ReadonlyRepo;
use crate::repo::Repo as _;
use crate::repo::RewriteRootCommit;
use crate::repo_path::InvalidRepoPathError;
use crate::repo_path::RepoPath;
use crate::repo_path::RepoPathBuf;
use crate::settings::UserSettings;
use crate::store::Store;
use crate::transaction::TransactionCommitError;

/// The trait all working-copy implementations must implement.
#[async_trait(?Send)]
pub trait WorkingCopy: Any + Send {
    /// The name/id of the implementation. Used for choosing the right
    /// implementation when loading a working copy.
    fn name(&self) -> &str;

    /// The working copy's workspace name (or identifier.)
    fn workspace_name(&self) -> &WorkspaceName;

    /// The operation this working copy was most recently updated to.
    fn operation_id(&self) -> &OperationId;

    /// The tree this working copy was most recently updated to.
    fn tree(&self) -> Result<&MergedTree, WorkingCopyStateError>;

    /// Patterns that decide which paths from the current tree should be checked
    /// out in the working copy. An empty list means that no paths should be
    /// checked out in the working copy. A single `RepoPath::root()` entry means
    /// that all files should be checked out.
    fn sparse_patterns(&self) -> Result<&[RepoPathBuf], WorkingCopyStateError>;

    /// Locks the working copy and returns an instance with methods for updating
    /// the working copy files and state.
    async fn start_mutation(&self) -> Result<Box<dyn LockedWorkingCopy>, WorkingCopyStateError>;
}

impl dyn WorkingCopy {
    /// Returns reference of the implementation type.
    pub fn downcast_ref<T: WorkingCopy>(&self) -> Option<&T> {
        (self as &dyn Any).downcast_ref()
    }
}

/// The factory which creates and loads a specific type of working copy.
pub trait WorkingCopyFactory {
    /// Create a new working copy from scratch.
    fn init_working_copy(
        &self,
        store: Arc<Store>,
        working_copy_path: PathBuf,
        state_path: PathBuf,
        operation_id: OperationId,
        workspace_name: WorkspaceNameBuf,
        settings: &UserSettings,
    ) -> Result<Box<dyn WorkingCopy>, WorkingCopyStateError>;

    /// Load an existing working copy.
    fn load_working_copy(
        &self,
        store: Arc<Store>,
        working_copy_path: PathBuf,
        state_path: PathBuf,
        settings: &UserSettings,
    ) -> Result<Box<dyn WorkingCopy>, WorkingCopyStateError>;
}

/// A working copy that's being modified.
#[async_trait]
pub trait LockedWorkingCopy: Any + Send {
    /// The operation at the time the lock was taken
    fn old_operation_id(&self) -> &OperationId;

    /// The tree at the time the lock was taken
    fn old_tree(&self) -> &MergedTree;

    /// Snapshot the working copy. Returns the tree and stats.
    async fn snapshot(
        &mut self,
        options: &SnapshotOptions,
    ) -> Result<(MergedTree, SnapshotStats), SnapshotError>;

    /// Check out the specified commit in the working copy.
    async fn check_out(&mut self, commit: &Commit) -> Result<CheckoutStats, CheckoutError>;

    /// Update the workspace name.
    fn rename_workspace(&mut self, new_workspace_name: WorkspaceNameBuf);

    /// Update to another commit without touching the files in the working copy.
    async fn reset(&mut self, commit: &Commit) -> Result<(), ResetError>;

    /// Update to another commit without touching the files in the working copy,
    /// without assuming that the previous tree exists.
    async fn recover(&mut self, commit: &Commit) -> Result<(), ResetError>;

    /// See `WorkingCopy::sparse_patterns()`
    fn sparse_patterns(&self) -> Result<&[RepoPathBuf], WorkingCopyStateError>;

    /// Updates the patterns that decide which paths from the current tree
    /// should be checked out in the working copy.
    // TODO: Use a different error type here so we can include a
    // `SparseNotSupported` variants for working copies that don't support sparse
    // checkouts (e.g. because they use a virtual file system so there's no reason
    // to use sparse).
    async fn set_sparse_patterns(
        &mut self,
        new_sparse_patterns: Vec<RepoPathBuf>,
    ) -> Result<CheckoutStats, CheckoutError>;

    /// Finish the modifications to the working copy by writing the updated
    /// states to disk. Returns the new (unlocked) working copy.
    async fn finish(
        self: Box<Self>,
        operation_id: OperationId,
    ) -> Result<Box<dyn WorkingCopy>, WorkingCopyStateError>;
}

impl dyn LockedWorkingCopy {
    /// Returns reference of the implementation type.
    pub fn downcast_ref<T: LockedWorkingCopy>(&self) -> Option<&T> {
        (self as &dyn Any).downcast_ref()
    }

    /// Returns mutable reference of the implementation type.
    pub fn downcast_mut<T: LockedWorkingCopy>(&mut self) -> Option<&mut T> {
        (self as &mut dyn Any).downcast_mut()
    }
}

/// An error while snapshotting the working copy.
#[derive(Debug, Error)]
pub enum SnapshotError {
    /// A tracked path contained invalid component such as `..`.
    #[error(transparent)]
    InvalidRepoPath(#[from] InvalidRepoPathError),
    /// A symlink target in the working copy was not valid UTF-8.
    #[error("Symlink {path} target is not valid UTF-8")]
    InvalidUtf8SymlinkTarget {
        /// The path of the symlink that has a target that's not valid UTF-8.
        /// This path itself is valid UTF-8.
        path: PathBuf,
    },
    /// Reading or writing from the commit backend failed.
    #[error(transparent)]
    BackendError(#[from] BackendError),
    /// Checking path with ignore patterns failed.
    #[error(transparent)]
    GitIgnoreError(#[from] GitIgnoreError),
    /// Failed to load the working copy state.
    #[error(transparent)]
    WorkingCopyStateError(#[from] WorkingCopyStateError),
    /// Some other error happened while snapshotting the working copy.
    #[error("{message}")]
    Other {
        /// Error message.
        message: String,
        /// The underlying error.
        #[source]
        err: Box<dyn std::error::Error + Send + Sync>,
    },
}

/// Options used when snapshotting the working copy. Some of them may be ignored
/// by some `WorkingCopy` implementations.
#[derive(Clone)]
pub struct SnapshotOptions<'a> {
    /// The `.gitignore`s to use while snapshotting. The typically come from the
    /// user's configured patterns combined with per-repo patterns.
    // The base_ignores are passed in here rather than being set on the TreeState
    // because the TreeState may be long-lived if the library is used in a
    // long-lived process.
    pub base_ignores: Arc<GitIgnoreFile>,
    /// A callback for the UI to display progress.
    pub progress: Option<&'a SnapshotProgress<'a>>,
    /// For new files that are not already tracked, start tracking them if they
    /// match this.
    pub start_tracking_matcher: &'a dyn Matcher,
    /// For files that match the ignore patterns or are too large, start
    /// tracking them anyway if they match this.
    pub force_tracking_matcher: &'a dyn Matcher,
    /// The size of the largest file that should be allowed to become tracked
    /// (already tracked files are always snapshotted). If there are larger
    /// files in the working copy, then `LockedWorkingCopy::snapshot()` may
    /// (depending on implementation)
    /// return `SnapshotError::NewFileTooLarge`.
    pub max_new_file_size: u64,
}

/// A callback for getting progress updates.
pub type SnapshotProgress<'a> = dyn Fn(&RepoPath) + 'a + Sync;

/// Stats about a snapshot operation on a working copy.
#[derive(Clone, Debug, Default)]
pub struct SnapshotStats {
    /// List of new (previously untracked) files which are still untracked.
    pub untracked_paths: BTreeMap<RepoPathBuf, UntrackedReason>,
    /// Paths that were skipped because their file names aren't valid UTF-8,
    /// as (directory, file name) pairs. These paths cannot be represented as
    /// `RepoPath`s.
    pub invalid_utf8_paths: BTreeSet<(RepoPathBuf, OsString)>,
}

/// Reason why the new path isn't tracked.
#[derive(Clone, Debug)]
pub enum UntrackedReason {
    /// File was larger than the specified maximum file size.
    FileTooLarge {
        /// Actual size of the large file.
        size: u64,
        /// Maximum allowed size.
        max_size: u64,
    },
    /// File does not match the fileset specified in snapshot.auto-track.
    FileNotAutoTracked,
}

/// Stats about a checkout operation on a working copy. All "files" mentioned
/// below may also be symlinks or materialized conflicts.
#[derive(Debug, PartialEq, Eq, Clone, Default)]
pub struct CheckoutStats {
    /// The number of files that were updated in the working copy.
    /// These files existed before and after the checkout.
    pub updated_files: u32,
    /// The number of files added in the working copy.
    pub added_files: u32,
    /// The number of files removed in the working copy.
    pub removed_files: u32,
    /// The number of files that were supposed to be updated or added in the
    /// working copy but were skipped because there was an untracked (probably
    /// ignored) file in its place.
    pub skipped_files: u32,
}

/// The working-copy checkout failed.
#[derive(Debug, Error)]
pub enum CheckoutError {
    /// The current working-copy commit was deleted, maybe by an overly
    /// aggressive GC that happened while the current process was running.
    #[error("Current working-copy commit not found")]
    SourceNotFound {
        /// The underlying error.
        source: Box<dyn std::error::Error + Send + Sync>,
    },
    /// Another process checked out a commit while the current process was
    /// running (after the working copy was read by the current process).
    #[error("Concurrent checkout")]
    ConcurrentCheckout,
    /// Path in the commit contained invalid component such as `..`.
    #[error(transparent)]
    InvalidRepoPath(#[from] InvalidRepoPathError),
    /// Path contained reserved name which cannot be checked out to disk.
    #[error("Reserved path component {name} in {path}")]
    ReservedPathComponent {
        /// The file or directory path.
        path: PathBuf,
        /// The reserved path component.
        name: &'static str,
    },
    /// Reading or writing from the commit backend failed.
    #[error("Internal backend error")]
    InternalBackendError(#[from] BackendError),
    /// Failed to load the working copy state.
    #[error(transparent)]
    WorkingCopyStateError(#[from] WorkingCopyStateError),
    /// Some other error happened while checking out the working copy.
    #[error("{message}")]
    Other {
        /// Error message.
        message: String,
        /// The underlying error.
        #[source]
        err: Box<dyn std::error::Error + Send + Sync>,
    },
}

/// An error while resetting the working copy.
#[derive(Debug, Error)]
pub enum ResetError {
    /// The current working-copy commit was deleted, maybe by an overly
    /// aggressive GC that happened while the current process was running.
    #[error("Current working-copy commit not found")]
    SourceNotFound {
        /// The underlying error.
        source: Box<dyn std::error::Error + Send + Sync>,
    },
    /// Reading or writing from the commit backend failed.
    #[error("Internal error")]
    InternalBackendError(#[from] BackendError),
    /// Failed to load the working copy state.
    #[error(transparent)]
    WorkingCopyStateError(#[from] WorkingCopyStateError),
    /// Some other error happened while resetting the working copy.
    #[error("{message}")]
    Other {
        /// Error message.
        message: String,
        /// The underlying error.
        #[source]
        err: Box<dyn std::error::Error + Send + Sync>,
    },
}

/// Whether the working copy is stale or not.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum WorkingCopyFreshness {
    /// The working copy isn't stale, and no need to reload the repo.
    Fresh,
    /// The working copy was updated since we loaded the repo. The repo must be
    /// reloaded at the working copy's operation.
    Updated(Box<Operation>),
    /// The working copy is behind the latest operation.
    WorkingCopyStale,
    /// The working copy is a sibling of the latest operation.
    SiblingOperation,
}

impl WorkingCopyFreshness {
    /// Determine the freshness of the provided working copy relative to the
    /// target commit.
    #[instrument(skip_all)]
    pub async fn check_stale(
        locked_wc: &dyn LockedWorkingCopy,
        wc_commit: &Commit,
        repo: &ReadonlyRepo,
    ) -> Result<Self, OpStoreError> {
        // Check if the working copy's operation matches the repo's operation
        if locked_wc.old_operation_id() == repo.op_id() {
            // The working copy isn't stale, and no need to reload the repo.
            Ok(Self::Fresh)
        } else {
            let wc_operation = repo
                .loader()
                .load_operation(locked_wc.old_operation_id())
                .await?;
            let repo_operation = repo.operation();
            let ancestor_ops =
                op_walk::closest_common_ancestors([wc_operation.clone()], [repo_operation.clone()])
                    .await?;
            // TODO: test all operations instead of using only a single common operation
            let ancestor_op = ancestor_ops.into_iter().next().unwrap();
            if ancestor_op.id() == repo_operation.id() {
                // The working copy was updated since we loaded the repo. The repo must be
                // reloaded at the working copy's operation.
                Ok(Self::Updated(Box::new(wc_operation)))
            } else if ancestor_op.id() == wc_operation.id() {
                // The working copy was not updated when some repo operation committed,
                // meaning that it's stale compared to the repo view.
                if locked_wc.old_tree().tree_ids_and_labels()
                    == wc_commit.tree().tree_ids_and_labels()
                {
                    // The working copy doesn't require any changes
                    Ok(Self::Fresh)
                } else {
                    Ok(Self::WorkingCopyStale)
                }
            } else {
                Ok(Self::SiblingOperation)
            }
        }
    }
}

/// An error while recovering a stale working copy.
#[derive(Debug, Error)]
pub enum RecoverWorkspaceError {
    /// Backend error.
    #[error(transparent)]
    Backend(#[from] BackendError),
    /// Error during checkout.
    #[error(transparent)]
    Reset(#[from] ResetError),
    /// Checkout attempted to modify the root commit.
    #[error(transparent)]
    RewriteRootCommit(#[from] RewriteRootCommit),
    /// Error during transaction.
    #[error(transparent)]
    TransactionCommit(#[from] TransactionCommitError),
    /// Working copy commit is missing.
    #[error(r#""{}" doesn't have a working-copy commit"#, .0.as_symbol())]
    WorkspaceMissingWorkingCopy(WorkspaceNameBuf),
}

/// Recover this workspace to its last known checkout.
pub async fn create_and_check_out_recovery_commit(
    locked_wc: &mut dyn LockedWorkingCopy,
    repo: &Arc<ReadonlyRepo>,
    workspace_name: WorkspaceNameBuf,
    description: &str,
) -> Result<(Arc<ReadonlyRepo>, Commit), RecoverWorkspaceError> {
    let mut tx = repo.start_transaction();
    let repo_mut = tx.repo_mut();

    let commit_id = repo
        .view()
        .get_wc_commit_id(&workspace_name)
        .ok_or_else(|| {
            RecoverWorkspaceError::WorkspaceMissingWorkingCopy(workspace_name.clone())
        })?;
    let commit = repo.store().get_commit_async(commit_id).await?;
    let new_commit = repo_mut
        .new_commit(vec![commit_id.clone()], commit.tree())
        .set_description(description)
        .write()
        .await?;
    repo_mut.set_wc_commit(workspace_name, new_commit.id().clone())?;

    let repo = tx.commit("recovery commit").await?;
    locked_wc.recover(&new_commit).await?;

    Ok((repo, new_commit))
}

/// An error while reading the working copy state.
#[derive(Debug, Error)]
#[error("{message}")]
pub struct WorkingCopyStateError {
    /// Error message.
    pub message: String,
    /// The underlying error.
    #[source]
    pub err: Box<dyn std::error::Error + Send + Sync>,
}
