/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

use super::request::InfoConfiguration;
use super::{CollectionKeys, GlobalState};
use crate::engine::{CollSyncIds, EngineSyncAssociation, SyncEngine};
use crate::error;
use crate::KeyBundle;
use crate::ServerTimestamp;

/// Holds state for a collection necessary to perform a sync of it. Lives for the lifetime
/// of a single sync.
#[derive(Debug, Clone)]
pub struct CollState {
    // Info about the server configuration/capabilities
    pub config: InfoConfiguration,
    // from meta/global, used for XIUS when we POST outgoing record based on this state.
    pub last_modified: ServerTimestamp,
    pub key: KeyBundle,
}

/// This mini state-machine helps build a CollState
#[derive(Debug)]
pub enum LocalCollState {
    /// The state is unknown, with the EngineSyncAssociation the collection
    /// reports.
    Unknown { assoc: EngineSyncAssociation },

    /// The engine has been declined. This is a "terminal" state.
    Declined,

    /// There's no such collection in meta/global. We could possibly update
    /// meta/global, but currently all known collections are there by default,
    /// so this is, basically, an error condition.
    NoSuchCollection,

    /// Either the global or collection sync ID has changed - we will reset the engine.
    SyncIdChanged { ids: CollSyncIds },

    /// The collection is ready to sync.
    Ready { coll_state: CollState },
}

pub struct LocalCollStateMachine<'state> {
    global_state: &'state GlobalState,
    root_key: &'state KeyBundle,
}

impl<'state> LocalCollStateMachine<'state> {
    fn advance(
        &self,
        from: LocalCollState,
        engine: &dyn SyncEngine,
    ) -> error::Result<LocalCollState> {
        let name = &engine.collection_name().to_string();
        let meta_global = &self.global_state.global;
        match from {
            LocalCollState::Unknown { assoc } => {
                if meta_global.declined.contains(name) {
                    return Ok(LocalCollState::Declined);
                }
                match meta_global.engines.get(name) {
                    Some(engine_meta) => match assoc {
                        EngineSyncAssociation::Disconnected => Ok(LocalCollState::SyncIdChanged {
                            ids: CollSyncIds {
                                global: meta_global.sync_id.clone(),
                                coll: engine_meta.sync_id.clone(),
                            },
                        }),
                        EngineSyncAssociation::Connected(ref ids)
                            if ids.global == meta_global.sync_id
                                && ids.coll == engine_meta.sync_id =>
                        {
                            // We are done - build the CollState
                            let coll_keys = CollectionKeys::from_encrypted_payload(
                                self.global_state.keys.clone(),
                                self.global_state.keys_timestamp,
                                self.root_key,
                            )?;
                            let key = coll_keys.key_for_collection(name).clone();
                            let name = engine.collection_name();
                            let config = self.global_state.config.clone();
                            let last_modified = self
                                .global_state
                                .collections
                                .get(name.as_ref())
                                .cloned()
                                .unwrap_or_default();
                            let coll_state = CollState {
                                config,
                                last_modified,
                                key,
                            };
                            Ok(LocalCollState::Ready { coll_state })
                        }
                        _ => Ok(LocalCollState::SyncIdChanged {
                            ids: CollSyncIds {
                                global: meta_global.sync_id.clone(),
                                coll: engine_meta.sync_id.clone(),
                            },
                        }),
                    },
                    None => Ok(LocalCollState::NoSuchCollection),
                }
            }

            LocalCollState::Declined => unreachable!("can't advance from declined"),

            LocalCollState::NoSuchCollection => unreachable!("the collection is unknown"),

            LocalCollState::SyncIdChanged { ids } => {
                let assoc = EngineSyncAssociation::Connected(ids);
                log::info!("Resetting {} engine", engine.collection_name());
                engine.reset(&assoc)?;
                Ok(LocalCollState::Unknown { assoc })
            }

            LocalCollState::Ready { .. } => unreachable!("can't advance from ready"),
        }
    }

    // A little whimsy - a portmanteau of far and fast
    fn run_and_run_as_farst_as_you_can(
        &mut self,
        engine: &dyn SyncEngine,
    ) -> error::Result<Option<CollState>> {
        let mut s = LocalCollState::Unknown {
            assoc: engine.get_sync_assoc()?,
        };
        // This is a simple state machine and should never take more than
        // 10 goes around.
        let mut count = 0;
        loop {
            log::trace!("LocalCollState in {:?}", s);
            match s {
                LocalCollState::Ready { coll_state } => return Ok(Some(coll_state)),
                LocalCollState::Declined | LocalCollState::NoSuchCollection => return Ok(None),
                _ => {
                    count += 1;
                    if count > 10 {
                        log::warn!("LocalCollStateMachine appears to be looping");
                        return Ok(None);
                    }
                    // should we have better loop detection? Our limit of 10
                    // goes is probably OK for now, but not really ideal.
                    s = self.advance(s, engine)?;
                }
            };
        }
    }

    pub fn get_state(
        engine: &dyn SyncEngine,
        global_state: &'state GlobalState,
        root_key: &'state KeyBundle,
    ) -> error::Result<Option<CollState>> {
        let mut gingerbread_man = Self {
            global_state,
            root_key,
        };
        gingerbread_man.run_and_run_as_farst_as_you_can(engine)
    }
}

#[cfg(test)]
mod tests {
    use super::super::request::{InfoCollections, InfoConfiguration};
    use super::super::CollectionKeys;
    use super::*;
    use crate::engine::CollectionRequest;
    use crate::engine::{IncomingChangeset, OutgoingChangeset};
    use crate::record_types::{MetaGlobalEngine, MetaGlobalRecord};
    use crate::{telemetry, CollectionName};
    use anyhow::Result;
    use std::cell::{Cell, RefCell};
    use std::collections::HashMap;
    use sync_guid::Guid;

    fn get_global_state(root_key: &KeyBundle) -> GlobalState {
        let keys = CollectionKeys::new_random()
            .unwrap()
            .to_encrypted_payload(root_key)
            .unwrap();
        GlobalState {
            config: InfoConfiguration::default(),
            collections: InfoCollections::new(HashMap::new()),
            global: MetaGlobalRecord {
                sync_id: "syncIDAAAAAA".into(),
                storage_version: 5usize,
                engines: vec![(
                    "bookmarks",
                    MetaGlobalEngine {
                        version: 1usize,
                        sync_id: "syncIDBBBBBB".into(),
                    },
                )]
                .into_iter()
                .map(|(key, value)| (key.to_owned(), value))
                .collect(),
                declined: vec![],
            },
            global_timestamp: ServerTimestamp::default(),
            keys,
            keys_timestamp: ServerTimestamp::default(),
        }
    }

    struct TestSyncEngine {
        collection_name: &'static str,
        assoc: Cell<EngineSyncAssociation>,
        num_resets: RefCell<usize>,
    }

    impl TestSyncEngine {
        fn new(collection_name: &'static str, assoc: EngineSyncAssociation) -> Self {
            Self {
                collection_name,
                assoc: Cell::new(assoc),
                num_resets: RefCell::new(0),
            }
        }
        fn get_num_resets(&self) -> usize {
            *self.num_resets.borrow()
        }
    }

    impl SyncEngine for TestSyncEngine {
        fn collection_name(&self) -> CollectionName {
            self.collection_name.into()
        }

        fn apply_incoming(
            &self,
            _inbound: Vec<IncomingChangeset>,
            _telem: &mut telemetry::Engine,
        ) -> Result<OutgoingChangeset> {
            unreachable!("these tests shouldn't call these");
        }

        fn sync_finished(
            &self,
            _new_timestamp: ServerTimestamp,
            _records_synced: Vec<Guid>,
        ) -> Result<()> {
            unreachable!("these tests shouldn't call these");
        }

        fn get_collection_requests(
            &self,
            _server_timestamp: ServerTimestamp,
        ) -> Result<Vec<CollectionRequest>> {
            unreachable!("these tests shouldn't call these");
        }

        fn get_sync_assoc(&self) -> Result<EngineSyncAssociation> {
            Ok(self.assoc.replace(EngineSyncAssociation::Disconnected))
        }

        fn reset(&self, new_assoc: &EngineSyncAssociation) -> Result<()> {
            self.assoc.replace(new_assoc.clone());
            *self.num_resets.borrow_mut() += 1;
            Ok(())
        }

        fn wipe(&self) -> Result<()> {
            unreachable!("these tests shouldn't call these");
        }
    }

    #[test]
    fn test_unknown() {
        let root_key = KeyBundle::new_random().expect("should work");
        let gs = get_global_state(&root_key);
        let engine = TestSyncEngine::new("unknown", EngineSyncAssociation::Disconnected);
        let cs = LocalCollStateMachine::get_state(&engine, &gs, &root_key).expect("should work");
        assert!(cs.is_none(), "unknown collection name can't sync");
        assert_eq!(engine.get_num_resets(), 0);
    }

    #[test]
    fn test_known_no_state() {
        let root_key = KeyBundle::new_random().expect("should work");
        let gs = get_global_state(&root_key);
        let engine = TestSyncEngine::new("bookmarks", EngineSyncAssociation::Disconnected);
        let cs = LocalCollStateMachine::get_state(&engine, &gs, &root_key).expect("should work");
        assert!(cs.is_some(), "collection can sync");
        assert_eq!(
            engine.assoc.replace(EngineSyncAssociation::Disconnected),
            EngineSyncAssociation::Connected(CollSyncIds {
                global: "syncIDAAAAAA".into(),
                coll: "syncIDBBBBBB".into(),
            })
        );
        assert_eq!(engine.get_num_resets(), 1);
    }

    #[test]
    fn test_known_wrong_state() {
        let root_key = KeyBundle::new_random().expect("should work");
        let gs = get_global_state(&root_key);
        let engine = TestSyncEngine::new(
            "bookmarks",
            EngineSyncAssociation::Connected(CollSyncIds {
                global: "syncIDXXXXXX".into(),
                coll: "syncIDYYYYYY".into(),
            }),
        );
        let cs = LocalCollStateMachine::get_state(&engine, &gs, &root_key).expect("should work");
        assert!(cs.is_some(), "collection can sync");
        assert_eq!(
            engine.assoc.replace(EngineSyncAssociation::Disconnected),
            EngineSyncAssociation::Connected(CollSyncIds {
                global: "syncIDAAAAAA".into(),
                coll: "syncIDBBBBBB".into(),
            })
        );
        assert_eq!(engine.get_num_resets(), 1);
    }

    #[test]
    fn test_known_good_state() {
        let root_key = KeyBundle::new_random().expect("should work");
        let gs = get_global_state(&root_key);
        let engine = TestSyncEngine::new(
            "bookmarks",
            EngineSyncAssociation::Connected(CollSyncIds {
                global: "syncIDAAAAAA".into(),
                coll: "syncIDBBBBBB".into(),
            }),
        );
        let cs = LocalCollStateMachine::get_state(&engine, &gs, &root_key).expect("should work");
        assert!(cs.is_some(), "collection can sync");
        assert_eq!(engine.get_num_resets(), 0);
    }

    #[test]
    fn test_declined() {
        let root_key = KeyBundle::new_random().expect("should work");
        let mut gs = get_global_state(&root_key);
        gs.global.declined.push("bookmarks".to_string());
        let engine = TestSyncEngine::new(
            "bookmarks",
            EngineSyncAssociation::Connected(CollSyncIds {
                global: "syncIDAAAAAA".into(),
                coll: "syncIDBBBBBB".into(),
            }),
        );
        let cs = LocalCollStateMachine::get_state(&engine, &gs, &root_key).expect("should work");
        assert!(cs.is_none(), "declined collection can sync");
        assert_eq!(engine.get_num_resets(), 0);
    }
}
