# 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/.

"""
Tests that backups created with older manifest versions can still be
recovered by the current version of Firefox.

This ensures backward compatibility as the backup schema evolves.

To add a new version:
1. Before bumping SCHEMA_VERSION, run test_generate_backup_fixture.py to create the fixtures
2. Add entry to VERSION_CONFIG in compat_config.py
3. Implement any new _verify_* methods for extra_checks
4. Add test methods: test_recover_vN_backup_selectable / _legacy
"""

import os
import sys
import tempfile
from pathlib import Path

import mozfile

sys.path.append(os.fspath(Path(__file__).parents[0]))
from backup_test_base import BackupTestBase
from compat_config import VERSION_CONFIG


class BackupCompatibilityTest(BackupTestBase):
    """Test backward compatibility of backup recovery across schema versions."""

    def test_recover_v1_backup_selectable(self):
        """Test that a v1 backup can be recovered into a selectable profile environment."""
        self.logger.info("=== Test: V1 Backup -> Selectable ===")
        self._test_recover_backup_selectable(1)

    def test_recover_v1_backup_legacy(self):
        """Test that a v1 backup can be recovered into a legacy profile environment."""
        self.logger.info("=== Test: V1 Backup -> Legacy ===")
        self._test_recover_backup_legacy(1)

    def _test_recover_backup_selectable(self, version):
        """Test recovering a backup into a selectable profile environment."""
        config = VERSION_CONFIG[version]
        backup_file = config["selectable_backup_file"] or config["legacy_backup_file"]
        backup_path = self._get_backup_path(backup_file)
        self.assertTrue(
            os.path.exists(backup_path),
            f"V{version} backup fixture must exist at {backup_path}",
        )

        self.logger.info("Step 1: Setting up selectable profile environment")
        profile_name = self.register_profile_and_restart()
        self._cleanups.append({"profile_name": profile_name})

        selectable_info = self.setup_selectable_profile()
        original_store_id = selectable_info["store_id"]
        self.assertIsNotNone(original_store_id, "storeID should be set")
        self.logger.info(f"Recovery environment storeID: {original_store_id}")

        self.logger.info(f"Step 2: Recovering v{version} backup")
        self._recovery_path = os.path.join(
            tempfile.gettempdir(), f"v{version}-compat-selectable-recovery"
        )
        mozfile.remove(self._recovery_path)
        self._cleanups.append({"path": self._recovery_path})

        result = self._recover_backup(
            backup_path, self._recovery_path, config["recovery_password"]
        )
        self._new_profile_path = result["path"]
        self._new_profile_id = result["id"]
        self._cleanups.append({"path": self._new_profile_path})
        self.logger.info(
            f"Recovery complete. New profile path: {self._new_profile_path}"
        )

        self.logger.info("Step 3: Launching recovered profile and verifying data")
        self.marionette.quit()
        intermediate_profile = self.marionette.instance.profile
        self.marionette.instance.profile = self._new_profile_path
        self.marionette.start_session()
        self.marionette.set_context("chrome")

        self.wait_for_post_recovery()

        self.init_selectable_profile_service()

        store_id = self.get_store_id()
        self.assertEqual(
            store_id,
            original_store_id,
            "Recovered profile should have the same storeID as profile group",
        )
        self.logger.info(f"Verified storeID matches: {store_id}")

        self._verify_common_data(version)
        self.assert_profile_source("backup")

        for check in config["extra_checks_selectable"]:
            verify_method = getattr(self, f"_verify_{check}")
            verify_method(version)

        self.logger.info("Step 4: Cleaning up")
        self.marionette.quit()
        self.marionette.instance.profile = intermediate_profile
        self.marionette.start_session()
        self.marionette.set_context("chrome")

        self.cleanup_selectable_profiles()
        self.logger.info(f"=== Test: V{version} Backup -> Selectable PASSED ===")

    def _test_recover_backup_legacy(self, version):
        """Test recovering a backup into a legacy profile environment."""
        config = VERSION_CONFIG[version]
        backup_path = self._get_backup_path(config["legacy_backup_file"])
        self.assertTrue(
            os.path.exists(backup_path),
            f"V{version} backup fixture must exist at {backup_path}",
        )

        self.logger.info("Step 1: Setting up legacy profile environment")
        profile_name = self.register_profile_and_restart()
        self._cleanups.append({"profile_name": profile_name})

        self.set_prefs({
            "browser.profiles.enabled": True,
            "browser.profiles.created": False,
        })

        has_selectable = self.has_selectable_profiles()
        self.assertFalse(has_selectable, "Should start as legacy profile")
        self.logger.info("Verified profile is legacy")

        self.logger.info(f"Step 2: Recovering v{version} backup")
        self._recovery_path = os.path.join(
            tempfile.gettempdir(), f"v{version}-compat-legacy-recovery"
        )
        mozfile.remove(self._recovery_path)
        self._cleanups.append({"path": self._recovery_path})

        result = self._recover_backup(
            backup_path, self._recovery_path, config["recovery_password"]
        )
        self._new_profile_path = result["path"]
        self._new_profile_id = result["id"]
        self._cleanups.append({"path": self._new_profile_path})
        self.logger.info(
            f"Recovery complete. New profile path: {self._new_profile_path}"
        )

        self.logger.info("Step 3: Verifying legacy profile was converted to selectable")
        has_selectable_after = self.has_selectable_profiles()
        self.assertTrue(
            has_selectable_after,
            "Legacy profile should be converted to selectable after recovery",
        )
        self.logger.info("Legacy profile converted to selectable")

        self.logger.info("Step 4: Launching recovered profile and verifying data")
        self.marionette.quit()
        intermediate_profile = self.marionette.instance.profile
        self.marionette.instance.profile = self._new_profile_path
        self.marionette.start_session()
        self.marionette.set_context("chrome")

        self.wait_for_post_recovery()

        self.init_selectable_profile_service()

        self._verify_common_data(version)

        for check in config["extra_checks_legacy"]:
            verify_method = getattr(self, f"_verify_{check}")
            verify_method(version)

        self.logger.info("Step 5: Cleaning up")
        self.marionette.quit()
        self.marionette.instance.profile = intermediate_profile
        self.marionette.start_session()
        self.marionette.set_context("chrome")

        self.cleanup_selectable_profiles()
        self.logger.info(f"=== Test: V{version} Backup -> Legacy PASSED ===")

    def _verify_common_data(self, version):
        """Verify data that should exist in all backup versions."""
        prefix = f"v{version}-test"
        self._verify_login(f"https://{prefix}.example.com")
        self._verify_bookmark(f"https://{prefix}.example.com/bookmark")
        self._verify_history(f"https://{prefix}.example.com/history")
        self._verify_preference(f"test.v{version}.compatibility.pref")

    def _get_backup_path(self, filename):
        """Get path to a backup fixture."""
        test_dir = os.path.dirname(__file__)
        return os.path.join(test_dir, "compat-files", "backups", filename)

    def _recover_backup(
        self,
        archive_path,
        recovery_path,
        recovery_password,
        replace_current_profile=False,
    ):
        """Recover from an encrypted backup archive."""
        return self.run_async(
            """
            const { OSKeyStore } = ChromeUtils.importESModule(
                "resource://gre/modules/OSKeyStore.sys.mjs"
            );
            const { BackupService } = ChromeUtils.importESModule(
                "resource:///modules/backup/BackupService.sys.mjs"
            );
            let [archivePath, recoveryCode, recoveryPath, replaceCurrentProfile] = arguments;
            // Use a fake OSKeyStore label to avoid keychain auth prompts
            OSKeyStore.STORE_LABEL = "test-" + Math.random().toString(36).substr(2);

            let bs = BackupService.init();
            let newProfileRoot = await IOUtils.createUniqueDirectory(
                PathUtils.tempDir, "backupCompatTest"
            );
            let profile = await bs.recoverFromBackupArchive(
                archivePath, recoveryCode, false, recoveryPath, newProfileRoot, replaceCurrentProfile
            );
            let rootDir = await profile.rootDir;
            return { name: profile.name, path: rootDir.path, id: profile.id };
            """,
            script_args=[
                archive_path,
                recovery_password,
                recovery_path,
                replace_current_profile,
            ],
        )

    def _verify_login(self, origin):
        """Verify a login exists for the given origin."""
        count = self.marionette.execute_async_script(
            """
            let [origin, outerResolve] = arguments;
            (async () => {
                let logins = await Services.logins.searchLoginsAsync({
                    origin: origin,
                });
                return logins.length;
            })().then(outerResolve);
            """,
            script_args=[origin],
        )
        self.assertEqual(count, 1, f"Login for {origin} should exist")

    def _verify_bookmark(self, url):
        """Verify a bookmark exists for the given URL."""
        exists = self.marionette.execute_async_script(
            """
            const { PlacesUtils } = ChromeUtils.importESModule(
                "resource://gre/modules/PlacesUtils.sys.mjs"
            );
            let [url, outerResolve] = arguments;
            (async () => {
                let bookmark = await PlacesUtils.bookmarks.fetch({ url });
                return bookmark != null;
            })().then(outerResolve);
            """,
            script_args=[url],
        )
        self.assertTrue(exists, f"Bookmark for {url} should exist")

    def _verify_history(self, url):
        """Verify a history entry exists for the given URL."""
        exists = self.marionette.execute_async_script(
            """
            const { PlacesUtils } = ChromeUtils.importESModule(
                "resource://gre/modules/PlacesUtils.sys.mjs"
            );
            let [url, outerResolve] = arguments;
            (async () => {
                let entry = await PlacesUtils.history.fetch(url);
                return entry != null;
            })().then(outerResolve);
            """,
            script_args=[url],
        )
        self.assertTrue(exists, f"History for {url} should exist")

    def _verify_preference(self, pref_name):
        """Verify a preference exists and is true."""
        value = self.marionette.execute_script(
            """
            let [prefName] = arguments;
            return Services.prefs.getBoolPref(prefName, false);
            """,
            script_args=[pref_name],
        )
        self.assertTrue(value, f"Preference {pref_name} should be true")

    def _verify_selectable_profile_metadata(self, version):
        """Verify recovered profile has expected selectable profile metadata."""
        metadata = self.get_selectable_profile_metadata()
        self.assertEqual(
            metadata["name"],
            f"V{version} Test Profile",
            f"Profile name should be 'V{version} Test Profile'",
        )
        self.assertEqual(
            metadata["avatar"],
            "book",
            "Profile avatar should be 'book'",
        )
        self.assertIsNotNone(
            metadata["theme"],
            "Profile should have a theme object",
        )
        self.logger.info(
            f"Verified selectable profile metadata: name={metadata['name']}, "
            f"avatar={metadata['avatar']}"
        )
