import asyncio
from datetime import timedelta

import pytest
from pytest import raises

from backup.config import Config, Setting, CreateOptions
from backup.exceptions import LogicError, LowSpaceError, NoBackup, PleaseWait, UserCancelledError
from backup.util import GlobalInfo, DataCache
from backup.model import Coordinator, Model, Backup, DestinationPrecache
from .conftest import FsFaker
from .faketime import FakeTime
from .helpers import HelperTestSource, skipForWindows


@pytest.fixture
def source():
    return HelperTestSource("Source")


@pytest.fixture
def dest():
    return HelperTestSource("Dest")


@pytest.fixture
def simple_config():
    config = Config()
    config.override(Setting.BACKUP_STARTUP_DELAY_MINUTES, 0)
    return config


@pytest.fixture
def model(source, dest, time, simple_config, global_info, estimator, data_cache: DataCache):
    return Model(simple_config, time, source, dest, global_info, estimator, data_cache)


@pytest.fixture
def coord(model, time, simple_config, global_info, estimator):
    return Coordinator(model, time, simple_config, global_info, estimator)


@pytest.fixture
def precache(coord, time, dest, simple_config):
    return DestinationPrecache(coord, time, dest, simple_config)


@pytest.mark.asyncio
async def test_enabled(coord: Coordinator, dest, time):
    dest.setEnabled(True)
    assert coord.enabled()
    dest.setEnabled(False)
    assert not coord.enabled()


@pytest.mark.asyncio
async def test_sync(coord: Coordinator, global_info: GlobalInfo, time: FakeTime):
    await coord.sync()
    assert global_info._syncs == 1
    assert global_info._successes == 1
    assert global_info._last_sync_start == time.now()
    assert len(coord.backups()) == 1


@pytest.mark.asyncio
async def test_blocking(coord: Coordinator):
    # This just makes sure the wait thread is blocked while we do stuff
    event_start = asyncio.Event()
    event_end = asyncio.Event()
    asyncio.create_task(coord._withSoftLock(lambda: sleepHelper(event_start, event_end)))
    await event_start.wait()

    # Make sure PleaseWait gets called on these
    with raises(PleaseWait):
        await coord.delete(None, None)
    with raises(PleaseWait):
        await coord.sync()
    with raises(PleaseWait):
        await coord.uploadBackups(None)
    with raises(PleaseWait):
        await coord.startBackup(None)
    event_end.set()


async def sleepHelper(event_start: asyncio.Event, event_end: asyncio.Event):
    event_start.set()
    await event_end.wait()


@pytest.mark.asyncio
async def test_new_backup(coord: Coordinator, time: FakeTime, source, dest):
    await coord.startBackup(CreateOptions(time.now(), "Test Name"))
    backups = coord.backups()
    assert len(backups) == 1
    assert backups[0].name() == "Test Name"
    assert backups[0].getSource(source.name()) is not None
    assert backups[0].getSource(dest.name()) is None


@pytest.mark.asyncio
async def test_sync_error(coord: Coordinator, global_info: GlobalInfo, time: FakeTime, model):
    error = Exception("BOOM")
    old_sync = model.sync
    model.sync = lambda s: doRaise(error)
    await coord.sync()
    assert global_info._last_error is error
    assert global_info._last_failure_time == time.now()
    assert global_info._successes == 0
    model.sync = old_sync
    await coord.sync()
    assert global_info._last_error is None
    assert global_info._successes == 1
    assert global_info._last_success == time.now()
    await coord.sync()


def doRaise(error):
    raise error


@pytest.mark.asyncio
async def test_delete(coord: Coordinator, backup, source, dest):
    assert backup.getSource(source.name()) is not None
    assert backup.getSource(dest.name()) is not None
    await coord.delete([source.name()], backup.slug())
    assert len(coord.backups()) == 1
    assert backup.getSource(source.name()) is None
    assert backup.getSource(dest.name()) is not None
    await coord.delete([dest.name()], backup.slug())
    assert backup.getSource(source.name()) is None
    assert backup.getSource(dest.name()) is None
    assert backup.isDeleted()
    assert len(coord.backups()) == 0

    await coord.sync()
    assert len(coord.backups()) == 1
    await coord.delete([source.name(), dest.name()], coord.backups()[0].slug())
    assert len(coord.backups()) == 0


@pytest.mark.asyncio
async def test_delete_errors(coord: Coordinator, source, dest, backup):
    with raises(NoBackup):
        await coord.delete([source.name()], "badslug")
    bad_source = HelperTestSource("bad")
    with raises(NoBackup):
        await coord.delete([bad_source.name()], backup.slug())


@pytest.mark.asyncio
async def test_retain(coord: Coordinator, source, dest, backup):
    assert not backup.getSource(source.name()).retained()
    assert not backup.getSource(dest.name()).retained()
    await coord.retain({
        source.name(): True,
        dest.name(): True
    }, backup.slug())
    assert backup.getSource(source.name()).retained()
    assert backup.getSource(dest.name()).retained()


@pytest.mark.asyncio
async def test_retain_errors(coord: Coordinator, source, dest, backup):
    with raises(NoBackup):
        await coord.retain({source.name(): True}, "badslug")
    bad_source = HelperTestSource("bad")
    with raises(NoBackup):
        await coord.delete({bad_source.name(): True}, backup.slug())


@pytest.mark.asyncio
async def test_freshness(coord: Coordinator, source: HelperTestSource, dest: HelperTestSource, backup: Backup, time: FakeTime):
    source.setMax(2)
    dest.setMax(2)
    await coord.sync()
    assert backup.getPurges() == {
        source.name(): False,
        dest.name(): False
    }

    source.setMax(1)
    dest.setMax(1)
    await coord.sync()
    assert backup.getPurges() == {
        source.name(): True,
        dest.name(): True
    }

    dest.setMax(0)
    await coord.sync()
    assert backup.getPurges() == {
        source.name(): True,
        dest.name(): False
    }

    source.setMax(0)
    await coord.sync()
    assert backup.getPurges() == {
        source.name(): False,
        dest.name(): False
    }

    source.setMax(2)
    dest.setMax(2)
    time.advance(days=7)
    await coord.sync()
    assert len(coord.backups()) == 2
    assert backup.getPurges() == {
        source.name(): True,
        dest.name(): True
    }
    assert coord.backups()[1].getPurges() == {
        source.name(): False,
        dest.name(): False
    }

    # should refresh on delete
    source.setMax(1)
    dest.setMax(1)
    await coord.delete([source.name()], backup.slug())
    assert coord.backups()[0].getPurges() == {
        dest.name(): True
    }
    assert coord.backups()[1].getPurges() == {
        source.name(): True,
        dest.name(): False
    }

    # should update on retain
    await coord.retain({dest.name(): True}, backup.slug())
    assert coord.backups()[0].getPurges() == {
        dest.name(): False
    }
    assert coord.backups()[1].getPurges() == {
        source.name(): True,
        dest.name(): True
    }

    # should update on upload
    await coord.uploadBackups(coord.backups()[0].slug())
    assert coord.backups()[0].getPurges() == {
        dest.name(): False,
        source.name(): True
    }
    assert coord.backups()[1].getPurges() == {
        source.name(): False,
        dest.name(): True
    }


@pytest.mark.asyncio
async def test_upload(coord: Coordinator, source: HelperTestSource, dest: HelperTestSource, backup):
    await coord.delete([source.name()], backup.slug())
    assert backup.getSource(source.name()) is None
    await coord.uploadBackups(backup.slug())
    assert backup.getSource(source.name()) is not None

    with raises(LogicError):
        await coord.uploadBackups(backup.slug())

    with raises(NoBackup):
        await coord.uploadBackups("bad slug")

    await coord.delete([dest.name()], backup.slug())
    with raises(NoBackup):
        await coord.uploadBackups(backup.slug())


@pytest.mark.asyncio
async def test_download(coord: Coordinator, source, dest, backup):
    await coord.download(backup.slug())
    await coord.delete([source.name()], backup.slug())
    await coord.download(backup.slug())

    with raises(NoBackup):
        await coord.download("bad slug")


@pytest.mark.asyncio
async def test_backoff(coord: Coordinator, model, source: HelperTestSource, dest: HelperTestSource, backup, time: FakeTime, simple_config: Config):
    assert await coord.check()
    simple_config.override(Setting.DAYS_BETWEEN_BACKUPS, 1)
    simple_config.override(Setting.MAX_SYNC_INTERVAL_SECONDS, 60 * 60 * 6)
    simple_config.override(Setting.DEFAULT_SYNC_INTERVAL_VARIATION, 0)

    assert coord.nextSyncAttempt() == time.now() + timedelta(hours=6)
    assert not await coord.check()
    old_sync = model.sync
    model.sync = lambda s: doRaise(Exception("BOOM"))
    await coord.sync()

    # first backoff should be 0 seconds
    assert coord.nextSyncAttempt() == time.now()
    assert await coord.check()

    # backoff maxes out at 2 hr = 7200 seconds
    for seconds in [10, 20, 40, 80, 160, 320, 640, 1280, 2560, 5120, 7200, 7200]:
        await coord.sync()
        assert coord.nextSyncAttempt() == time.now() + timedelta(seconds=seconds)
        assert not await coord.check()
        assert not await coord.check()
        assert not await coord.check()

    # a good sync resets it back to 6 hours from now
    model.sync = old_sync
    await coord.sync()
    assert coord.nextSyncAttempt() == time.now() + timedelta(hours=6)
    assert not await coord.check()

    # if the next backup is less that 6 hours from the last one, that that shoudl be when we sync
    simple_config.override(Setting.DAYS_BETWEEN_BACKUPS, 1.0 / 24.0)
    assert coord.nextSyncAttempt() == time.now() + timedelta(hours=1)
    assert not await coord.check()

    time.advance(hours=2)
    assert coord.nextSyncAttempt() == time.now() - timedelta(hours=1)
    assert await coord.check()


def test_save_creds(coord: Coordinator, source, dest):
    pass


@pytest.mark.asyncio
async def test_check_size_new_backup(coord: Coordinator, source: HelperTestSource, dest: HelperTestSource, time, fs: FsFaker):
    skipForWindows()
    fs.setFreeBytes(0)
    with raises(LowSpaceError):
        await coord.startBackup(CreateOptions(time.now(), "Test Name"))


@pytest.mark.asyncio
async def test_check_size_sync(coord: Coordinator, source: HelperTestSource, dest: HelperTestSource, time, fs: FsFaker, global_info: GlobalInfo):
    skipForWindows()
    fs.setFreeBytes(0)
    await coord.sync()
    assert len(coord.backups()) == 0
    assert global_info._last_error is not None

    await coord.sync()
    assert len(coord.backups()) == 0
    assert global_info._last_error is not None

    # Verify it resets the global size skip check, but gets through once
    global_info.setSkipSpaceCheckOnce(True)
    await coord.sync()
    assert len(coord.backups()) == 1
    assert global_info._last_error is None
    assert not global_info.isSkipSpaceCheckOnce()

    # Next attempt to backup shoudl fail again.
    time.advance(days=7)
    await coord.sync()
    assert len(coord.backups()) == 1
    assert global_info._last_error is not None


@pytest.mark.asyncio
async def test_cancel(coord: Coordinator, global_info: GlobalInfo):
    coord._sync_wait.clear()
    asyncio.create_task(coord.sync())
    await coord._sync_start.wait()
    await coord.cancel()
    assert isinstance(global_info._last_error, UserCancelledError)


@pytest.mark.asyncio
async def test_working_through_upload(coord: Coordinator, global_info: GlobalInfo, dest):
    coord._sync_wait.clear()
    assert not coord.isWorkingThroughUpload()
    sync_task = asyncio.create_task(coord.sync())
    await coord._sync_start.wait()
    assert not coord.isWorkingThroughUpload()
    dest.working = True
    assert coord.isWorkingThroughUpload()
    coord._sync_wait.set()
    await asyncio.wait([sync_task])
    assert not coord.isWorkingThroughUpload()


@pytest.mark.asyncio
async def test_alternate_timezone(coord: Coordinator, time: FakeTime, model: Model, dest, source, simple_config: Config):
    time.setTimeZone("Europe/Stockholm")
    simple_config.override(Setting.BACKUP_TIME_OF_DAY, "12:00")
    simple_config.override(Setting.DAYS_BETWEEN_BACKUPS, 1)

    source.setMax(10)
    source.insert("Fri", time.toUtc(time.local(2020, 3, 16, 18, 5)))
    time.setNow(time.local(2020, 3, 16, 18, 6))
    model.reinitialize()
    coord.reset()
    await coord.sync()
    assert not await coord.check()
    assert coord.nextBackupTime() == time.local(2020, 3, 17, 12)

    time.setNow(time.local(2020, 3, 17, 11, 59))
    await coord.sync()
    assert not await coord.check()
    time.setNow(time.local(2020, 3, 17, 12))
    assert await coord.check()


@pytest.mark.asyncio
async def test_disabled_at_install(coord: Coordinator, dest, time):
    """
    Verifies that at install time, if some backups are already present the
    addon doesn't try to sync over and over when drive is disabled.  This was
    a problem at one point.
    """
    dest.setEnabled(True)
    await coord.sync()
    assert len(coord.backups()) == 1

    dest.setEnabled(False)
    time.advance(days=5)
    assert await coord.check()
    await coord.sync()
    assert not await coord.check()


@pytest.mark.asyncio
async def test_only_source_configured(coord: Coordinator, dest: HelperTestSource, time, source: HelperTestSource):
    source.setEnabled(True)
    dest.setEnabled(False)
    dest.setNeedsConfiguration(False)
    await coord.sync()
    assert len(coord.backups()) == 1


@pytest.mark.asyncio
async def test_schedule_backup_next_sync_attempt(coord: Coordinator, model, source: HelperTestSource, dest: HelperTestSource, backup, time: FakeTime, simple_config: Config):
    """
    Next backup is before max sync interval is reached
    """
    simple_config.override(Setting.DAYS_BETWEEN_BACKUPS, 1)
    simple_config.override(Setting.MAX_SYNC_INTERVAL_SECONDS, 60 * 60)
    simple_config.override(Setting.DEFAULT_SYNC_INTERVAL_VARIATION, 0)

    time.setTimeZone("Europe/Stockholm")
    simple_config.override(Setting.BACKUP_TIME_OF_DAY, "03:23")
    simple_config.override(Setting.DAYS_BETWEEN_BACKUPS, 1)

    source.setMax(10)
    source.insert("Fri", time.toUtc(time.local(2020, 3, 16, 3, 33)))

    time.setNow(time.local(2020, 3, 17, 2, 29))
    model.reinitialize()
    coord.reset()
    await coord.sync()
    assert coord.nextBackupTime() == time.local(2020, 3, 17, 3, 23)
    assert coord.nextBackupTime() == coord.nextSyncAttempt()


@pytest.mark.asyncio
async def test_max_sync_interval_next_sync_attempt(coord: Coordinator, model, source: HelperTestSource, dest: HelperTestSource, backup, time: FakeTime, simple_config: Config):
    """
    Next backup is after max sync interval is reached
    """
    simple_config.override(Setting.DAYS_BETWEEN_BACKUPS, 1)
    simple_config.override(Setting.MAX_SYNC_INTERVAL_SECONDS, 60 * 60)
    simple_config.override(Setting.DEFAULT_SYNC_INTERVAL_VARIATION, 0)

    time.setTimeZone("Europe/Stockholm")
    simple_config.override(Setting.BACKUP_TIME_OF_DAY, "03:23")
    simple_config.override(Setting.DAYS_BETWEEN_BACKUPS, 1)

    source.setMax(10)
    source.insert("Fri", time.toUtc(time.local(2020, 3, 16, 3, 33)))
    time.setNow(time.local(2020, 3, 17, 1, 29))
    model.reinitialize()
    coord.reset()
    await coord.sync()
    assert coord.nextSyncAttempt() == time.local(2020, 3, 17, 2, 29)
    assert coord.nextBackupTime() > coord.nextSyncAttempt()


@pytest.mark.asyncio
async def test_generational_only_ignored_snapshots(coord: Coordinator, model, source: HelperTestSource, dest: HelperTestSource, time: FakeTime, simple_config: Config, global_info: GlobalInfo):
    """
    Verifies a sync with generational settings and only ignored snapshots doesn't cause an error.
    Setup is taken from https://github.com/sabeechen/hassio-google-drive-backup/issues/727
    """
    simple_config.override(Setting.DAYS_BETWEEN_BACKUPS, 1)
    simple_config.override(Setting.GENERATIONAL_DAYS, 3)
    simple_config.override(Setting.GENERATIONAL_WEEKS, 4)
    simple_config.override(Setting.GENERATIONAL_DELETE_EARLY, True)
    simple_config.override(Setting.MAX_BACKUPS_IN_HA, 2)
    simple_config.override(Setting.MAX_BACKUPS_IN_GOOGLE_DRIVE, 6)

    backup = source.insert("Fri", time.toUtc(time.local(2020, 3, 16, 3, 33)))
    backup.setIgnore(True)
    time.setNow(time.local(2020, 3, 16, 4, 0))
    dest.setEnabled(False)
    source.setEnabled(True)

    await coord.sync()
    assert global_info._last_error is None


@pytest.mark.asyncio
async def test_max_sync_interval_randomness(coord: Coordinator, model, source: HelperTestSource, dest: HelperTestSource, backup, time: FakeTime, simple_config: Config):
    simple_config.override(Setting.DAYS_BETWEEN_BACKUPS, 1)
    simple_config.override(Setting.MAX_SYNC_INTERVAL_SECONDS, 60 * 60)
    simple_config.override(Setting.DEFAULT_SYNC_INTERVAL_VARIATION, 0.5)

    time.setTimeZone("Europe/Stockholm")
    simple_config.override(Setting.BACKUP_TIME_OF_DAY, "03:23")
    simple_config.override(Setting.DAYS_BETWEEN_BACKUPS, 1)

    source.setMax(10)
    source.insert("Fri", time.toUtc(time.local(2020, 3, 16, 3, 33)))
    time.setNow(time.local(2020, 3, 17, 1, 29))
    model.reinitialize()
    coord.reset()
    await coord.sync()
    next_attempt = coord.nextSyncAttempt()

    # verify its within expected range
    assert next_attempt - time.now() <= timedelta(hours=1)
    assert next_attempt - time.now() >= timedelta(hours=0.5)

    # verify it doesn't change
    assert coord.nextSyncAttempt() == next_attempt
    time.advance(minutes=1)
    assert coord.nextSyncAttempt() == next_attempt

    # sync, and verify it does change
    await coord.sync()
    assert coord.nextSyncAttempt() != next_attempt


@pytest.mark.asyncio
async def test_precaching(coord: Coordinator, precache: DestinationPrecache, dest: HelperTestSource, time: FakeTime, global_info: GlobalInfo):
    await coord.sync()
    dest.reset()

    # Warm the cache
    assert precache.getNextWarmDate() < coord.nextSyncAttempt()
    assert precache.cached(dest.name(), time.now()) is None
    assert dest.query_count == 0
    time.setNow(precache.getNextWarmDate())
    await precache.checkForSmoothing()
    assert precache.cached(dest.name(), time.now()) is not None
    assert dest.query_count == 1

    # No queries should have been made to dest, and the cache should now be cleared
    time.setNow(coord.nextSyncAttempt())
    assert precache.cached(dest.name(), time.now()) is not None
    await coord.sync()
    assert dest.query_count == 1
    assert precache.cached(dest.name(), time.now()) is None
    assert global_info._last_error is None
