/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * This software may be used and distributed according to the terms of the
 * GNU General Public License version 2.
 */

//! Tests for the synced commits mapping.
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::str::FromStr;
use std::sync::Arc;

use anyhow::anyhow;
use anyhow::Error;
use ascii::AsciiString;
use assert_matches::assert_matches;
use blobrepo::save_bonsai_changesets;
use blobstore::Loadable;
use bonsai_hg_mapping::BonsaiHgMappingRef;
use bookmarks::BookmarkKey;
use bookmarks::BookmarkUpdateReason;
use bookmarks::BookmarksRef;
use cacheblob::InProcessLease;
use changeset_fetcher::ChangesetFetcherRef;
use context::CoreContext;
use cross_repo_sync::types::Target;
use cross_repo_sync::update_mapping_with_version;
use cross_repo_sync::validation::verify_working_copy;
use cross_repo_sync::CandidateSelectionHint;
use cross_repo_sync::CommitSyncContext;
use cross_repo_sync::CommitSyncDataProvider;
use cross_repo_sync::CommitSyncOutcome;
use cross_repo_sync::CommitSyncRepos;
use cross_repo_sync::CommitSyncer;
use cross_repo_sync::ErrorKind;
use cross_repo_sync::PluralCommitSyncOutcome;
use cross_repo_sync::PushrebaseRewriteDates;
use cross_repo_sync_test_utils::rebase_root_on_master;
use cross_repo_sync_test_utils::TestRepo;
use fbinit::FacebookInit;
use fixtures::Linear;
use fixtures::ManyFilesDirs;
use fixtures::TestRepoFixture;
use futures::FutureExt;
use futures::TryStreamExt;
use live_commit_sync_config::TestLiveCommitSyncConfig;
use live_commit_sync_config::TestLiveCommitSyncConfigSource;
use manifest::ManifestOps;
use maplit::btreemap;
use maplit::hashmap;
use mercurial_derivation::DeriveHgChangeset;
use mercurial_types::HgChangesetId;
use metaconfig_types::CommitSyncConfig;
use metaconfig_types::CommitSyncConfigVersion;
use metaconfig_types::CommonCommitSyncConfig;
use metaconfig_types::DefaultSmallToLargeCommitSyncPathAction;
use metaconfig_types::SmallRepoCommitSyncConfig;
use metaconfig_types::SmallRepoPermanentConfig;
use mononoke_types::BonsaiChangesetMut;
use mononoke_types::ChangesetId;
use mononoke_types::DateTime;
use mononoke_types::FileChange;
use mononoke_types::MPath;
use mononoke_types::RepositoryId;
use pushrebase::PushrebaseError;
use repo_blobstore::RepoBlobstoreRef;
use repo_identity::RepoIdentityRef;
use sorted_vector_map::sorted_vector_map;
use sql::rusqlite::Connection as SqliteConnection;
use sql_construct::SqlConstruct;
use synced_commit_mapping::SqlSyncedCommitMapping;
use synced_commit_mapping::SyncedCommitMapping;
use synced_commit_mapping::SyncedCommitMappingEntry;
use test_repo_factory::TestRepoFactory;
use tests_utils::bookmark;
use tests_utils::resolve_cs_id;
use tests_utils::CreateCommitContext;
use tunables::with_tunables_async;
use tunables::MononokeTunables;

fn mpath(p: &str) -> MPath {
    MPath::new(p).unwrap()
}

async fn move_bookmark(
    ctx: &CoreContext,
    repo: &TestRepo,
    bookmark_name: &str,
    cs_id: ChangesetId,
) {
    bookmark(ctx, repo, bookmark_name)
        .set_to(cs_id)
        .await
        .unwrap();
}

async fn get_bookmark(ctx: &CoreContext, repo: &TestRepo, bookmark: &str) -> ChangesetId {
    resolve_cs_id(ctx, repo, bookmark).await.unwrap()
}

async fn create_initial_commit(ctx: CoreContext, repo: &TestRepo) -> ChangesetId {
    create_initial_commit_with_contents(ctx, repo, btreemap! { "master_file" => "123" }).await
}

async fn create_initial_commit_with_contents<'a>(
    ctx: CoreContext,
    repo: &'a TestRepo,
    file_changes: BTreeMap<&'static str, impl Into<Vec<u8>>>,
) -> ChangesetId {
    let bcs_id = CreateCommitContext::new_root(&ctx, repo)
        .add_files(file_changes)
        .set_author("Test User <test@fb.com>")
        .set_author_date(DateTime::from_timestamp(1504040000, 0).unwrap())
        .set_message("Initial commit to get going")
        .commit()
        .await
        .unwrap();

    bookmark(&ctx, repo, "master").set_to(bcs_id).await.unwrap();
    bcs_id
}

async fn create_empty_commit(ctx: CoreContext, repo: &TestRepo) -> ChangesetId {
    let bookmark = BookmarkKey::new("master").unwrap();
    let p1 = repo
        .bookmarks()
        .get(ctx.clone(), &bookmark)
        .await
        .unwrap()
        .unwrap();

    let bcs = BonsaiChangesetMut {
        parents: vec![p1],
        author: "Test User <test@fb.com>".to_string(),
        author_date: DateTime::from_timestamp(1504040001, 0).unwrap(),
        committer: None,
        committer_date: None,
        message: "Change master_file".to_string(),
        hg_extra: Default::default(),
        git_extra_headers: None,
        git_tree_hash: None,
        file_changes: sorted_vector_map! {},
        is_snapshot: false,
        git_annotated_tag: None,
    }
    .freeze()
    .unwrap();

    let bcs_id = bcs.get_changeset_id();
    save_bonsai_changesets(vec![bcs], ctx.clone(), repo)
        .await
        .unwrap();

    let mut txn = repo.bookmarks().create_transaction(ctx.clone());
    txn.force_set(&bookmark, bcs_id, BookmarkUpdateReason::TestMove)
        .unwrap();
    txn.commit().await.unwrap();
    bcs_id
}

async fn sync_to_master<M>(
    ctx: CoreContext,
    config: &CommitSyncer<M, TestRepo>,
    source_bcs_id: ChangesetId,
) -> Result<Option<ChangesetId>, Error>
where
    M: SyncedCommitMapping + Clone + 'static,
{
    let bookmark_name = BookmarkKey::new("master").unwrap();
    let source_bcs = source_bcs_id
        .load(&ctx, config.get_source_repo().repo_blobstore())
        .await
        .unwrap();

    config
        .unsafe_sync_commit_pushrebase(
            &ctx,
            source_bcs,
            bookmark_name,
            CommitSyncContext::Tests,
            PushrebaseRewriteDates::No,
        )
        .await
}

async fn get_bcs_id<M>(
    ctx: &CoreContext,
    config: &CommitSyncer<M, TestRepo>,
    source_hg_cs: HgChangesetId,
) -> ChangesetId
where
    M: SyncedCommitMapping + Clone + 'static,
{
    config
        .get_source_repo()
        .bonsai_hg_mapping()
        .get_bonsai_from_hg(ctx, source_hg_cs)
        .await
        .unwrap()
        .unwrap()
}

async fn check_mapping<M>(
    ctx: CoreContext,
    config: &CommitSyncer<M, TestRepo>,
    source_bcs_id: ChangesetId,
    expected_bcs_id: Option<ChangesetId>,
) where
    M: SyncedCommitMapping + Clone + 'static,
{
    let source_repoid = config.get_source_repo().repo_identity().id();
    let destination_repoid = config.get_target_repo().repo_identity().id();
    let mapping = config.get_mapping();
    assert_eq!(
        mapping
            .get(&ctx, source_repoid, source_bcs_id, destination_repoid,)
            .await
            .unwrap()
            .into_iter()
            .next()
            .map(|(cs, _maybe_version, _maybe_source_repo)| cs),
        expected_bcs_id
    );

    if let Some(expected_bcs_id) = expected_bcs_id {
        assert_eq!(
            mapping
                .get(&ctx, destination_repoid, expected_bcs_id, source_repoid)
                .await
                .unwrap()
                .into_iter()
                .next()
                .map(|(cs, _maybe_version, _maybe_source_repo)| cs),
            Some(source_bcs_id)
        );
    }
}

pub fn version_name_with_small_repo() -> CommitSyncConfigVersion {
    CommitSyncConfigVersion("TEST_VERSION_NAME".to_string())
}

fn create_commit_sync_config(
    small_repo_id: RepositoryId,
    large_repo_id: RepositoryId,
    prefix: &str,
) -> Result<CommitSyncConfig, Error> {
    let small_repo_config = SmallRepoCommitSyncConfig {
        default_action: DefaultSmallToLargeCommitSyncPathAction::PrependPrefix(MPath::new(prefix)?),
        map: hashmap! {},
    };

    Ok(CommitSyncConfig {
        large_repo_id,
        common_pushrebase_bookmarks: vec![],
        small_repos: hashmap! {
            small_repo_id => small_repo_config,
        },
        version_name: version_name_with_small_repo(),
    })
}

fn create_small_to_large_commit_syncer(
    ctx: &CoreContext,
    small_repo: TestRepo,
    large_repo: TestRepo,
    prefix: &str,
    mapping: SqlSyncedCommitMapping,
) -> Result<CommitSyncer<SqlSyncedCommitMapping, TestRepo>, Error> {
    let small_repo_id = small_repo.repo_identity().id();
    let large_repo_id = large_repo.repo_identity().id();

    let common_config = CommonCommitSyncConfig {
        common_pushrebase_bookmarks: vec![],
        small_repos: hashmap! {
            small_repo.repo_identity().id() => SmallRepoPermanentConfig {
                bookmark_prefix: AsciiString::new(),
            }
        },
        large_repo_id: large_repo.repo_identity().id(),
    };
    let repos = CommitSyncRepos::new(small_repo, large_repo, &common_config)?;
    let commit_sync_config = create_commit_sync_config(small_repo_id, large_repo_id, prefix)?;

    let (sync_config, source) = TestLiveCommitSyncConfig::new_with_source();
    source.add_config(commit_sync_config);
    source.add_common_config(common_config);

    let live_commit_sync_config = Arc::new(sync_config);
    let lease = Arc::new(InProcessLease::new());
    Ok(CommitSyncer::new(
        ctx,
        mapping,
        repos,
        live_commit_sync_config,
        lease,
    ))
}

fn create_large_to_small_commit_syncer_and_config_source(
    ctx: &CoreContext,
    small_repo: TestRepo,
    large_repo: TestRepo,
    prefix: &str,
    mapping: SqlSyncedCommitMapping,
) -> Result<
    (
        CommitSyncer<SqlSyncedCommitMapping, TestRepo>,
        TestLiveCommitSyncConfigSource,
    ),
    Error,
> {
    let small_repo_id = small_repo.repo_identity().id();
    let large_repo_id = large_repo.repo_identity().id();

    let commit_sync_config = create_commit_sync_config(small_repo_id, large_repo_id, prefix)?;
    let common_config = CommonCommitSyncConfig {
        common_pushrebase_bookmarks: vec![],
        small_repos: hashmap! {
            small_repo.repo_identity().id() => SmallRepoPermanentConfig {
                bookmark_prefix: AsciiString::new(),
            }
        },
        large_repo_id: large_repo.repo_identity().id(),
    };
    let repos = CommitSyncRepos::new(large_repo, small_repo, &common_config)?;

    let (sync_config, source) = TestLiveCommitSyncConfig::new_with_source();
    source.add_config(commit_sync_config);
    source.add_common_config(common_config);

    let live_commit_sync_config = Arc::new(sync_config);
    let lease = Arc::new(InProcessLease::new());
    Ok((
        CommitSyncer::new(ctx, mapping, repos, live_commit_sync_config, lease),
        source,
    ))
}

fn create_large_to_small_commit_syncer(
    ctx: &CoreContext,
    small_repo: TestRepo,
    large_repo: TestRepo,
    prefix: &str,
    mapping: SqlSyncedCommitMapping,
) -> Result<CommitSyncer<SqlSyncedCommitMapping, TestRepo>, Error> {
    let (syncer, _) = create_large_to_small_commit_syncer_and_config_source(
        ctx, small_repo, large_repo, prefix, mapping,
    )?;
    Ok(syncer)
}

#[fbinit::test]
async fn test_sync_parentage(fb: FacebookInit) -> Result<(), Error> {
    let ctx = CoreContext::test_mock(fb);
    let (small_repo, megarepo, mapping) = prepare_repos_and_mapping(fb).await?;
    Linear::initrepo(fb, &small_repo).await;
    let config =
        create_small_to_large_commit_syncer(&ctx, small_repo, megarepo.clone(), "linear", mapping)?;
    create_initial_commit(ctx.clone(), &megarepo).await;

    // Take 2d7d4ba9ce0a6ffd222de7785b249ead9c51c536 from linear, and rewrite it as a child of master
    // As this is the first commit from linear, it'll rewrite cleanly
    let linear_base_bcs_id = get_bcs_id(
        &ctx,
        &config,
        HgChangesetId::from_str("2d7d4ba9ce0a6ffd222de7785b249ead9c51c536")?,
    )
    .await;
    let expected_bcs_id =
        ChangesetId::from_str("8966842d2031e69108028d6f0ce5812bca28cae53679d066368a8c1472a5bb9a")
            .ok();

    let megarepo_base_bcs_id =
        rebase_root_on_master(ctx.clone(), &config, linear_base_bcs_id).await?;
    // Confirm that we got the expected conversion
    assert_eq!(Some(megarepo_base_bcs_id), expected_bcs_id);
    check_mapping(ctx.clone(), &config, linear_base_bcs_id, expected_bcs_id).await;

    // Finally, sync another commit
    let linear_second_bcs_id = get_bcs_id(
        &ctx,
        &config,
        HgChangesetId::from_str("3e0e761030db6e479a7fb58b12881883f9f8c63f").unwrap(),
    )
    .await;
    let expected_bcs_id =
        ChangesetId::from_str("95c03dcd3324e172275ce22a5628d7a501aecb51d9a198b33284887769537acf")?;
    let megarepo_second_bcs_id = sync_to_master(ctx.clone(), &config, linear_second_bcs_id).await?;
    // Confirm that we got the expected conversion
    assert_eq!(megarepo_second_bcs_id, Some(expected_bcs_id));
    // And check that the synced commit has correct parentage
    assert_eq!(
        megarepo
            .changeset_fetcher()
            .get_parents(&ctx, megarepo_second_bcs_id.unwrap())
            .await?,
        vec![megarepo_base_bcs_id]
    );

    Ok(())
}

async fn create_commit_from_parent_and_changes<'a>(
    ctx: &'a CoreContext,
    repo: &'a TestRepo,
    p1: ChangesetId,
    changes: BTreeMap<&'static str, &'static str>,
) -> ChangesetId {
    CreateCommitContext::new(ctx, repo, vec![p1])
        .set_author("Test User <test@fb.com>")
        .set_author_date(DateTime::from_timestamp(1504040001, 0).unwrap())
        .set_message("ababagalamaga")
        .add_files(changes)
        .commit()
        .await
        .unwrap()
}

async fn update_master_file(ctx: CoreContext, repo: &TestRepo) -> ChangesetId {
    let bcs_id = CreateCommitContext::new(&ctx, repo, vec!["master"])
        .set_author("Test User <test@fb.com>")
        .set_author_date(DateTime::from_timestamp(1504040001, 0).unwrap())
        .add_file("master_file", "456")
        .set_message("Change master_file")
        .commit()
        .await
        .unwrap();

    bookmark(&ctx, repo, "master").set_to(bcs_id).await.unwrap();
    bcs_id
}

#[fbinit::test]
async fn test_sync_causes_conflict(fb: FacebookInit) -> Result<(), Error> {
    let ctx = CoreContext::test_mock(fb);
    let megarepo: TestRepo = TestRepoFactory::new(fb)?
        .with_id(RepositoryId::new(1))
        .build()
        .await?;

    let mapping = SqlSyncedCommitMapping::with_sqlite_in_memory()?;
    let linear: TestRepo = Linear::get_custom_test_repo(fb).await;
    let linear_config = create_small_to_large_commit_syncer(
        &ctx,
        linear.clone(),
        megarepo.clone(),
        "linear",
        mapping.clone(),
    )?;

    let master_file_config = create_small_to_large_commit_syncer(
        &ctx,
        linear,
        megarepo.clone(),
        "master_file",
        mapping,
    )?;

    create_initial_commit(ctx.clone(), &megarepo).await;

    // Take 2d7d4ba9ce0a6ffd222de7785b249ead9c51c536 from linear, and rewrite it as a child of master
    let linear_base_bcs_id = get_bcs_id(
        &ctx,
        &linear_config,
        HgChangesetId::from_str("2d7d4ba9ce0a6ffd222de7785b249ead9c51c536")?,
    )
    .await;
    rebase_root_on_master(ctx.clone(), &linear_config, linear_base_bcs_id).await?;

    // Change master_file
    update_master_file(ctx.clone(), &megarepo).await;

    // Finally, sync another commit over master_file - this should fail
    let linear_second_bcs_id = get_bcs_id(
        &ctx,
        &master_file_config,
        HgChangesetId::from_str("3e0e761030db6e479a7fb58b12881883f9f8c63f")?,
    )
    .await;
    let megarepo_fail_bcs_id =
        sync_to_master(ctx.clone(), &master_file_config, linear_second_bcs_id).await;
    // Confirm the syncing failed
    assert!(megarepo_fail_bcs_id.is_err(), "{:?}", megarepo_fail_bcs_id);

    check_mapping(ctx.clone(), &master_file_config, linear_second_bcs_id, None).await;
    Ok(())
}

async fn prepare_repos_and_mapping(
    fb: FacebookInit,
) -> Result<(TestRepo, TestRepo, SqlSyncedCommitMapping), Error> {
    let metadata_con = SqliteConnection::open_in_memory()?;
    metadata_con.execute_batch(SqlSyncedCommitMapping::CREATION_QUERY)?;
    let hg_mutation_con = SqliteConnection::open_in_memory()?;
    let mut factory = TestRepoFactory::with_sqlite_connection(fb, metadata_con, hg_mutation_con)?;
    let megarepo = factory.with_id(RepositoryId::new(1)).build().await?;
    let small_repo = factory.with_id(RepositoryId::new(0)).build().await?;
    let mapping = SqlSyncedCommitMapping::from_sql_connections(factory.metadata_db().clone());
    Ok((small_repo, megarepo, mapping))
}

#[fbinit::test]
async fn test_sync_empty_commit(fb: FacebookInit) -> Result<(), Error> {
    let ctx = CoreContext::test_mock(fb);
    let (small_repo, megarepo, mapping) = prepare_repos_and_mapping(fb).await?;
    Linear::initrepo(fb, &small_repo).await;
    let linear = small_repo;

    let stl_config = create_small_to_large_commit_syncer(
        &ctx,
        linear.clone(),
        megarepo.clone(),
        "linear",
        mapping.clone(),
    )?;
    let lts_config = create_large_to_small_commit_syncer(
        &ctx,
        linear.clone(),
        megarepo.clone(),
        "linear",
        mapping.clone(),
    )?;

    create_initial_commit(ctx.clone(), &megarepo).await;

    // Take 2d7d4ba9ce0a6ffd222de7785b249ead9c51c536 from linear, and rewrite it as a child of master
    // As this is the first commit from linear, it'll rewrite cleanly
    let linear_base_bcs_id = get_bcs_id(
        &ctx,
        &stl_config,
        HgChangesetId::from_str("2d7d4ba9ce0a6ffd222de7785b249ead9c51c536")?,
    )
    .await;
    rebase_root_on_master(ctx.clone(), &stl_config, linear_base_bcs_id).await?;

    // Sync an empty commit back to linear
    let megarepo_empty_bcs_id = create_empty_commit(ctx.clone(), &megarepo).await;
    let linear_empty_bcs_id =
        sync_to_master(ctx.clone(), &lts_config, megarepo_empty_bcs_id).await?;

    let expected_bcs_id =
        ChangesetId::from_str("dad900d07c885c21d4361a11590c220cc65c287d52fe1e0f4df61242c7c03f07")
            .ok();
    assert_eq!(linear_empty_bcs_id, expected_bcs_id);
    check_mapping(
        ctx.clone(),
        &lts_config,
        megarepo_empty_bcs_id,
        linear_empty_bcs_id,
    )
    .await;

    Ok(())
}

async fn megarepo_copy_file(ctx: CoreContext, repo: &TestRepo) -> ChangesetId {
    let bcs_id = CreateCommitContext::new(&ctx, repo, vec!["master"])
        .set_author("Test User <test@fb.com>")
        .set_author_date(DateTime::from_timestamp(1504040055, 0).unwrap())
        .add_file_with_copy_info("linear/new_file", "99\n", ("master", "linear/1"))
        .set_message("Change 1")
        .commit()
        .await
        .unwrap();

    bookmark(&ctx, repo, "master").set_to(bcs_id).await.unwrap();

    bcs_id
}

#[fbinit::test]
async fn test_sync_copyinfo(fb: FacebookInit) -> Result<(), Error> {
    let ctx = CoreContext::test_mock(fb);
    let (small_repo, megarepo, mapping) = prepare_repos_and_mapping(fb).await.unwrap();
    Linear::initrepo(fb, &small_repo).await;
    let linear = small_repo;

    let stl_config = create_small_to_large_commit_syncer(
        &ctx,
        linear.clone(),
        megarepo.clone(),
        "linear",
        mapping.clone(),
    )?;
    let lts_config = create_large_to_small_commit_syncer(
        &ctx,
        linear.clone(),
        megarepo.clone(),
        "linear",
        mapping,
    )?;

    create_initial_commit(ctx.clone(), &megarepo).await;

    // Take 2d7d4ba9ce0a6ffd222de7785b249ead9c51c536 from linear, and rewrite it as a child of master
    // As this is the first commit from linear, it'll rewrite cleanly
    let linear_base_bcs_id = get_bcs_id(
        &ctx,
        &stl_config,
        HgChangesetId::from_str("2d7d4ba9ce0a6ffd222de7785b249ead9c51c536")?,
    )
    .await;
    rebase_root_on_master(ctx.clone(), &stl_config, linear_base_bcs_id).await?;

    // Fetch master from linear - the pushrebase in a remap will change copyinfo
    let linear_master_bcs_id = {
        let bookmark = BookmarkKey::new("master").unwrap();
        linear
            .bookmarks()
            .get(ctx.clone(), &bookmark)
            .await?
            .unwrap()
    };

    let megarepo_copyinfo_commit = megarepo_copy_file(ctx.clone(), &megarepo).await;
    let linear_copyinfo_bcs_id =
        sync_to_master(ctx.clone(), &lts_config, megarepo_copyinfo_commit).await?;

    let expected_bcs_id =
        ChangesetId::from_str("68e495f850e16cd4a6b372d27f18f59931139242b5097c137afa1d738769cc60")
            .ok();
    assert_eq!(linear_copyinfo_bcs_id, expected_bcs_id);
    check_mapping(
        ctx.clone(),
        &lts_config,
        megarepo_copyinfo_commit,
        linear_copyinfo_bcs_id,
    )
    .await;

    // Fetch commit from linear by its new ID, and confirm that it has the correct copyinfo
    let linear_bcs = linear_copyinfo_bcs_id
        .unwrap()
        .load(&ctx, linear.repo_blobstore())
        .await?;

    let file_changes: Vec<_> = linear_bcs.file_changes().collect();
    assert!(file_changes.len() == 1, "Wrong file change count");
    let (path, copy_info) = file_changes.first().unwrap();
    assert_eq!(**path, mpath("new_file"));
    let (copy_source, copy_bcs) = match copy_info {
        FileChange::Change(tc) => tc.copy_from().unwrap(),
        _ => panic!(),
    };
    assert_eq!(*copy_source, mpath("1"));
    assert_eq!(*copy_bcs, linear_master_bcs_id);

    Ok(())
}

#[fbinit::test]
async fn test_sync_implicit_deletes(fb: FacebookInit) -> Result<(), Error> {
    let ctx = CoreContext::test_mock(fb);
    let (small_repo, megarepo, mapping) = prepare_repos_and_mapping(fb).await.unwrap();
    ManyFilesDirs::initrepo(fb, &small_repo).await;
    let repo = small_repo.clone();

    let mut commit_syncer = create_small_to_large_commit_syncer(
        &ctx,
        repo.clone(),
        megarepo.clone(),
        "linear",
        mapping.clone(),
    )?;

    let small_repo_config = SmallRepoCommitSyncConfig {
        default_action: DefaultSmallToLargeCommitSyncPathAction::Preserve,
        map: hashmap! {
            MPath::new("dir1/subdir1/subsubdir1")? => MPath::new("prefix1")?,
            MPath::new("dir1")? => MPath::new("prefix2")?,
        },
    };

    let commit_sync_config = CommitSyncConfig {
        large_repo_id: megarepo.repo_identity().id(),
        common_pushrebase_bookmarks: vec![],
        small_repos: hashmap! {
            small_repo.repo_identity().id() => small_repo_config,
        },
        version_name: version_name_with_small_repo(),
    };

    let common_config = CommonCommitSyncConfig {
        common_pushrebase_bookmarks: vec![],
        small_repos: hashmap! {
            small_repo.repo_identity().id() => SmallRepoPermanentConfig {
                bookmark_prefix: AsciiString::new(),
            }
        },
        large_repo_id: megarepo.repo_identity().id(),
    };
    let (sync_config, source) = TestLiveCommitSyncConfig::new_with_source();

    source.add_config(commit_sync_config.clone());
    source.add_common_config(common_config);

    let live_commit_sync_config = Arc::new(sync_config);

    let commit_sync_repos = CommitSyncRepos::SmallToLarge {
        small_repo: repo.clone(),
        large_repo: megarepo.clone(),
    };
    let version = version_name_with_small_repo();
    let commit_sync_data_provider = CommitSyncDataProvider::Live(live_commit_sync_config);
    commit_syncer.commit_sync_data_provider = commit_sync_data_provider;
    commit_syncer.repos = commit_sync_repos;

    let megarepo_initial_bcs_id = create_initial_commit(ctx.clone(), &megarepo).await;

    // Insert a fake mapping entry, so that syncs succeed
    let repo_initial_bcs_id = get_bcs_id(
        &ctx,
        &commit_syncer,
        HgChangesetId::from_str("2f866e7e549760934e31bf0420a873f65100ad63").unwrap(),
    )
    .await;
    let entry = SyncedCommitMappingEntry::new(
        megarepo.repo_identity().id(),
        megarepo_initial_bcs_id,
        repo.repo_identity().id(),
        repo_initial_bcs_id,
        version,
        commit_syncer.get_source_repo_type(),
    );
    mapping.add(&ctx, entry).await?;

    // d261bc7900818dea7c86935b3fb17a33b2e3a6b4 from "ManyFilesDirs" should sync cleanly
    // on top of master. Among others, it introduces the following files:
    // - "dir1/subdir1/subsubdir1/file_1"
    // - "dir1/subdir1/subsubdir2/file_1"
    // - "dir1/subdir1/subsubdir2/file_2"
    let repo_base_bcs_id = get_bcs_id(
        &ctx,
        &commit_syncer,
        HgChangesetId::from_str("d261bc7900818dea7c86935b3fb17a33b2e3a6b4").unwrap(),
    )
    .await;

    sync_to_master(ctx.clone(), &commit_syncer, repo_base_bcs_id)
        .await?
        .expect("Unexpectedly rewritten into nothingness");

    // 051946ed218061e925fb120dac02634f9ad40ae2 from "ManyFilesDirs" replaces the
    // entire "dir1" directory with a file, which implicitly deletes
    // "dir1/subdir1/subsubdir1" and "dir1/subdir1/subsubdir2".
    let repo_implicit_delete_bcs_id = get_bcs_id(
        &ctx,
        &commit_syncer,
        HgChangesetId::from_str("051946ed218061e925fb120dac02634f9ad40ae2").unwrap(),
    )
    .await;
    let megarepo_implicit_delete_bcs_id =
        sync_to_master(ctx.clone(), &commit_syncer, repo_implicit_delete_bcs_id)
            .await?
            .expect("Unexpectedly rewritten into nothingness");

    let megarepo_implicit_delete_bcs = megarepo_implicit_delete_bcs_id
        .load(&ctx, megarepo.repo_blobstore())
        .await?;
    let file_changes: BTreeMap<MPath, _> = megarepo_implicit_delete_bcs
        .file_changes()
        .map(|(a, b)| (a.clone(), b.clone()))
        .collect();

    // "dir1" was rewrtitten as "prefix2" and explicitly replaced with file, so the file
    // change should be `Some`
    assert!(file_changes[&mpath("prefix2")].is_changed());
    // "dir1/subdir1/subsubdir1/file_1" was rewritten as "prefix1/file_1", and became
    // an implicit delete
    assert!(file_changes[&mpath("prefix1/file_1")].is_removed());
    // there are no other entries in `file_changes` as other implicit deletes where
    // removed by the minimization process
    assert_eq!(file_changes.len(), 2);

    Ok(())
}

async fn update_linear_1_file(ctx: CoreContext, repo: &TestRepo) -> ChangesetId {
    let bcs_id = CreateCommitContext::new(&ctx, repo, vec!["master"])
        .set_author("Test User <test@fb.com>")
        .set_author_date(DateTime::from_timestamp(1504040002, 0).unwrap())
        .set_message("Change linear/1")
        .add_files(btreemap! {"linear/1" => "999"})
        .commit()
        .await
        .unwrap();

    bookmark(&ctx, repo, "master").set_to(bcs_id).await.unwrap();

    bcs_id
}

#[fbinit::test]
async fn test_sync_parent_search(fb: FacebookInit) -> Result<(), Error> {
    let ctx = CoreContext::test_mock(fb);
    let (small_repo, megarepo, mapping) = prepare_repos_and_mapping(fb).await?;
    Linear::initrepo(fb, &small_repo).await;
    let linear = small_repo;

    let config = create_small_to_large_commit_syncer(
        &ctx,
        linear.clone(),
        megarepo.clone(),
        "linear",
        mapping.clone(),
    )?;
    let reverse_config = create_large_to_small_commit_syncer(
        &ctx,
        linear.clone(),
        megarepo.clone(),
        "linear",
        mapping,
    )?;

    create_initial_commit(ctx.clone(), &megarepo).await;

    // Take 2d7d4ba9ce0a6ffd222de7785b249ead9c51c536 from linear, and rewrite it as a child of master
    let linear_base_bcs_id = get_bcs_id(
        &ctx,
        &config,
        HgChangesetId::from_str("2d7d4ba9ce0a6ffd222de7785b249ead9c51c536").unwrap(),
    )
    .await;
    rebase_root_on_master(ctx.clone(), &config, linear_base_bcs_id).await?;

    // Change master_file
    let master_file_cs_id = update_master_file(ctx.clone(), &megarepo).await;
    sync_to_master(ctx.clone(), &reverse_config, master_file_cs_id).await?;
    // And change a file in linear
    let new_commit = update_linear_1_file(ctx.clone(), &megarepo).await;

    // Now sync it back to linear
    let sync_success_bcs_id = sync_to_master(ctx.clone(), &reverse_config, new_commit).await?;

    // Confirm the syncing succeeded
    let expected_bcs_id =
        ChangesetId::from_str("9cd24566d5a4b7e7b4cc3a62b412d58ea60804434255863d3bfac1282a2d44fa")
            .ok();
    assert_eq!(sync_success_bcs_id, expected_bcs_id);

    check_mapping(
        ctx.clone(),
        &reverse_config,
        new_commit,
        sync_success_bcs_id,
    )
    .await;
    // And validate that the mapping is correct when looked at the other way round
    check_mapping(
        ctx.clone(),
        &config,
        sync_success_bcs_id.unwrap(),
        Some(new_commit),
    )
    .await;

    Ok(())
}

async fn check_rewritten_multiple<M>(
    ctx: &CoreContext,
    syncer: &CommitSyncer<M, TestRepo>,
    cs_id: ChangesetId,
    expected_rewrite_count: usize,
) -> Result<(), Error>
where
    M: SyncedCommitMapping + Clone + 'static,
{
    let plural_commit_sync_outcome = syncer
        .get_plural_commit_sync_outcome(ctx, cs_id)
        .await?
        .expect("should've been remapped");

    if let PluralCommitSyncOutcome::RewrittenAs(v) = plural_commit_sync_outcome {
        assert_eq!(v.len(), expected_rewrite_count);
    } else {
        panic!(
            "incorrect remapping of {}: {:?}",
            cs_id, plural_commit_sync_outcome
        );
    }

    Ok(())
}

/// Prepare two repos with small repo master remapping to
/// two commits in the large repo:
/// ```text
/// master     master
///   | other   |
///   | branch  |
///   |  |      |
///   D'.D''....D
///   |  |      |
///   B  C      |
///   | /       |
///   A'........A
///   |         |
/// LARGE      SMALL
/// ```
/// (horizontal dots represent `RewrittenAs` relationship)
async fn get_multiple_master_mapping_setup(
    fb: FacebookInit,
) -> Result<
    (
        CoreContext,
        TestRepo,
        TestRepo,
        ChangesetId,
        ChangesetId,
        CommitSyncer<SqlSyncedCommitMapping, TestRepo>,
    ),
    Error,
> {
    let ctx = CoreContext::test_mock(fb);
    let (small_repo, megarepo, mapping) = prepare_repos_and_mapping(fb).await.unwrap();
    Linear::initrepo(fb, &small_repo).await;
    let small_to_large_syncer = create_small_to_large_commit_syncer(
        &ctx,
        small_repo.clone(),
        megarepo.clone(),
        "prefix",
        mapping.clone(),
    )?;

    create_initial_commit(ctx.clone(), &megarepo).await;

    let megarepo_master_cs_id = get_bookmark(&ctx, &megarepo, "master").await;
    let small_repo_master_cs_id = get_bookmark(&ctx, &small_repo, "master").await;
    // Masters map to each other before we even do any syncs
    let version = version_name_with_small_repo();
    mapping
        .add(
            &ctx,
            SyncedCommitMappingEntry::new(
                megarepo.repo_identity().id(),
                megarepo_master_cs_id,
                small_repo.repo_identity().id(),
                small_repo_master_cs_id,
                version.clone(),
                small_to_large_syncer.get_source_repo_type(),
            ),
        )
        .await?;

    // 1. Create two commits in megarepo, on separate branches,
    // neither touching small repo files.
    let b1 = create_commit_from_parent_and_changes(
        &ctx,
        &megarepo,
        megarepo_master_cs_id,
        btreemap! {"unrelated_1" => "unrelated"},
    )
    .await;
    let b2 = create_commit_from_parent_and_changes(
        &ctx,
        &megarepo,
        megarepo_master_cs_id,
        btreemap! {"unrelated_2" => "unrelated"},
    )
    .await;

    move_bookmark(&ctx, &megarepo, "other_branch", b2).await;
    move_bookmark(&ctx, &megarepo, "master", b1).await;

    // 2. Create a small repo commit and sync it onto both branches
    let small_repo_master_cs_id = create_commit_from_parent_and_changes(
        &ctx,
        &small_repo,
        small_repo_master_cs_id,
        btreemap! {"small_repo_file" => "content"},
    )
    .await;
    move_bookmark(&ctx, &small_repo, "master", small_repo_master_cs_id).await;

    let small_cs = small_repo_master_cs_id
        .load(&ctx, small_repo.repo_blobstore())
        .await?;
    small_to_large_syncer
        .unsafe_sync_commit_pushrebase(
            &ctx,
            small_cs.clone(),
            BookmarkKey::new("master").unwrap(),
            CommitSyncContext::Tests,
            PushrebaseRewriteDates::No,
        )
        .await
        .expect("sync should have succeeded");

    small_to_large_syncer
        .unsafe_sync_commit_pushrebase(
            &ctx,
            small_cs.clone(),
            BookmarkKey::new("other_branch").unwrap(),
            CommitSyncContext::Tests,
            PushrebaseRewriteDates::No,
        )
        .await
        .expect("sync should have succeeded");

    // 3. Sanity-check that the small repo master is indeed rewritten
    // into two different commits in the large repo
    check_rewritten_multiple(&ctx, &small_to_large_syncer, small_repo_master_cs_id, 2).await?;

    // Re-query megarepo master bookmark, as its localtion has changed due
    // to a cross-repo sync
    let megarepo_master_cs_id = get_bookmark(&ctx, &megarepo, "master").await;
    Ok((
        ctx,
        small_repo,
        megarepo,
        megarepo_master_cs_id,
        small_repo_master_cs_id,
        small_to_large_syncer,
    ))
}

#[fbinit::test]
async fn test_sync_parent_has_multiple_mappings(fb: FacebookInit) -> Result<(), Error> {
    let (
        ctx,
        small_repo,
        megarepo,
        _megarepo_master_cs_id,
        small_repo_master_cs_id,
        small_to_large_syncer,
    ) = get_multiple_master_mapping_setup(fb).await?;

    // Create a small repo commit on top of master
    let to_sync = create_commit_from_parent_and_changes(
        &ctx,
        &small_repo,
        small_repo_master_cs_id,
        btreemap! {"foo" => "bar"},
    )
    .await;

    // Cannot sync without a hint
    let e = small_to_large_syncer
        .unsafe_sync_commit(
            &ctx,
            to_sync,
            CandidateSelectionHint::Only,
            CommitSyncContext::Tests,
        )
        .await
        .expect_err("sync should have failed");
    assert!(format!("{:?}", e).contains("Too many rewritten candidates for"));

    // Can sync with a bookmark-based hint
    let book = Target(BookmarkKey::new("master").unwrap());
    small_to_large_syncer
        .unsafe_sync_commit(
            &ctx,
            to_sync,
            CandidateSelectionHint::OnlyOrAncestorOfBookmark(book, Target(megarepo.clone())),
            CommitSyncContext::Tests,
        )
        .await
        .expect("sync should have succeeded");

    Ok(())
}

#[fbinit::test]
async fn test_sync_no_op_pushrebase_has_multiple_mappings(fb: FacebookInit) -> Result<(), Error> {
    let (
        ctx,
        small_repo,
        _megarepo,
        _megarepo_master_cs_id,
        small_repo_master_cs_id,
        small_to_large_syncer,
    ) = get_multiple_master_mapping_setup(fb).await?;

    // Create a small repo commit on top of master
    let to_sync_id = create_commit_from_parent_and_changes(
        &ctx,
        &small_repo,
        small_repo_master_cs_id,
        btreemap! {"foo" => "bar"},
    )
    .await;
    let to_sync = to_sync_id.load(&ctx, small_repo.repo_blobstore()).await?;

    small_to_large_syncer
        .unsafe_sync_commit_pushrebase(
            &ctx,
            to_sync,
            BookmarkKey::new("master").unwrap(),
            CommitSyncContext::Tests,
            PushrebaseRewriteDates::No,
        )
        .await
        .expect("sync should have succeeded");

    Ok(())
}

#[fbinit::test]
async fn test_sync_real_pushrebase_has_multiple_mappings(fb: FacebookInit) -> Result<(), Error> {
    let (
        ctx,
        small_repo,
        megarepo,
        megarepo_master_cs_id,
        small_repo_master_cs_id,
        small_to_large_syncer,
    ) = get_multiple_master_mapping_setup(fb).await?;

    // Advance megarepo master
    let cs_id = create_commit_from_parent_and_changes(
        &ctx,
        &megarepo,
        megarepo_master_cs_id,
        btreemap! {"unrelated_3" => "unrelated"},
    )
    .await;
    move_bookmark(&ctx, &megarepo, "master", cs_id).await;

    // Create a small repo commit on top of master
    let to_sync_id = create_commit_from_parent_and_changes(
        &ctx,
        &small_repo,
        small_repo_master_cs_id,
        btreemap! {"foo" => "bar"},
    )
    .await;
    let to_sync = to_sync_id.load(&ctx, small_repo.repo_blobstore()).await?;

    small_to_large_syncer
        .unsafe_sync_commit_pushrebase(
            &ctx,
            to_sync,
            BookmarkKey::new("master").unwrap(),
            CommitSyncContext::Tests,
            PushrebaseRewriteDates::No,
        )
        .await
        .expect("sync should have succeeded");

    Ok(())
}

#[fbinit::test]
async fn test_sync_with_mapping_change(fb: FacebookInit) -> Result<(), Error> {
    let ctx = CoreContext::test_mock(fb);
    let (old_version, new_version, large_to_small_syncer) =
        prepare_commit_syncer_with_mapping_change(fb).await?;
    let megarepo = large_to_small_syncer.get_source_repo();
    let small_repo = large_to_small_syncer.get_target_repo();

    let new_mapping_large_cs_id = resolve_cs_id(&ctx, &megarepo, "new_mapping").await?;
    // Create a new commit on top of commit with new mapping.
    let new_mapping_cs_id =
        CreateCommitContext::new(&ctx, &megarepo, vec![new_mapping_large_cs_id])
            .add_file("tools/newtool", "1")
            .delete_file("tools/1.txt")
            .add_file("tools/somefile", "somefile1")
            .add_file("prefix/dir/file", "3")
            .add_file("prefix/dir/newfile", "3")
            .commit()
            .await?;

    let synced = large_to_small_syncer
        .sync_commit(
            &ctx,
            new_mapping_cs_id,
            CandidateSelectionHint::Only,
            CommitSyncContext::Tests,
            false,
        )
        .await?;
    assert!(synced.is_some());
    let new_mapping_small_cs_id = synced.unwrap();

    verify_working_copy(
        ctx.clone(),
        large_to_small_syncer.clone(),
        new_mapping_cs_id,
    )
    .await?;
    assert_working_copy(
        &ctx,
        small_repo,
        new_mapping_small_cs_id,
        vec!["tools/somefile", "tools/newtool", "dir/file", "dir/newfile"],
    )
    .await?;

    let outcome = large_to_small_syncer
        .get_commit_sync_outcome(&ctx, new_mapping_cs_id)
        .await?;

    match outcome {
        Some(CommitSyncOutcome::RewrittenAs(_, version)) => {
            assert_eq!(version, new_version);
        }
        _ => {
            return Err(anyhow!("unexpected outcome: {:?}", outcome));
        }
    }

    // Create a new commit on top of commit with old mapping.

    let old_mapping_large_cs_id = resolve_cs_id(&ctx, &megarepo, "old_mapping").await?;
    let old_mapping_cs_id =
        CreateCommitContext::new(&ctx, &megarepo, vec![old_mapping_large_cs_id])
            .add_file("tools/3.txt", "2")
            .add_file("prefix/file", "2")
            .commit()
            .await?;
    let synced = large_to_small_syncer
        .sync_commit(
            &ctx,
            old_mapping_cs_id,
            CandidateSelectionHint::Only,
            CommitSyncContext::Tests,
            false,
        )
        .await?;
    assert!(synced.is_some());
    let old_mapping_small_cs_id = synced.unwrap();

    verify_working_copy(
        ctx.clone(),
        large_to_small_syncer.clone(),
        old_mapping_cs_id,
    )
    .await?;
    assert_working_copy(
        &ctx,
        small_repo,
        old_mapping_small_cs_id,
        vec!["dir/file", "file", "tools/1.txt"],
    )
    .await?;

    let outcome = large_to_small_syncer
        .get_commit_sync_outcome(&ctx, old_mapping_cs_id)
        .await?;

    match outcome {
        Some(CommitSyncOutcome::RewrittenAs(_, version)) => {
            assert_eq!(version, old_version);
        }
        _ => {
            return Err(anyhow!("unexpected outcome: {:?}", outcome));
        }
    }
    Ok(())
}

#[fbinit::test]
async fn test_sync_equivalent_wc_with_mapping_change(fb: FacebookInit) -> Result<(), Error> {
    let ctx = CoreContext::test_mock(fb);
    let (old_version, new_version, large_to_small_syncer) =
        prepare_commit_syncer_with_mapping_change(fb).await?;
    let megarepo = large_to_small_syncer.get_source_repo();
    let small_repo = large_to_small_syncer.get_target_repo();

    println!("create commits with new mapping");
    let new_mapping_large_cs_id = resolve_cs_id(&ctx, &megarepo, "new_mapping").await?;
    // Create a stack of commits on top of commit with new mapping.
    // First commit should not rewrite into a small repo, but second should

    let does_not_rewrite_large_cs_id =
        CreateCommitContext::new(&ctx, &megarepo, vec![new_mapping_large_cs_id])
            .add_file("somerandomfile", "1")
            .commit()
            .await?;

    let synced = large_to_small_syncer
        .sync_commit(
            &ctx,
            does_not_rewrite_large_cs_id,
            CandidateSelectionHint::Only,
            CommitSyncContext::Tests,
            false,
        )
        .await?;
    let parent_synced = large_to_small_syncer
        .sync_commit(
            &ctx,
            new_mapping_large_cs_id,
            CandidateSelectionHint::Only,
            CommitSyncContext::Tests,
            false,
        )
        .await?;
    // does_not_rewrite_large_cs_id commit was rewritten out, so sync_commit
    // should return the same changeset id as the parent
    assert_eq!(synced, parent_synced);

    let new_mapping_cs_id =
        CreateCommitContext::new(&ctx, &megarepo, vec![does_not_rewrite_large_cs_id])
            .add_file("tools/newtool", "1")
            .delete_file("tools/1.txt")
            .add_file("tools/somefile", "somefile1")
            .add_file("prefix/dir/file", "3")
            .add_file("prefix/dir/newfile", "3")
            .commit()
            .await?;

    let synced = large_to_small_syncer
        .sync_commit(
            &ctx,
            new_mapping_cs_id,
            CandidateSelectionHint::Only,
            CommitSyncContext::Tests,
            false,
        )
        .await?;
    assert!(synced.is_some());
    let new_mapping_small_cs_id = synced.unwrap();

    verify_working_copy(
        ctx.clone(),
        large_to_small_syncer.clone(),
        new_mapping_cs_id,
    )
    .await?;
    assert_working_copy(
        &ctx,
        small_repo,
        new_mapping_small_cs_id,
        vec!["tools/somefile", "tools/newtool", "dir/file", "dir/newfile"],
    )
    .await?;

    let outcome = large_to_small_syncer
        .get_commit_sync_outcome(&ctx, new_mapping_cs_id)
        .await?;

    match outcome {
        Some(CommitSyncOutcome::RewrittenAs(_, version)) => {
            assert_eq!(version, new_version);
        }
        _ => {
            return Err(anyhow!("unexpected outcome: {:?}", outcome));
        }
    }

    // Create a new commit on top of commit with old mapping.
    println!("create commits with old mapping");

    let old_mapping_large_cs_id = resolve_cs_id(&ctx, &megarepo, "old_mapping").await?;
    let does_not_rewrite_large_cs_id =
        CreateCommitContext::new(&ctx, &megarepo, vec![old_mapping_large_cs_id])
            .add_file("somerandomfile", "1")
            .commit()
            .await?;

    let old_mapping_cs_id =
        CreateCommitContext::new(&ctx, &megarepo, vec![does_not_rewrite_large_cs_id])
            .add_file("tools/3.txt", "2")
            .add_file("prefix/file", "2")
            .commit()
            .await?;
    let synced = large_to_small_syncer
        .sync_commit(
            &ctx,
            old_mapping_cs_id,
            CandidateSelectionHint::Only,
            CommitSyncContext::Tests,
            false,
        )
        .await?;
    assert!(synced.is_some());
    let old_mapping_small_cs_id = synced.unwrap();

    verify_working_copy(
        ctx.clone(),
        large_to_small_syncer.clone(),
        old_mapping_cs_id,
    )
    .await?;
    assert_working_copy(
        &ctx,
        small_repo,
        old_mapping_small_cs_id,
        vec!["dir/file", "file", "tools/1.txt"],
    )
    .await?;

    let outcome = large_to_small_syncer
        .get_commit_sync_outcome(&ctx, old_mapping_cs_id)
        .await?;

    match outcome {
        Some(CommitSyncOutcome::RewrittenAs(_, version)) => {
            assert_eq!(version, old_version);
        }
        _ => {
            return Err(anyhow!("unexpected outcome: {:?}", outcome));
        }
    }
    Ok(())
}

#[fbinit::test]
async fn test_disabled_sync(fb: FacebookInit) -> Result<(), Error> {
    let ctx = CoreContext::test_mock(fb);
    let (_, _, large_to_small_syncer) = prepare_commit_syncer_with_mapping_change(fb).await?;
    let megarepo = large_to_small_syncer.get_source_repo();

    let new_mapping_large_cs_id = resolve_cs_id(&ctx, &megarepo, "new_mapping").await?;
    // Create a new commit on top of commit with new mapping.
    let new_mapping_cs_id =
        CreateCommitContext::new(&ctx, &megarepo, vec![new_mapping_large_cs_id])
            .add_file("tools/newtool", "1")
            .commit()
            .await?;

    let tunables = MononokeTunables::default();
    tunables.update_bools(&hashmap! {"xrepo_sync_disable_all_syncs".to_string() => true});

    // Disable sync - make sure it fails
    let res = with_tunables_async(
        tunables,
        async {
            large_to_small_syncer
                .sync_commit(
                    &ctx,
                    new_mapping_cs_id,
                    CandidateSelectionHint::Only,
                    CommitSyncContext::Tests,
                    false,
                )
                .await
        }
        .boxed(),
    )
    .await;

    match res {
        Ok(_) => Err(anyhow!("unexpected success")),
        Err(err) => {
            check_x_repo_sync_disabled(&err);
            Ok(())
        }
    }
}

#[fbinit::test]
async fn test_disabled_sync_pushrebase(fb: FacebookInit) -> Result<(), Error> {
    let ctx = CoreContext::test_mock(fb);
    let (small_repo, megarepo, mapping) = prepare_repos_and_mapping(fb).await.unwrap();
    Linear::initrepo(fb, &small_repo).await;
    let small_to_large_syncer = create_small_to_large_commit_syncer(
        &ctx,
        small_repo.clone(),
        megarepo.clone(),
        "prefix",
        mapping.clone(),
    )?;

    create_initial_commit(ctx.clone(), &megarepo).await;

    let megarepo_master_cs_id = get_bookmark(&ctx, &megarepo, "master").await;
    let small_repo_master_cs_id = get_bookmark(&ctx, &small_repo, "master").await;
    // Masters map to each other before we even do any syncs
    let version = version_name_with_small_repo();
    mapping
        .add(
            &ctx,
            SyncedCommitMappingEntry::new(
                megarepo.repo_identity().id(),
                megarepo_master_cs_id,
                small_repo.repo_identity().id(),
                small_repo_master_cs_id,
                version.clone(),
                small_to_large_syncer.get_source_repo_type(),
            ),
        )
        .await?;

    // 2. Create a small repo commit and sync it onto both branches
    let small_repo_master_cs_id = create_commit_from_parent_and_changes(
        &ctx,
        &small_repo,
        small_repo_master_cs_id,
        btreemap! {"small_repo_file" => "content"},
    )
    .await;
    move_bookmark(&ctx, &small_repo, "master", small_repo_master_cs_id).await;

    let small_cs = small_repo_master_cs_id
        .load(&ctx, small_repo.repo_blobstore())
        .await?;

    let tunables = MononokeTunables::default();
    tunables.update_bools(&hashmap! {"xrepo_sync_disable_all_syncs".to_string() => true});

    // Disable sync - make sure it fails
    let res = with_tunables_async(
        tunables,
        async {
            small_to_large_syncer
                .unsafe_sync_commit_pushrebase(
                    &ctx,
                    small_cs.clone(),
                    BookmarkKey::new("master").unwrap(),
                    CommitSyncContext::Tests,
                    PushrebaseRewriteDates::No,
                )
                .await
        }
        .boxed(),
    )
    .await;

    match res {
        Ok(_) => Err(anyhow!("unexpected success")),
        Err(err) => match err.downcast_ref::<ErrorKind>() {
            Some(error_kind) => match error_kind {
                ErrorKind::PushrebaseFailure(error) => match error {
                    PushrebaseError::Error(err) => {
                        check_x_repo_sync_disabled(err);
                        Ok(())
                    }
                    _ => Err(anyhow!("unexpected pushrebase error: {}", error)),
                },
                _ => Err(anyhow!("unexpected ErrorKind: {}", error_kind)),
            },
            None => Err(anyhow!("unexpected error - not ErrorKind")),
        },
    }
}

fn check_x_repo_sync_disabled(err: &Error) {
    assert_matches!(
        err.downcast_ref::<ErrorKind>(),
        Some(ErrorKind::XRepoSyncDisabled)
    );
}

async fn prepare_commit_syncer_with_mapping_change(
    fb: FacebookInit,
) -> Result<
    (
        CommitSyncConfigVersion,
        CommitSyncConfigVersion,
        CommitSyncer<SqlSyncedCommitMapping, TestRepo>,
    ),
    Error,
> {
    let ctx = CoreContext::test_mock(fb);
    let (small_repo, megarepo, mapping) = prepare_repos_and_mapping(fb).await?;
    let (large_to_small_syncer, config_source) =
        create_large_to_small_commit_syncer_and_config_source(
            &ctx,
            small_repo.clone(),
            megarepo.clone(),
            "prefix",
            mapping,
        )?;

    let root_cs_id = CreateCommitContext::new_root(&ctx, &megarepo)
        .add_file("tools/somefile", "somefile")
        .add_file("prefix/tools/1.txt", "1")
        .add_file("prefix/dir/file", "2")
        .commit()
        .await?;

    bookmark(&ctx, &megarepo, "old_mapping")
        .set_to(root_cs_id)
        .await?;

    let maybe_small_root_cs_id = large_to_small_syncer
        .unsafe_always_rewrite_sync_commit(
            &ctx,
            root_cs_id,
            None,
            &version_name_with_small_repo(),
            CommitSyncContext::Tests,
        )
        .await?;
    assert!(maybe_small_root_cs_id.is_some());
    let small_root_cs_id = maybe_small_root_cs_id.unwrap();

    verify_working_copy(ctx.clone(), large_to_small_syncer.clone(), root_cs_id).await?;
    assert_working_copy(
        &ctx,
        &small_repo,
        small_root_cs_id,
        vec!["tools/1.txt", "dir/file"],
    )
    .await?;

    // Change the mapping - "tools" now doesn't change it's location after remapping!

    let small_repo_id = small_repo.repo_identity().id();
    let large_repo_id = megarepo.repo_identity().id();
    let small_repo_config = SmallRepoCommitSyncConfig {
        default_action: DefaultSmallToLargeCommitSyncPathAction::PrependPrefix(MPath::new(
            "prefix",
        )?),
        map: hashmap! {
            MPath::new("tools")? => MPath::new("tools")?,
        },
    };

    let old_version = CommitSyncConfigVersion("TEST_VERSION_NAME".to_string());
    let new_version = CommitSyncConfigVersion("TEST_VERSION_NAME2".to_string());
    let commit_sync_config = CommitSyncConfig {
        large_repo_id,
        common_pushrebase_bookmarks: vec![],
        small_repos: hashmap! {
            small_repo_id => small_repo_config,
        },
        version_name: new_version.clone(),
    };
    config_source.add_common_config(CommonCommitSyncConfig {
        common_pushrebase_bookmarks: vec![],
        small_repos: hashmap! {
            small_repo.repo_identity().id() => SmallRepoPermanentConfig {
                bookmark_prefix: AsciiString::new(),
            }
        },
        large_repo_id,
    });
    config_source.add_config(commit_sync_config);

    // Create manual commit to change mapping
    let new_mapping_large_cs_id = CreateCommitContext::new(&ctx, &megarepo, vec![root_cs_id])
        .delete_file("prefix/tools/1.txt")
        .add_file("tools/1.txt", "1")
        .commit()
        .await?;

    bookmark(&ctx, &megarepo, "new_mapping")
        .set_to(new_mapping_large_cs_id)
        .await?;

    let new_mapping_small_cs_id =
        CreateCommitContext::new(&ctx, &small_repo, vec![small_root_cs_id])
            .add_file("tools/somefile", "somefile")
            .commit()
            .await?;

    update_mapping_with_version(
        &ctx,
        hashmap! {new_mapping_large_cs_id => new_mapping_small_cs_id},
        &large_to_small_syncer,
        &new_version,
    )
    .await?;

    verify_working_copy(
        ctx.clone(),
        large_to_small_syncer.clone(),
        new_mapping_large_cs_id,
    )
    .await?;
    assert_working_copy(
        &ctx,
        &small_repo,
        new_mapping_small_cs_id,
        vec!["tools/1.txt", "tools/somefile", "dir/file"],
    )
    .await?;

    Ok((old_version, new_version, large_to_small_syncer))
}

/// Build a test CommitSyncDataProvider for merge
/// testing purposes.
fn get_merge_sync_data_provider(
    large_repo_id: RepositoryId,
    small_repo_id: RepositoryId,
) -> Result<CommitSyncDataProvider, Error> {
    let v1 = CommitSyncConfigVersion("v1".to_string());
    let v2 = CommitSyncConfigVersion("v2".to_string());

    let small_repo_config = SmallRepoCommitSyncConfig {
        default_action: DefaultSmallToLargeCommitSyncPathAction::Preserve,
        map: hashmap! {},
    };
    let commit_sync_config_v1 = CommitSyncConfig {
        large_repo_id,
        common_pushrebase_bookmarks: vec![BookmarkKey::new("master")?],
        small_repos: hashmap! {
            small_repo_id => small_repo_config.clone(),
        },
        version_name: v1,
    };
    let commit_sync_config_v2 = CommitSyncConfig {
        large_repo_id,
        common_pushrebase_bookmarks: vec![BookmarkKey::new("master")?],
        small_repos: hashmap! {
            small_repo_id => small_repo_config,
        },
        version_name: v2,
    };

    let common_config = CommonCommitSyncConfig {
        common_pushrebase_bookmarks: vec![BookmarkKey::new("master")?],
        small_repos: hashmap! {
            small_repo_id => SmallRepoPermanentConfig {
                bookmark_prefix: AsciiString::new(),
            }
        },
        large_repo_id,
    };

    let (sync_config, source) = TestLiveCommitSyncConfig::new_with_source();
    source.add_config(commit_sync_config_v1);
    source.add_config(commit_sync_config_v2);
    source.add_common_config(common_config);

    let live_commit_sync_config = Arc::new(sync_config);

    Ok(CommitSyncDataProvider::Live(live_commit_sync_config))
}

/// This function sets up scene for syncing merges
/// Main goal is to return mergeable commits in the large repo
/// with a convenient grouping of which versions were used to
/// sync these commits to the small repo.
/// More concretely, this function returns:
/// - a context object
/// - a large-to-small syncer
/// - a map of version-to-changesets-list, where all the changesets
///   in the list are synced with that mapping
async fn merge_test_setup(
    fb: FacebookInit,
) -> Result<
    (
        CoreContext,
        CommitSyncer<SqlSyncedCommitMapping, TestRepo>,
        HashMap<Option<CommitSyncConfigVersion>, Vec<ChangesetId>>,
    ),
    Error,
> {
    let ctx = CoreContext::test_mock(fb);
    // Set up various structures
    let mut factory = TestRepoFactory::new(fb)?;
    let large_repo: TestRepo = factory.with_id(RepositoryId::new(0)).build().await?;
    let small_repo: TestRepo = factory.with_id(RepositoryId::new(1)).build().await?;
    let mapping = SqlSyncedCommitMapping::with_sqlite_in_memory()?;
    let v1 = CommitSyncConfigVersion("v1".to_string());
    let v2 = CommitSyncConfigVersion("v2".to_string());

    let lts_syncer = {
        let mut lts_syncer = create_large_to_small_commit_syncer(
            &ctx,
            small_repo.clone(),
            large_repo.clone(),
            // This is ignored
            "_",
            mapping.clone(),
        )?;
        lts_syncer.repos = CommitSyncRepos::LargeToSmall {
            small_repo: small_repo.clone(),
            large_repo: large_repo.clone(),
        };
        lts_syncer.commit_sync_data_provider = get_merge_sync_data_provider(
            large_repo.repo_identity().id(),
            small_repo.repo_identity().id(),
        )?;
        lts_syncer
    };

    let c1 =
        create_initial_commit_with_contents(ctx.clone(), &large_repo, btreemap! { "f1" => "1" })
            .await;
    let c2 =
        create_initial_commit_with_contents(ctx.clone(), &large_repo, btreemap! { "f2" => "2" })
            .await;
    let c3 =
        create_initial_commit_with_contents(ctx.clone(), &large_repo, btreemap! { "f3" => "3" })
            .await;
    let c4 =
        create_initial_commit_with_contents(ctx.clone(), &large_repo, btreemap! { "f4" => "4" })
            .await;

    lts_syncer
        .unsafe_always_rewrite_sync_commit(
            &ctx,
            c1,
            None, // parents override
            &v1,
            CommitSyncContext::Tests,
        )
        .await?;
    lts_syncer
        .unsafe_always_rewrite_sync_commit(
            &ctx,
            c2,
            None, // parents override
            &v1,
            CommitSyncContext::Tests,
        )
        .await?;
    lts_syncer
        .unsafe_always_rewrite_sync_commit(
            &ctx,
            c3,
            None, // parents override
            &v2,
            CommitSyncContext::Tests,
        )
        .await?;
    lts_syncer
        .unsafe_always_rewrite_sync_commit(
            &ctx,
            c4,
            None, // parents override
            &v2,
            CommitSyncContext::Tests,
        )
        .await?;

    let heads_with_versions = hashmap! {
        Some(v1) => vec![c1, c2],
        Some(v2) => vec![c3, c4],
    };

    Ok((ctx, lts_syncer, heads_with_versions))
}

async fn create_merge(
    ctx: &CoreContext,
    repo: &TestRepo,
    parents: Vec<ChangesetId>,
) -> ChangesetId {
    let bcs = BonsaiChangesetMut {
        parents,
        author: "Test User <test@fb.com>".to_string(),
        author_date: DateTime::from_timestamp(1504040001, 0).unwrap(),
        committer: None,
        committer_date: None,
        message: "Never gonna give you up".to_string(),
        hg_extra: Default::default(),
        git_extra_headers: None,
        git_tree_hash: None,
        file_changes: Default::default(),
        is_snapshot: false,
        git_annotated_tag: None,
    }
    .freeze()
    .unwrap();

    let bcs_id = bcs.get_changeset_id();
    save_bonsai_changesets(vec![bcs], ctx.clone(), repo)
        .await
        .unwrap();

    bcs_id
}

#[fbinit::test]
async fn test_sync_merge_gets_version_from_parents_1(fb: FacebookInit) -> Result<(), Error> {
    let v1 = CommitSyncConfigVersion("v1".to_string());
    let (ctx, lts_syncer, heads_with_versions) = merge_test_setup(fb).await?;
    let heads = heads_with_versions[&Some(v1.clone())].clone();
    let merge_bcs_id = create_merge(&ctx, lts_syncer.get_source_repo(), heads).await;
    println!(
        "merge sync outcome: {:?}",
        lts_syncer
            .sync_commit(
                &ctx,
                merge_bcs_id,
                CandidateSelectionHint::Only,
                CommitSyncContext::Tests,
                false
            )
            .await?
    );
    let outcome = lts_syncer
        .get_commit_sync_outcome(&ctx, merge_bcs_id)
        .await?
        .expect("merge syncing outcome is missing");
    if let CommitSyncOutcome::RewrittenAs(_, merge_version) = outcome {
        assert_eq!(v1, merge_version);
    } else {
        panic!(
            "unexpected outcome after syncing a merge commit: {:?}",
            outcome
        );
    }
    Ok(())
}

#[fbinit::test]
async fn test_sync_merge_gets_version_from_parents_2(fb: FacebookInit) -> Result<(), Error> {
    let v2 = CommitSyncConfigVersion("v2".to_string());
    let (ctx, lts_syncer, heads_with_versions) = merge_test_setup(fb).await?;
    let heads = heads_with_versions[&Some(v2.clone())].clone();
    let merge_bcs_id = create_merge(&ctx, lts_syncer.get_source_repo(), heads).await;
    lts_syncer
        .sync_commit(
            &ctx,
            merge_bcs_id,
            CandidateSelectionHint::Only,
            CommitSyncContext::Tests,
            false,
        )
        .await?
        .unwrap();
    let outcome = lts_syncer
        .get_commit_sync_outcome(&ctx, merge_bcs_id)
        .await?
        .expect("merge syncing outcome is missing");
    if let CommitSyncOutcome::RewrittenAs(_, merge_version) = outcome {
        assert_eq!(v2, merge_version);
    } else {
        panic!(
            "unexpected outcome after syncing a merge commit: {:?}",
            outcome
        );
    }
    Ok(())
}

#[fbinit::test]
async fn test_sync_merge_fails_when_parents_have_different_versions(
    fb: FacebookInit,
) -> Result<(), Error> {
    let v1 = CommitSyncConfigVersion("v1".to_string());
    let v2 = CommitSyncConfigVersion("v2".to_string());
    let (ctx, lts_syncer, heads_with_versions) = merge_test_setup(fb).await?;
    let heads_0 = heads_with_versions[&Some(v1)].clone();
    let heads_1 = heads_with_versions[&Some(v2)].clone();
    let merge_heads = [heads_0[0], heads_1[0]].to_vec();
    let merge_bcs_id = create_merge(&ctx, lts_syncer.get_source_repo(), merge_heads).await;
    let e = lts_syncer
        .sync_commit(
            &ctx,
            merge_bcs_id,
            CandidateSelectionHint::Only,
            CommitSyncContext::Tests,
            false,
        )
        .await
        .expect_err("syncing a merge with differently-remapped parents must fail");
    assert!(format!("{}", e).contains("failed getting a mover to use for merge rewriting"));
    Ok(())
}

async fn assert_working_copy(
    ctx: &CoreContext,
    repo: &TestRepo,
    cs_id: ChangesetId,
    expected_files: Vec<&str>,
) -> Result<(), Error> {
    let hg_cs_id = repo.derive_hg_changeset(ctx, cs_id).await?;

    let hg_cs = hg_cs_id.load(ctx, repo.repo_blobstore()).await?;
    let mf_id = hg_cs.manifestid();
    let mut actual_paths = mf_id
        .list_leaf_entries(ctx.clone(), repo.repo_blobstore().clone())
        .map_ok(|(path, _)| path)
        .try_collect::<Vec<_>>()
        .await?;
    actual_paths.sort();

    let expected_paths: Result<Vec<_>, Error> =
        expected_files.into_iter().map(MPath::new).collect();
    let mut expected_paths = expected_paths?;
    expected_paths.sort();

    assert_eq!(actual_paths, expected_paths);
    Ok(())
}

async fn test_no_accidental_preserved_roots(
    ctx: CoreContext,
    commit_sync_repos: CommitSyncRepos<TestRepo>,
    mapping: SqlSyncedCommitMapping,
) -> Result<(), Error> {
    let version = version_name_with_small_repo();
    let commit_syncer = {
        use CommitSyncRepos::*;
        let mut commit_syncer = match &commit_sync_repos {
            LargeToSmall {
                small_repo,
                large_repo,
            } => create_large_to_small_commit_syncer(
                &ctx,
                small_repo.clone(),
                large_repo.clone(),
                "ignored",
                mapping.clone(),
            )?,
            SmallToLarge {
                small_repo,
                large_repo,
            } => create_small_to_large_commit_syncer(
                &ctx,
                small_repo.clone(),
                large_repo.clone(),
                "ignored",
                mapping.clone(),
            )?,
        };

        let small_repo_config = SmallRepoCommitSyncConfig {
            default_action: DefaultSmallToLargeCommitSyncPathAction::Preserve,
            map: hashmap! {},
        };
        let commit_sync_config = CommitSyncConfig {
            large_repo_id: commit_syncer.get_large_repo().repo_identity().id(),
            common_pushrebase_bookmarks: vec![BookmarkKey::new("master")?],
            small_repos: hashmap! {
                commit_syncer.get_small_repo().repo_identity().id() => small_repo_config,
            },
            version_name: version.clone(),
        };

        let common_config = CommonCommitSyncConfig {
            common_pushrebase_bookmarks: vec![BookmarkKey::new("master")?],
            small_repos: hashmap! {
                commit_syncer.get_small_repo().repo_identity().id() => SmallRepoPermanentConfig {
                    bookmark_prefix: AsciiString::new(),
                }
            },
            large_repo_id: commit_syncer.get_large_repo().repo_identity().id(),
        };

        let (sync_config, source) = TestLiveCommitSyncConfig::new_with_source();
        source.add_config(commit_sync_config);
        source.add_common_config(common_config);

        let live_commit_sync_config = Arc::new(sync_config);

        let commit_sync_data_provider = CommitSyncDataProvider::Live(live_commit_sync_config);
        commit_syncer.commit_sync_data_provider = commit_sync_data_provider;
        commit_syncer.repos = commit_sync_repos.clone();

        commit_syncer
    };

    let root_commit = create_initial_commit(ctx.clone(), commit_sync_repos.get_source_repo()).await;
    commit_syncer
        .unsafe_sync_commit_with_expected_version(
            &ctx,
            root_commit,
            CandidateSelectionHint::Only,
            CommitSyncConfigVersion("TEST_VERSION_NAME".to_string()),
            CommitSyncContext::Tests,
        )
        .await?;
    let outcome = commit_syncer
        .get_commit_sync_outcome(&ctx, root_commit)
        .await?;
    assert!(matches!(outcome, Some(CommitSyncOutcome::RewrittenAs(_, v)) if v == version));

    Ok(())
}

#[fbinit::test]
async fn test_no_accidental_preserved_roots_large_to_small(fb: FacebookInit) -> Result<(), Error> {
    let ctx = CoreContext::test_mock(fb);
    let (small_repo, large_repo, mapping) = prepare_repos_and_mapping(fb).await.unwrap();
    let commit_sync_repos = CommitSyncRepos::LargeToSmall {
        small_repo: small_repo.clone(),
        large_repo: large_repo.clone(),
    };
    test_no_accidental_preserved_roots(ctx, commit_sync_repos, mapping).await
}

#[fbinit::test]
async fn test_no_accidental_preserved_roots_small_to_large(fb: FacebookInit) -> Result<(), Error> {
    let ctx = CoreContext::test_mock(fb);
    let (small_repo, large_repo, mapping) = prepare_repos_and_mapping(fb).await.unwrap();
    let commit_sync_repos = CommitSyncRepos::SmallToLarge {
        small_repo: small_repo.clone(),
        large_repo: large_repo.clone(),
    };
    test_no_accidental_preserved_roots(ctx, commit_sync_repos, mapping).await
}

#[fbinit::test]
async fn test_not_sync_candidate_if_mapping_does_not_have_small_repo(
    fb: FacebookInit,
) -> Result<(), Error> {
    let ctx = CoreContext::test_mock(fb);
    let mut factory = TestRepoFactory::new(fb)?;
    let mapping = SqlSyncedCommitMapping::from_sql_connections(factory.metadata_db().clone());

    let large_repo_id = RepositoryId::new(0);
    let large_repo: TestRepo = factory.with_id(large_repo_id).build().await?;
    let first_small_repo_id = RepositoryId::new(1);
    let first_smallrepo: TestRepo = factory.with_id(first_small_repo_id).build().await?;
    let second_small_repo_id = RepositoryId::new(2);
    let second_smallrepo: TestRepo = factory.with_id(second_small_repo_id).build().await?;

    let (sync_config, source) = TestLiveCommitSyncConfig::new_with_source();

    // First create common config that have two small repos in it
    source.add_common_config(CommonCommitSyncConfig {
        common_pushrebase_bookmarks: vec![BookmarkKey::new("master")?],
        small_repos: hashmap! {
            first_small_repo_id => SmallRepoPermanentConfig {
                bookmark_prefix: AsciiString::new(),
            },
            second_small_repo_id => SmallRepoPermanentConfig {
                bookmark_prefix: AsciiString::new(),
            },
        },
        large_repo_id,
    });

    // Then create config version that has only a first config repo
    let noop_version_first_small_repo = CommitSyncConfigVersion("noop_first".to_string());
    let noop_first_version_config = CommitSyncConfig {
        large_repo_id,
        common_pushrebase_bookmarks: vec![BookmarkKey::new("master")?],
        small_repos: hashmap! {
            first_small_repo_id => SmallRepoCommitSyncConfig {
                default_action: DefaultSmallToLargeCommitSyncPathAction::Preserve,
                map: hashmap! {},
            },
        },
        version_name: noop_version_first_small_repo.clone(),
    };
    source.add_config(noop_first_version_config);

    // Now create commit in large repo and sync it to the first small repo with the config
    // created above.
    let commit_sync_data_provider = CommitSyncDataProvider::Live(Arc::new(sync_config));
    let repos = CommitSyncRepos::LargeToSmall {
        small_repo: first_smallrepo.clone(),
        large_repo: large_repo.clone(),
    };
    let large_to_first_small_commit_syncer = CommitSyncer::new_with_provider(
        &ctx,
        mapping.clone(),
        repos.clone(),
        commit_sync_data_provider.clone(),
    );

    let first_bcs_id = CreateCommitContext::new_root(&ctx, &large_repo)
        .add_file("file", "content")
        .commit()
        .await?;
    large_to_first_small_commit_syncer
        .unsafe_always_rewrite_sync_commit(
            &ctx,
            first_bcs_id,
            None, // parents override
            &noop_version_first_small_repo,
            CommitSyncContext::Tests,
        )
        .await?;

    // Now try to sync it to the other small repo, it should return NotSyncCandidate
    let repos = CommitSyncRepos::LargeToSmall {
        small_repo: second_smallrepo.clone(),
        large_repo: large_repo.clone(),
    };
    let large_to_second_small_commit_syncer = CommitSyncer::new_with_provider(
        &ctx,
        mapping.clone(),
        repos.clone(),
        commit_sync_data_provider.clone(),
    );
    large_to_second_small_commit_syncer
        .sync_commit(
            &ctx,
            first_bcs_id,
            CandidateSelectionHint::Only,
            CommitSyncContext::Tests,
            false,
        )
        .await?;

    assert_eq!(
        large_to_second_small_commit_syncer
            .get_commit_sync_outcome(&ctx, first_bcs_id)
            .await?,
        Some(CommitSyncOutcome::NotSyncCandidate(
            noop_version_first_small_repo
        ))
    );
    Ok(())
}


/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * This software may be used and distributed according to the terms of the
 * GNU General Public License version 2.
 */

use std::collections::HashMap;
use std::str::FromStr;
use std::sync::Arc;

use anyhow::format_err;
use anyhow::Error;
use ascii::AsciiString;
use blobrepo::AsBlobRepo;
use blobrepo::BlobRepo;
use blobstore::Loadable;
use bonsai_hg_mapping::BonsaiHgMapping;
use bonsai_hg_mapping::BonsaiHgMappingRef;
use bookmarks::BookmarkKey;
use bookmarks::BookmarkUpdateLog;
use bookmarks::BookmarkUpdateReason;
use bookmarks::Bookmarks;
use changeset_fetcher::ChangesetFetcher;
use changesets::Changesets;
use commit_graph::CommitGraph;
use commit_transformation::upload_commits;
use context::CoreContext;
use cross_repo_sync::rewrite_commit;
use cross_repo_sync::update_mapping_with_version;
use cross_repo_sync::CommitSyncContext;
use cross_repo_sync::CommitSyncDataProvider;
use cross_repo_sync::CommitSyncRepos;
use cross_repo_sync::CommitSyncer;
use cross_repo_sync::Repo;
use cross_repo_sync::Syncers;
use filenodes::Filenodes;
use filestore::FilestoreConfig;
use live_commit_sync_config::LiveCommitSyncConfig;
use live_commit_sync_config::TestLiveCommitSyncConfig;
use live_commit_sync_config::TestLiveCommitSyncConfigSource;
use maplit::hashmap;
use megarepolib::common::ChangesetArgs;
use megarepolib::perform_move;
use metaconfig_types::CommitSyncConfig;
use metaconfig_types::CommitSyncConfigVersion;
use metaconfig_types::CommonCommitSyncConfig;
use metaconfig_types::DefaultSmallToLargeCommitSyncPathAction;
use metaconfig_types::RepoConfig;
use metaconfig_types::SmallRepoCommitSyncConfig;
use metaconfig_types::SmallRepoPermanentConfig;
use mononoke_types::ChangesetId;
use mononoke_types::DateTime;
use mononoke_types::MPath;
use mononoke_types::RepositoryId;
use mutable_counters::MutableCounters;
use phases::Phases;
use repo_blobstore::RepoBlobstore;
use repo_derived_data::RepoDerivedData;
use repo_identity::RepoIdentity;
use sql_construct::SqlConstruct;
use synced_commit_mapping::SqlSyncedCommitMapping;
use synced_commit_mapping::SyncedCommitMapping;
use synced_commit_mapping::SyncedCommitMappingEntry;
use test_repo_factory::TestRepoFactory;
use tests_utils::bookmark;
use tests_utils::CreateCommitContext;

#[facet::container]
#[derive(Clone)]
pub struct TestRepo {
    #[delegate(
        dyn Bookmarks,
        dyn BookmarkUpdateLog,
        dyn BonsaiHgMapping,
        dyn Changesets,
        dyn ChangesetFetcher,
        dyn Filenodes,
        FilestoreConfig,
        dyn MutableCounters,
        dyn Phases,
        RepoBlobstore,
        RepoDerivedData,
        RepoIdentity,
        CommitGraph,
    )]
    pub blob_repo: BlobRepo,

    #[facet]
    pub repo_config: RepoConfig,
}

impl AsBlobRepo for TestRepo {
    fn as_blob_repo(&self) -> &BlobRepo {
        &self.blob_repo
    }
}

pub fn xrepo_mapping_version_with_small_repo() -> CommitSyncConfigVersion {
    CommitSyncConfigVersion("TEST_VERSION_NAME".to_string())
}

// Helper function that takes a root commit from source repo and rebases it on master bookmark
// in target repo
pub async fn rebase_root_on_master<M, R>(
    ctx: CoreContext,
    commit_syncer: &CommitSyncer<M, R>,
    source_bcs_id: ChangesetId,
) -> Result<ChangesetId, Error>
where
    M: SyncedCommitMapping + Clone + 'static,
    R: Repo,
{
    let bookmark_name = BookmarkKey::new("master").unwrap();
    let source_bcs = source_bcs_id
        .load(&ctx, commit_syncer.get_source_repo().repo_blobstore())
        .await
        .unwrap();
    if !source_bcs.parents().collect::<Vec<_>>().is_empty() {
        return Err(format_err!("not a root commit"));
    }

    let maybe_bookmark_val = commit_syncer
        .get_target_repo()
        .bookmarks()
        .get(ctx.clone(), &bookmark_name)
        .await?;

    let source_repo = commit_syncer.get_source_repo();
    let target_repo = commit_syncer.get_target_repo();

    let bookmark_val = maybe_bookmark_val.ok_or_else(|| format_err!("master not found"))?;
    let source_bcs_mut = source_bcs.into_mut();
    let maybe_rewritten = {
        let map = HashMap::new();
        let mover = commit_syncer
            .get_mover_by_version(&CommitSyncConfigVersion("TEST_VERSION_NAME".to_string()))
            .await?;
        rewrite_commit(
            &ctx,
            source_bcs_mut,
            &map,
            mover,
            source_repo,
            Default::default(),
        )
        .await?
    };
    let mut target_bcs_mut = maybe_rewritten.unwrap();
    target_bcs_mut.parents = vec![bookmark_val];

    let target_bcs = target_bcs_mut.freeze()?;

    upload_commits(
        &ctx,
        vec![target_bcs.clone()],
        commit_syncer.get_source_repo(),
        commit_syncer.get_target_repo(),
    )
    .await?;

    let mut txn = target_repo.bookmarks().create_transaction(ctx.clone());
    txn.force_set(
        &bookmark_name,
        target_bcs.get_changeset_id(),
        BookmarkUpdateReason::TestMove,
    )
    .unwrap();
    txn.commit().await.unwrap();

    let entry = SyncedCommitMappingEntry::new(
        target_repo.repo_identity().id(),
        target_bcs.get_changeset_id(),
        source_repo.repo_identity().id(),
        source_bcs_id,
        CommitSyncConfigVersion("TEST_VERSION_NAME".to_string()),
        commit_syncer.get_source_repo_type(),
    );
    commit_syncer.get_mapping().add(&ctx, entry).await?;

    Ok(target_bcs.get_changeset_id())
}

pub async fn init_small_large_repo(
    ctx: &CoreContext,
) -> Result<
    (
        Syncers<SqlSyncedCommitMapping, TestRepo>,
        CommitSyncConfig,
        TestLiveCommitSyncConfig,
        TestLiveCommitSyncConfigSource,
    ),
    Error,
> {
    let mut factory = TestRepoFactory::new(ctx.fb)?;
    let megarepo: TestRepo = factory.with_id(RepositoryId::new(1)).build().await?;
    let mapping = SqlSyncedCommitMapping::from_sql_connections(factory.metadata_db().clone());
    let smallrepo: TestRepo = factory.with_id(RepositoryId::new(0)).build().await?;

    let repos = CommitSyncRepos::SmallToLarge {
        small_repo: smallrepo.clone(),
        large_repo: megarepo.clone(),
    };

    let noop_version = CommitSyncConfigVersion("noop".to_string());
    let version_with_small_repo = xrepo_mapping_version_with_small_repo();
    let (sync_config, source) = TestLiveCommitSyncConfig::new_with_source();

    let noop_version_config = CommitSyncConfig {
        large_repo_id: RepositoryId::new(1),
        common_pushrebase_bookmarks: vec![BookmarkKey::new("master")?],
        small_repos: hashmap! {
            RepositoryId::new(0) => get_small_repo_sync_config_noop(),
        },
        version_name: noop_version.clone(),
    };

    let version_with_small_repo_config = CommitSyncConfig {
        large_repo_id: RepositoryId::new(1),
        common_pushrebase_bookmarks: vec![BookmarkKey::new("master")?],
        small_repos: hashmap! {
            RepositoryId::new(0) => get_small_repo_sync_config_1(),
        },
        version_name: version_with_small_repo.clone(),
    };

    source.add_config(noop_version_config);
    source.add_config(version_with_small_repo_config);

    source.add_common_config(CommonCommitSyncConfig {
        common_pushrebase_bookmarks: vec![],
        small_repos: hashmap! {
            RepositoryId::new(0) => SmallRepoPermanentConfig {
                bookmark_prefix: AsciiString::new(),
            }
        },
        large_repo_id: RepositoryId::new(1),
    });

    let commit_sync_data_provider = CommitSyncDataProvider::Live(Arc::new(sync_config.clone()));

    let small_to_large_commit_syncer = CommitSyncer::new_with_provider(
        ctx,
        mapping.clone(),
        repos.clone(),
        commit_sync_data_provider.clone(),
    );

    let repos = CommitSyncRepos::LargeToSmall {
        small_repo: smallrepo.clone(),
        large_repo: megarepo.clone(),
    };

    let large_to_small_commit_syncer = CommitSyncer::new_with_provider(
        ctx,
        mapping.clone(),
        repos.clone(),
        commit_sync_data_provider,
    );

    let first_bcs_id = CreateCommitContext::new_root(ctx, &smallrepo)
        .add_file("file", "content")
        .commit()
        .await?;
    let second_bcs_id = CreateCommitContext::new(ctx, &smallrepo, vec![first_bcs_id])
        .add_file("file2", "content")
        .commit()
        .await?;

    small_to_large_commit_syncer
        .unsafe_always_rewrite_sync_commit(
            ctx,
            first_bcs_id,
            None, // parents override
            &noop_version,
            CommitSyncContext::Tests,
        )
        .await?;
    small_to_large_commit_syncer
        .unsafe_always_rewrite_sync_commit(
            ctx,
            second_bcs_id,
            None, // parents override
            &noop_version,
            CommitSyncContext::Tests,
        )
        .await?;
    bookmark(ctx, &smallrepo, "premove")
        .set_to(second_bcs_id)
        .await?;
    bookmark(ctx, &megarepo, "premove")
        .set_to(second_bcs_id)
        .await?;

    let move_cs_args = ChangesetArgs {
        author: "Author Authorov".to_string(),
        message: "move commit".to_string(),
        datetime: DateTime::from_rfc3339("2018-11-29T12:00:00.00Z").unwrap(),
        bookmark: None,
        mark_public: false,
    };
    let move_hg_cs = perform_move(
        ctx,
        &megarepo.blob_repo,
        second_bcs_id,
        Arc::new(prefix_mover),
        move_cs_args,
    )
    .await?;

    let maybe_move_bcs_id = megarepo
        .bonsai_hg_mapping()
        .get_bonsai_from_hg(ctx, move_hg_cs)
        .await?;
    let move_bcs_id = maybe_move_bcs_id.unwrap();

    bookmark(ctx, &megarepo, "megarepo_start")
        .set_to(move_bcs_id)
        .await?;

    bookmark(ctx, &smallrepo, "megarepo_start")
        .set_to("premove")
        .await?;

    // Master commit in the small repo after "big move"
    let small_master_bcs_id = CreateCommitContext::new(ctx, &smallrepo, vec![second_bcs_id])
        .add_file("file3", "content3")
        .commit()
        .await?;

    // Master commit in large repo after "big move"
    let large_master_bcs_id = CreateCommitContext::new(ctx, &megarepo, vec![move_bcs_id])
        .add_file("prefix/file3", "content3")
        .commit()
        .await?;

    bookmark(ctx, &smallrepo, "master")
        .set_to(small_master_bcs_id)
        .await?;
    bookmark(ctx, &megarepo, "master")
        .set_to(large_master_bcs_id)
        .await?;

    update_mapping_with_version(
        ctx,
        hashmap! { small_master_bcs_id => large_master_bcs_id},
        &small_to_large_commit_syncer,
        &version_with_small_repo,
    )
    .await?;

    println!(
        "small master: {}, large master: {}",
        small_master_bcs_id, large_master_bcs_id
    );
    println!(
        "{:?}",
        small_to_large_commit_syncer
            .get_commit_sync_outcome(ctx, small_master_bcs_id)
            .await?
    );

    Ok((
        Syncers {
            small_to_large: small_to_large_commit_syncer,
            large_to_small: large_to_small_commit_syncer,
        },
        base_commit_sync_config(&megarepo, &smallrepo),
        sync_config,
        source,
    ))
}

pub fn base_commit_sync_config(large_repo: &TestRepo, small_repo: &TestRepo) -> CommitSyncConfig {
    let small_repo_sync_config = SmallRepoCommitSyncConfig {
        default_action: DefaultSmallToLargeCommitSyncPathAction::PrependPrefix(
            MPath::new("prefix").unwrap(),
        ),
        map: hashmap! {},
    };
    CommitSyncConfig {
        large_repo_id: large_repo.repo_identity().id(),
        common_pushrebase_bookmarks: vec![],
        small_repos: hashmap! {
            small_repo.repo_identity().id() => small_repo_sync_config,
        },
        version_name: CommitSyncConfigVersion("TEST_VERSION_NAME".to_string()),
    }
}

fn prefix_mover(v: &MPath) -> Result<Option<MPath>, Error> {
    let prefix = MPath::new("prefix").unwrap();
    Ok(Some(MPath::join(&prefix, v)))
}

pub fn get_live_commit_sync_config() -> Arc<dyn LiveCommitSyncConfig> {
    let (sync_config, source) = TestLiveCommitSyncConfig::new_with_source();

    let bookmark_prefix = AsciiString::from_str("small").unwrap();
    let first_version = CommitSyncConfig {
        large_repo_id: RepositoryId::new(0),
        common_pushrebase_bookmarks: vec![],
        small_repos: hashmap! {
            RepositoryId::new(1) => get_small_repo_sync_config_1(),
        },
        version_name: CommitSyncConfigVersion("first_version".to_string()),
    };

    let second_version = CommitSyncConfig {
        large_repo_id: RepositoryId::new(0),
        common_pushrebase_bookmarks: vec![],
        small_repos: hashmap! {
            RepositoryId::new(1) => get_small_repo_sync_config_2(),
        },
        version_name: CommitSyncConfigVersion("second_version".to_string()),
    };

    source.add_config(first_version);
    source.add_config(second_version);

    source.add_common_config(CommonCommitSyncConfig {
        common_pushrebase_bookmarks: vec![],
        small_repos: hashmap! {
            RepositoryId::new(1) => SmallRepoPermanentConfig {
                bookmark_prefix,
            }
        },
        large_repo_id: RepositoryId::new(0),
    });

    Arc::new(sync_config)
}

fn get_small_repo_sync_config_noop() -> SmallRepoCommitSyncConfig {
    SmallRepoCommitSyncConfig {
        default_action: DefaultSmallToLargeCommitSyncPathAction::Preserve,
        map: hashmap! {},
    }
}

fn get_small_repo_sync_config_1() -> SmallRepoCommitSyncConfig {
    SmallRepoCommitSyncConfig {
        default_action: DefaultSmallToLargeCommitSyncPathAction::PrependPrefix(
            MPath::new("prefix").unwrap(),
        ),
        map: hashmap! {},
    }
}

fn get_small_repo_sync_config_2() -> SmallRepoCommitSyncConfig {
    SmallRepoCommitSyncConfig {
        default_action: DefaultSmallToLargeCommitSyncPathAction::PrependPrefix(
            MPath::new("prefix").unwrap(),
        ),
        map: hashmap! {
            MPath::new("special").unwrap() => MPath::new("special").unwrap(),
        },
    }
}


/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * This software may be used and distributed according to the terms of the
 * GNU General Public License version 2.
 */

use anyhow::Result;
use cached_config::ConfigHandle;
use cached_config::ConfigStore;
use commitsync::types::RawCommitSyncCurrentVersions;
use mononoke_types::RepositoryId;
use pushredirect_enable::types::MononokePushRedirectEnable;
use vec1::vec1;
use vec1::Vec1;

#[derive(Clone)]
pub struct CfgrCurrentCommitSyncConfig {
    config_handle_for_current_version: ConfigHandle<RawCommitSyncCurrentVersions>,
    config_handle_for_push_redirection: ConfigHandle<MononokePushRedirectEnable>,
}

pub enum RepoGroup {
    // Not on any megarepo
    Single(RepositoryId),
    // Megarepo with a large repo and (possibly) many small
    // More complicated structures not currently supported
    Megarepo {
        large: RepositoryId,
        small: Vec1<RepositoryId>,
    },
}

impl CfgrCurrentCommitSyncConfig {
    pub fn new(config_store: &ConfigStore) -> Result<Self> {
        let config_handle_for_push_redirection =
            config_store.get_config_handle(super::CONFIGERATOR_PUSHREDIRECT_ENABLE.to_string())?;
        let config_handle_for_current_version = config_store
            .get_config_handle(super::CONFIGERATOR_CURRENT_COMMIT_SYNC_CONFIG.to_string())?;
        Ok(Self {
            config_handle_for_current_version,
            config_handle_for_push_redirection,
        })
    }

    pub async fn repo_group(&self, repo_id: RepositoryId) -> Result<RepoGroup> {
        let config = self.config_handle_for_current_version.get();
        let group = config.repos.values().find(|config| {
            // Is large repo?
            config.large_repo_id == repo_id.id()
                // Is small repo?
                    || config
                        .small_repos
                        .iter()
                        .any(|small| small.repoid == repo_id.id())
        });
        if let Some(group) = group {
            Ok(RepoGroup::Megarepo {
                large: RepositoryId::new(group.large_repo_id),
                small: group
                    .small_repos
                    .iter()
                    .map(|small| RepositoryId::new(small.repoid))
                    .collect::<Vec<_>>()
                    .try_into()?,
            })
        } else {
            Ok(RepoGroup::Single(repo_id))
        }
    }
}

impl RepoGroup {
    /// If this repository group is a megarepo group and it has a small repo which has the
    /// pushredirection config set, but that config is set to false, it means we need to
    /// be extra careful that the changes we're doing will not lead to repos diverging.
    pub fn small_repos_with_pushredirection_disabled(
        &self,
        config: &CfgrCurrentCommitSyncConfig,
    ) -> Option<Vec1<RepositoryId>> {
        let mut repos = vec![];
        if let RepoGroup::Megarepo { large: _, small } = self {
            let config = config.config_handle_for_push_redirection.get();
            for small_repo in small {
                if let Some(state) = config.per_repo.get(&(small_repo.id() as i64)) {
                    if !state.public_push {
                        repos.push(small_repo.clone());
                    }
                }
            }
        }
        Vec1::try_from(repos).ok()
    }

    pub fn into_vec(self) -> Vec1<RepositoryId> {
        match self {
            Self::Single(repo) => vec1![repo],
            Self::Megarepo { large, mut small } => {
                small.push(large);
                small
            }
        }
    }
}