# Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html

import copy
import os
import time
from typing import Callable, Dict

import pytest

from anki import hooks
from anki.consts import *
from anki.lang import without_unicode_isolation
from anki.scheduler import UnburyDeck
from anki.utils import int_time
from tests.shared import getEmptyCol as getEmptyColOrig


# This file is used to exercise both the legacy Python 2.1 scheduler,
# and the experimental new one in Rust. Most tests run on both, but a few
# tests have been implemented separately where the behaviour differs.
def is_2021() -> bool:
    return "2021" in os.getenv("PYTEST_CURRENT_TEST")


def getEmptyCol():
    col = getEmptyColOrig()
    col.upgrade_to_v2_scheduler()
    if is_2021():
        col.set_v3_scheduler(True)
    return col


def test_clock():
    col = getEmptyCol()
    if (col.sched.day_cutoff - int_time()) < 10 * 60:
        raise Exception("Unit tests will fail around the day rollover.")


def checkRevIvl(col, c, targetIvl):
    if is_2021():
        return
    min, max = col.sched._fuzzIvlRange(targetIvl)
    assert min <= c.ivl <= max


def test_basics():
    col = getEmptyCol()
    col.reset()
    assert not col.sched.getCard()


def test_new():
    col = getEmptyCol()
    col.reset()
    assert col.sched.newCount == 0
    # add a note
    note = col.newNote()
    note["Front"] = "one"
    note["Back"] = "two"
    col.addNote(note)
    col.reset()
    assert col.sched.newCount == 1
    # fetch it
    c = col.sched.getCard()
    assert c
    assert c.queue == QUEUE_TYPE_NEW
    assert c.type == CARD_TYPE_NEW
    # if we answer it, it should become a learn card
    t = int_time()
    col.sched.answerCard(c, 1)
    assert c.queue == QUEUE_TYPE_LRN
    assert c.type == CARD_TYPE_LRN
    assert c.due >= t

    # disabled for now, as the learn fudging makes this randomly fail
    # # the default order should ensure siblings are not seen together, and
    # # should show all cards
    # m = col.models.current(); mm = col.models
    # t = mm.new_template("Reverse")
    # t['qfmt'] = "{{Back}}"
    # t['afmt'] = "{{Front}}"
    # mm.add_template(m, t)
    # mm.save(m)
    # note = col.newNote()
    # note['Front'] = u"2"; note['Back'] = u"2"
    # col.addNote(note)
    # note = col.newNote()
    # note['Front'] = u"3"; note['Back'] = u"3"
    # col.addNote(note)
    # col.reset()
    # qs = ("2", "3", "2", "3")
    # for n in range(4):
    #     c = col.sched.getCard()
    #     assert qs[n] in c.question()
    #     col.sched.answerCard(c, 2)


def test_newLimits():
    col = getEmptyCol()
    # add some notes
    deck2 = col.decks.id("Default::foo")
    for i in range(30):
        note = col.newNote()
        note["Front"] = str(i)
        if i > 4:
            note.note_type()["did"] = deck2
        col.addNote(note)
    # give the child deck a different configuration
    c2 = col.decks.add_config_returning_id("new conf")
    col.decks.set_config_id_for_deck_dict(col.decks.get(deck2), c2)
    col.reset()
    # both confs have defaulted to a limit of 20
    assert col.sched.newCount == 20
    # first card we get comes from parent
    c = col.sched.getCard()
    assert c.did == 1
    # limit the parent to 10 cards, meaning we get 10 in total
    conf1 = col.decks.config_dict_for_deck_id(1)
    conf1["new"]["perDay"] = 10
    col.decks.save(conf1)
    col.reset()
    assert col.sched.newCount == 10
    # if we limit child to 4, we should get 9
    conf2 = col.decks.config_dict_for_deck_id(deck2)
    conf2["new"]["perDay"] = 4
    col.decks.save(conf2)
    col.reset()
    assert col.sched.newCount == 9


def test_newBoxes():
    col = getEmptyCol()
    note = col.newNote()
    note["Front"] = "one"
    col.addNote(note)
    col.reset()
    c = col.sched.getCard()
    conf = col.sched._cardConf(c)
    conf["new"]["delays"] = [1, 2, 3, 4, 5]
    col.decks.save(conf)
    col.sched.answerCard(c, 2)
    # should handle gracefully
    conf["new"]["delays"] = [1]
    col.decks.save(conf)
    col.sched.answerCard(c, 2)


def test_learn():
    col = getEmptyCol()
    # add a note
    note = col.newNote()
    note["Front"] = "one"
    note["Back"] = "two"
    col.addNote(note)
    # set as a new card and rebuild queues
    col.db.execute(f"update cards set queue={QUEUE_TYPE_NEW}, type={CARD_TYPE_NEW}")
    col.reset()
    # sched.getCard should return it, since it's due in the past
    c = col.sched.getCard()
    assert c
    conf = col.sched._cardConf(c)
    conf["new"]["delays"] = [0.5, 3, 10]
    col.decks.save(conf)
    # fail it
    col.sched.answerCard(c, 1)
    # it should have three reps left to graduation
    assert c.left % 1000 == 3
    # it should be due in 30 seconds
    t = round(c.due - time.time())
    assert t >= 25 and t <= 40
    # pass it once
    col.sched.answerCard(c, 3)
    # it should be due in 3 minutes
    dueIn = c.due - time.time()
    assert 178 <= dueIn <= 180 * 1.25
    assert c.left % 1000 == 2
    # check log is accurate
    log = col.db.first("select * from revlog order by id desc")
    assert log[3] == 3
    assert log[4] == -180
    assert log[5] == -30
    # pass again
    col.sched.answerCard(c, 3)
    # it should be due in 10 minutes
    dueIn = c.due - time.time()
    assert 599 <= dueIn <= 600 * 1.25
    assert c.left % 1000 == 1
    # the next pass should graduate the card
    assert c.queue == QUEUE_TYPE_LRN
    assert c.type == CARD_TYPE_LRN
    col.sched.answerCard(c, 3)
    assert c.queue == QUEUE_TYPE_REV
    assert c.type == CARD_TYPE_REV
    # should be due tomorrow, with an interval of 1
    assert c.due == col.sched.today + 1
    assert c.ivl == 1
    # or normal removal
    c.type = CARD_TYPE_NEW
    c.queue = QUEUE_TYPE_LRN
    c.flush()
    col.sched.reset()
    col.sched.answerCard(c, 4)
    assert c.type == CARD_TYPE_REV
    assert c.queue == QUEUE_TYPE_REV
    checkRevIvl(col, c, 4)
    # revlog should have been updated each time
    assert col.db.scalar("select count() from revlog where type = 0") == 5


def test_relearn():
    col = getEmptyCol()
    note = col.newNote()
    note["Front"] = "one"
    col.addNote(note)
    c = note.cards()[0]
    c.ivl = 100
    c.due = col.sched.today
    c.queue = CARD_TYPE_REV
    c.type = QUEUE_TYPE_REV
    c.flush()

    # fail the card
    col.reset()
    c = col.sched.getCard()
    col.sched.answerCard(c, 1)
    assert c.queue == QUEUE_TYPE_LRN
    assert c.type == CARD_TYPE_RELEARNING
    assert c.ivl == 1

    # immediately graduate it
    col.sched.answerCard(c, 4)
    assert c.queue == CARD_TYPE_REV and c.type == QUEUE_TYPE_REV
    assert c.ivl == 2
    assert c.due == col.sched.today + c.ivl


def test_relearn_no_steps():
    col = getEmptyCol()
    note = col.newNote()
    note["Front"] = "one"
    col.addNote(note)
    c = note.cards()[0]
    c.ivl = 100
    c.due = col.sched.today
    c.queue = CARD_TYPE_REV
    c.type = QUEUE_TYPE_REV
    c.flush()

    conf = col.decks.config_dict_for_deck_id(1)
    conf["lapse"]["delays"] = []
    col.decks.save(conf)

    # fail the card
    col.reset()
    c = col.sched.getCard()
    col.sched.answerCard(c, 1)
    assert c.queue == CARD_TYPE_REV and c.type == QUEUE_TYPE_REV


def test_learn_collapsed():
    col = getEmptyCol()
    # add 2 notes
    note = col.newNote()
    note["Front"] = "1"
    col.addNote(note)
    note = col.newNote()
    note["Front"] = "2"
    col.addNote(note)
    # set as a new card and rebuild queues
    col.db.execute(f"update cards set queue={QUEUE_TYPE_NEW}, type={CARD_TYPE_NEW}")
    col.reset()
    # should get '1' first
    c = col.sched.getCard()
    assert c.question().endswith("1")
    # pass it so it's due in 10 minutes
    col.sched.answerCard(c, 3)
    # get the other card
    c = col.sched.getCard()
    assert c.question().endswith("2")
    # fail it so it's due in 1 minute
    col.sched.answerCard(c, 1)
    # we shouldn't get the same card again
    c = col.sched.getCard()
    assert not c.question().endswith("2")


def test_learn_day():
    col = getEmptyCol()
    # add a note
    note = col.newNote()
    note["Front"] = "one"
    col.addNote(note)
    note = col.newNote()
    note["Front"] = "two"
    col.addNote(note)
    col.sched.reset()
    c = col.sched.getCard()
    conf = col.sched._cardConf(c)
    conf["new"]["delays"] = [1, 10, 1440, 2880]
    col.decks.save(conf)
    # pass it
    col.sched.answerCard(c, 3)
    # two reps to graduate, 1 more today
    assert c.left % 1000 == 3
    assert col.sched.counts() == (1, 1, 0)
    c.load()
    ni = col.sched.nextIvl
    assert ni(c, 3) == 86400
    # answer the other dummy card
    col.sched.answerCard(col.sched.getCard(), 4)
    # answering the first one will place it in queue 3
    c = col.sched.getCard()
    col.sched.answerCard(c, 3)
    assert c.due == col.sched.today + 1
    assert c.queue == QUEUE_TYPE_DAY_LEARN_RELEARN
    assert not col.sched.getCard()
    # for testing, move it back a day
    c.due -= 1
    c.flush()
    col.reset()
    assert col.sched.counts() == (0, 1, 0)
    c = col.sched.getCard()
    # nextIvl should work
    assert ni(c, 3) == 86400 * 2
    # if we fail it, it should be back in the correct queue
    col.sched.answerCard(c, 1)
    assert c.queue == QUEUE_TYPE_LRN
    if is_2021():
        col.undo()
    else:
        col.undo_legacy()
    col.reset()
    c = col.sched.getCard()
    col.sched.answerCard(c, 3)
    # simulate the passing of another two days
    c.due -= 2
    c.flush()
    col.reset()
    # the last pass should graduate it into a review card
    assert ni(c, 3) == 86400
    col.sched.answerCard(c, 3)
    assert c.queue == CARD_TYPE_REV and c.type == QUEUE_TYPE_REV
    # if the lapse step is tomorrow, failing it should handle the counts
    # correctly
    c.due = 0
    c.flush()
    col.reset()
    assert col.sched.counts() == (0, 0, 1)
    conf = col.sched._cardConf(c)
    conf["lapse"]["delays"] = [1440]
    col.decks.save(conf)
    c = col.sched.getCard()
    col.sched.answerCard(c, 1)
    assert c.queue == QUEUE_TYPE_DAY_LEARN_RELEARN
    assert col.sched.counts() == (0, 0, 0)


def test_reviews():
    col = getEmptyCol()
    # add a note
    note = col.newNote()
    note["Front"] = "one"
    note["Back"] = "two"
    col.addNote(note)
    # set the card up as a review card, due 8 days ago
    c = note.cards()[0]
    c.type = CARD_TYPE_REV
    c.queue = QUEUE_TYPE_REV
    c.due = col.sched.today - 8
    c.factor = STARTING_FACTOR
    c.reps = 3
    c.lapses = 1
    c.ivl = 100
    c.start_timer()
    c.flush()
    # save it for later use as well
    cardcopy = copy.copy(c)
    # try with an ease of 2
    ##################################################
    c = copy.copy(cardcopy)
    c.flush()
    col.reset()
    col.sched.answerCard(c, 2)
    assert c.queue == QUEUE_TYPE_REV
    # the new interval should be (100) * 1.2 = 120
    checkRevIvl(col, c, 120)
    assert c.due == col.sched.today + c.ivl
    # factor should have been decremented
    assert c.factor == 2350
    # check counters
    assert c.lapses == 1
    assert c.reps == 4
    # ease 3
    ##################################################
    c = copy.copy(cardcopy)
    c.flush()
    col.sched.answerCard(c, 3)
    # the new interval should be (100 + 8/2) * 2.5 = 260
    checkRevIvl(col, c, 260)
    assert c.due == col.sched.today + c.ivl
    # factor should have been left alone
    assert c.factor == STARTING_FACTOR
    # ease 4
    ##################################################
    c = copy.copy(cardcopy)
    c.flush()
    col.sched.answerCard(c, 4)
    # the new interval should be (100 + 8) * 2.5 * 1.3 = 351
    checkRevIvl(col, c, 351)
    assert c.due == col.sched.today + c.ivl
    # factor should have been increased
    assert c.factor == 2650
    # leech handling
    ##################################################
    conf = col.decks.get_config(1)
    conf["lapse"]["leechAction"] = LEECH_SUSPEND
    col.decks.save(conf)
    c = copy.copy(cardcopy)
    c.lapses = 7
    c.flush()
    # setup hook
    hooked = []

    def onLeech(card):
        hooked.append(1)

    hooks.card_did_leech.append(onLeech)
    col.sched.answerCard(c, 1)
    if not is_2021():
        assert hooked
    assert c.queue == QUEUE_TYPE_SUSPENDED
    c.load()
    assert c.queue == QUEUE_TYPE_SUSPENDED
    assert "leech" in c.note().tags


def review_limits_setup() -> tuple[anki.collection.Collection, Dict]:
    col = getEmptyCol()

    parent = col.decks.get(col.decks.id("parent"))
    child = col.decks.get(col.decks.id("parent::child"))

    pconf = col.decks.get_config(col.decks.add_config_returning_id("parentConf"))
    cconf = col.decks.get_config(col.decks.add_config_returning_id("childConf"))

    pconf["rev"]["perDay"] = 5
    col.decks.update_config(pconf)
    col.decks.set_config_id_for_deck_dict(parent, pconf["id"])
    cconf["rev"]["perDay"] = 10
    col.decks.update_config(cconf)
    col.decks.set_config_id_for_deck_dict(child, cconf["id"])

    m = col.models.current()
    m["did"] = child["id"]
    col.models.save(m, updateReqs=False)

    # add some cards
    for i in range(20):
        note = col.newNote()
        note["Front"] = "one"
        note["Back"] = "two"
        col.addNote(note)

        # make them reviews
        c = note.cards()[0]
        c.queue = CARD_TYPE_REV
        c.type = QUEUE_TYPE_REV
        c.due = 0
        c.flush()

    return col, child


def test_review_limits():
    col, child = review_limits_setup()

    tree = col.sched.deck_due_tree().children
    # (('parent', 1514457677462, 5, 0, 0, (('child', 1514457677463, 5, 0, 0, ()),)))
    assert tree[0].review_count == 5  # parent
    assert tree[0].children[0].review_count == 10  # child

    # .counts() should match
    col.decks.select(child["id"])
    col.sched.reset()
    assert col.sched.counts() == (0, 0, 10)

    # answering a card in the child should decrement parent count
    c = col.sched.getCard()
    col.sched.answerCard(c, 3)
    assert col.sched.counts() == (0, 0, 9)

    tree = col.sched.deck_due_tree().children
    assert tree[0].review_count == 4  # parent
    assert tree[0].children[0].review_count == 9  # child


def test_button_spacing():
    col = getEmptyCol()
    note = col.newNote()
    note["Front"] = "one"
    col.addNote(note)
    # 1 day ivl review card due now
    c = note.cards()[0]
    c.type = CARD_TYPE_REV
    c.queue = QUEUE_TYPE_REV
    c.due = col.sched.today
    c.reps = 1
    c.ivl = 1
    c.start_timer()
    c.flush()
    col.reset()
    ni = col.sched.nextIvlStr
    wo = without_unicode_isolation
    assert wo(ni(c, 2)) == "2d"
    assert wo(ni(c, 3)) == "3d"
    assert wo(ni(c, 4)) == "4d"

    # if hard factor is <= 1, then hard may not increase
    conf = col.decks.config_dict_for_deck_id(1)
    conf["rev"]["hardFactor"] = 1
    col.decks.save(conf)
    assert wo(ni(c, 2)) == "1d"


def test_overdue_lapse():
    # disabled in commit 3069729776990980f34c25be66410e947e9d51a2
    return
    col = getEmptyCol()  # pylint: disable=unreachable
    # add a note
    note = col.newNote()
    note["Front"] = "one"
    col.addNote(note)
    # simulate a review that was lapsed and is now due for its normal review
    c = note.cards()[0]
    c.type = CARD_TYPE_REV
    c.queue = QUEUE_TYPE_LRN
    c.due = -1
    c.odue = -1
    c.factor = STARTING_FACTOR
    c.left = 2002
    c.ivl = 0
    c.flush()
    # checkpoint
    col.save()
    col.sched.reset()
    assert col.sched.counts() == (0, 2, 0)
    c = col.sched.getCard()
    col.sched.answerCard(c, 3)
    # it should be due tomorrow
    assert c.due == col.sched.today + 1
    # revert to before
    col.rollback()
    # with the default settings, the overdue card should be removed from the
    # learning queue
    col.sched.reset()
    assert col.sched.counts() == (0, 0, 1)


def test_nextIvl():
    col = getEmptyCol()
    note = col.newNote()
    note["Front"] = "one"
    note["Back"] = "two"
    col.addNote(note)
    col.reset()
    conf = col.decks.config_dict_for_deck_id(1)
    conf["new"]["delays"] = [0.5, 3, 10]
    conf["lapse"]["delays"] = [1, 5, 9]
    col.decks.save(conf)
    c = col.sched.getCard()
    # new cards
    ##################################################
    ni = col.sched.nextIvl
    assert ni(c, 1) == 30
    assert ni(c, 2) == (30 + 180) // 2
    assert ni(c, 3) == 180
    assert ni(c, 4) == 4 * 86400
    col.sched.answerCard(c, 1)
    # cards in learning
    ##################################################
    assert ni(c, 1) == 30
    assert ni(c, 2) == (30 + 180) // 2
    assert ni(c, 3) == 180
    assert ni(c, 4) == 4 * 86400
    col.sched.answerCard(c, 3)
    assert ni(c, 1) == 30
    assert ni(c, 2) == 180
    assert ni(c, 3) == 600
    assert ni(c, 4) == 4 * 86400
    col.sched.answerCard(c, 3)
    # normal graduation is tomorrow
    assert ni(c, 3) == 1 * 86400
    assert ni(c, 4) == 4 * 86400
    # lapsed cards
    ##################################################
    c.type = CARD_TYPE_RELEARNING
    c.ivl = 100
    c.factor = STARTING_FACTOR
    c.flush()
    assert ni(c, 1) == 60
    assert ni(c, 3) == 100 * 86400
    assert ni(c, 4) == 101 * 86400
    # review cards
    ##################################################
    c.type = CARD_TYPE_REV
    c.queue = QUEUE_TYPE_REV
    c.ivl = 100
    c.factor = STARTING_FACTOR
    c.flush()
    # failing it should put it at 60s
    assert ni(c, 1) == 60
    # or 1 day if relearn is false
    conf["lapse"]["delays"] = []
    col.decks.save(conf)
    assert ni(c, 1) == 1 * 86400
    # (* 100 1.2 86400)10368000.0
    assert ni(c, 2) == 10368000
    # (* 100 2.5 86400)21600000.0
    assert ni(c, 3) == 21600000
    # (* 100 2.5 1.3 86400)28080000.0
    assert ni(c, 4) == 28080000
    assert without_unicode_isolation(col.sched.nextIvlStr(c, 4)) == "10.8mo"


def test_bury():
    col = getEmptyCol()
    note = col.newNote()
    note["Front"] = "one"
    col.addNote(note)
    c = note.cards()[0]
    note = col.newNote()
    note["Front"] = "two"
    col.addNote(note)
    c2 = note.cards()[0]
    # burying
    col.sched.bury_cards([c.id], manual=True)  # pylint: disable=unexpected-keyword-arg
    c.load()
    assert c.queue == QUEUE_TYPE_MANUALLY_BURIED
    col.sched.bury_cards(
        [c2.id], manual=False
    )  # pylint: disable=unexpected-keyword-arg
    c2.load()
    assert c2.queue == QUEUE_TYPE_SIBLING_BURIED

    col.reset()
    assert not col.sched.getCard()

    col.sched.unbury_deck(deck_id=col.decks.get_current_id(), mode=UnburyDeck.USER_ONLY)
    c.load()
    assert c.queue == QUEUE_TYPE_NEW
    c2.load()
    assert c2.queue == QUEUE_TYPE_SIBLING_BURIED

    col.sched.unbury_deck(
        deck_id=col.decks.get_current_id(), mode=UnburyDeck.SCHED_ONLY
    )
    c2.load()
    assert c2.queue == QUEUE_TYPE_NEW

    col.sched.bury_cards([c.id, c2.id])
    col.sched.unbury_deck(deck_id=col.decks.get_current_id())

    col.reset()

    assert col.sched.counts() == (2, 0, 0)


def test_suspend():
    col = getEmptyCol()
    note = col.newNote()
    note["Front"] = "one"
    col.addNote(note)
    c = note.cards()[0]
    # suspending
    col.reset()
    assert col.sched.getCard()
    col.sched.suspend_cards([c.id])
    col.reset()
    assert not col.sched.getCard()
    # unsuspending
    col.sched.unsuspend_cards([c.id])
    col.reset()
    assert col.sched.getCard()
    # should cope with rev cards being relearnt
    c.due = 0
    c.ivl = 100
    c.type = CARD_TYPE_REV
    c.queue = QUEUE_TYPE_REV
    c.flush()
    col.reset()
    c = col.sched.getCard()
    col.sched.answerCard(c, 1)
    assert c.due >= time.time()
    due = c.due
    assert c.queue == QUEUE_TYPE_LRN
    assert c.type == CARD_TYPE_RELEARNING
    col.sched.suspend_cards([c.id])
    col.sched.unsuspend_cards([c.id])
    c.load()
    assert c.queue == QUEUE_TYPE_LRN
    assert c.type == CARD_TYPE_RELEARNING
    assert c.due == due
    # should cope with cards in cram decks
    c.due = 1
    c.flush()
    did = col.decks.new_filtered("tmp")
    col.sched.rebuild_filtered_deck(did)
    c.load()
    assert c.due != 1
    assert c.did != 1
    col.sched.suspend_cards([c.id])
    c.load()
    assert c.due != 1
    assert c.did != 1
    assert c.odue == 1


def test_filt_reviewing_early_normal():
    def to_int(val: float) -> int:
        if is_2021():
            return round(val)
        else:
            return int(val)

    col = getEmptyCol()
    note = col.newNote()
    note["Front"] = "one"
    col.addNote(note)
    c = note.cards()[0]
    c.ivl = 100
    c.queue = CARD_TYPE_REV
    c.type = QUEUE_TYPE_REV
    # due in 25 days, so it's been waiting 75 days
    c.due = col.sched.today + 25
    c.mod = 1
    c.factor = STARTING_FACTOR
    c.start_timer()
    c.flush()
    col.reset()
    assert col.sched.counts() == (0, 0, 0)
    # create a dynamic deck and refresh it
    did = col.decks.new_filtered("Cram")
    col.sched.rebuild_filtered_deck(did)
    col.reset()
    # should appear as normal in the deck list
    assert sorted(col.sched.deck_due_tree().children)[0].review_count == 1
    # and should appear in the counts
    assert col.sched.counts() == (0, 0, 1)
    # grab it and check estimates
    c = col.sched.getCard()
    assert col.sched.answerButtons(c) == 4
    assert col.sched.nextIvl(c, 1) == 600
    assert col.sched.nextIvl(c, 2) == to_int(75 * 1.2) * 86400
    assert col.sched.nextIvl(c, 3) == to_int(75 * 2.5) * 86400
    assert col.sched.nextIvl(c, 4) == to_int(75 * 2.5 * 1.15) * 86400

    # answer 'good'
    col.sched.answerCard(c, 3)
    checkRevIvl(col, c, int(75 * 2.5))
    assert c.due == col.sched.today + c.ivl
    assert not c.odue
    # should not be in learning
    assert c.queue == QUEUE_TYPE_REV
    # should be logged as a cram rep
    assert col.db.scalar("select type from revlog order by id desc limit 1") == 3

    # due in 75 days, so it's been waiting 25 days
    c.ivl = 100
    c.due = col.sched.today + 75
    c.flush()
    col.sched.rebuild_filtered_deck(did)
    col.reset()
    c = col.sched.getCard()

    assert col.sched.nextIvl(c, 2) == 100 * 1.2 / 2 * 86400
    assert col.sched.nextIvl(c, 3) == 100 * 86400
    assert col.sched.nextIvl(c, 4) == to_int(100 * (1.3 - (1.3 - 1) / 2)) * 86400


def test_filt_keep_lrn_state():
    col = getEmptyCol()

    note = col.newNote()
    note["Front"] = "one"
    col.addNote(note)

    # fail the card outside filtered deck
    c = col.sched.getCard()
    conf = col.sched._cardConf(c)
    conf["new"]["delays"] = [1, 10, 61]
    col.decks.save(conf)

    col.sched.answerCard(c, 1)

    assert c.type == CARD_TYPE_LRN and c.queue == QUEUE_TYPE_LRN
    assert c.left % 1000 == 3

    col.sched.answerCard(c, 3)
    assert c.type == CARD_TYPE_LRN and c.queue == QUEUE_TYPE_LRN

    # create a dynamic deck and refresh it
    did = col.decks.new_filtered("Cram")
    col.sched.rebuild_filtered_deck(did)
    col.reset()

    # card should still be in learning state
    c.load()
    assert c.type == CARD_TYPE_LRN and c.queue == QUEUE_TYPE_LRN
    assert c.left % 1000 == 2

    # should be able to advance learning steps
    col.sched.answerCard(c, 3)
    # should be due at least an hour in the future
    assert c.due - int_time() > 60 * 60

    # emptying the deck preserves learning state
    col.sched.empty_filtered_deck(did)
    c.load()
    assert c.type == CARD_TYPE_LRN and c.queue == QUEUE_TYPE_LRN
    assert c.left % 1000 == 1
    assert c.due - int_time() > 60 * 60


def test_preview():
    # add cards
    col = getEmptyCol()
    note = col.newNote()
    note["Front"] = "one"
    col.addNote(note)
    c = note.cards()[0]
    orig = copy.copy(c)
    note2 = col.newNote()
    note2["Front"] = "two"
    col.addNote(note2)
    # cram deck
    did = col.decks.new_filtered("Cram")
    cram = col.decks.get(did)
    cram["resched"] = False
    col.decks.save(cram)
    col.sched.rebuild_filtered_deck(did)
    col.reset()
    # grab the first card
    c = col.sched.getCard()

    if is_2021():
        passing_grade = 4
    else:
        passing_grade = 2

    assert col.sched.answerButtons(c) == passing_grade
    assert col.sched.nextIvl(c, 1) == 600
    assert col.sched.nextIvl(c, passing_grade) == 0

    # failing it will push its due time back
    due = c.due
    col.sched.answerCard(c, 1)
    assert c.due != due

    # the other card should come next
    c2 = col.sched.getCard()
    assert c2.id != c.id

    # passing it will remove it
    col.sched.answerCard(c2, passing_grade)
    assert c2.queue == QUEUE_TYPE_NEW
    assert c2.reps == 0
    assert c2.type == CARD_TYPE_NEW

    # the other card should appear again
    c = col.sched.getCard()
    assert c.id == orig.id

    # emptying the filtered deck should restore card
    col.sched.empty_filtered_deck(did)
    c.load()
    assert c.queue == QUEUE_TYPE_NEW
    assert c.reps == 0
    assert c.type == CARD_TYPE_NEW


def test_ordcycle():
    col = getEmptyCol()
    # add two more templates and set second active
    m = col.models.current()
    mm = col.models
    t = mm.new_template("Reverse")
    t["qfmt"] = "{{Back}}"
    t["afmt"] = "{{Front}}"
    mm.add_template(m, t)
    t = mm.new_template("f2")
    t["qfmt"] = "{{Front}}2"
    t["afmt"] = "{{Back}}"
    mm.add_template(m, t)
    mm.save(m)
    # create a new note; it should have 3 cards
    note = col.newNote()
    note["Front"] = "1"
    note["Back"] = "1"
    col.addNote(note)
    assert col.card_count() == 3

    conf = col.decks.get_config(1)
    conf["new"]["bury"] = False
    col.decks.save(conf)
    col.reset()

    # ordinals should arrive in order
    for i in range(3):
        c = col.sched.getCard()
        assert c.ord == i
        col.sched.answerCard(c, 4)


def test_counts_idx():
    if is_2021():
        pytest.skip("old sched only")
    col = getEmptyCol()
    note = col.newNote()
    note["Front"] = "one"
    note["Back"] = "two"
    col.addNote(note)
    col.reset()
    assert col.sched.counts() == (1, 0, 0)
    c = col.sched.getCard()
    # counter's been decremented but idx indicates 1
    assert col.sched.counts() == (0, 0, 0)
    assert col.sched.countIdx(c) == 0
    # answer to move to learn queue
    col.sched.answerCard(c, 1)
    assert col.sched.counts() == (0, 1, 0)
    # fetching again will decrement the count
    c = col.sched.getCard()
    assert col.sched.counts() == (0, 0, 0)
    assert col.sched.countIdx(c) == 1
    # answering should add it back again
    col.sched.answerCard(c, 1)
    assert col.sched.counts() == (0, 1, 0)


def test_counts_idx_new():
    if not is_2021():
        pytest.skip("new sched only")
    col = getEmptyCol()
    note = col.newNote()
    note["Front"] = "one"
    note["Back"] = "two"
    col.addNote(note)
    note = col.newNote()
    note["Front"] = "two"
    note["Back"] = "two"
    col.addNote(note)
    col.reset()
    assert col.sched.counts() == (2, 0, 0)
    c = col.sched.getCard()
    # getCard does not decrement counts
    assert col.sched.counts() == (2, 0, 0)
    assert col.sched.countIdx(c) == 0
    # answer to move to learn queue
    col.sched.answerCard(c, 1)
    assert col.sched.counts() == (1, 1, 0)
    assert col.sched.countIdx(c) == 1
    # fetching next will not decrement the count
    c = col.sched.getCard()
    assert col.sched.counts() == (1, 1, 0)
    assert col.sched.countIdx(c) == 0


def test_repCounts():
    col = getEmptyCol()
    note = col.newNote()
    note["Front"] = "one"
    col.addNote(note)
    note = col.newNote()
    note["Front"] = "two"
    col.addNote(note)
    col.reset()
    # lrnReps should be accurate on pass/fail
    assert col.sched.counts() == (2, 0, 0)
    col.sched.answerCard(col.sched.getCard(), 1)
    assert col.sched.counts() == (1, 1, 0)
    col.sched.answerCard(col.sched.getCard(), 1)
    assert col.sched.counts() == (0, 2, 0)
    col.sched.answerCard(col.sched.getCard(), 3)
    assert col.sched.counts() == (0, 2, 0)
    col.sched.answerCard(col.sched.getCard(), 1)
    assert col.sched.counts() == (0, 2, 0)
    col.sched.answerCard(col.sched.getCard(), 3)
    assert col.sched.counts() == (0, 1, 0)
    col.sched.answerCard(col.sched.getCard(), 4)
    assert col.sched.counts() == (0, 0, 0)
    note = col.newNote()
    note["Front"] = "three"
    col.addNote(note)
    note = col.newNote()
    note["Front"] = "four"
    col.addNote(note)
    col.reset()
    # initial pass and immediate graduate should be correct too
    assert col.sched.counts() == (2, 0, 0)
    col.sched.answerCard(col.sched.getCard(), 3)
    assert col.sched.counts() == (1, 1, 0)
    col.sched.answerCard(col.sched.getCard(), 4)
    assert col.sched.counts() == (0, 1, 0)
    col.sched.answerCard(col.sched.getCard(), 4)
    assert col.sched.counts() == (0, 0, 0)
    # and failing a review should too
    note = col.newNote()
    note["Front"] = "five"
    col.addNote(note)
    c = note.cards()[0]
    c.type = CARD_TYPE_REV
    c.queue = QUEUE_TYPE_REV
    c.due = col.sched.today
    c.flush()
    note = col.newNote()
    note["Front"] = "six"
    col.addNote(note)
    col.reset()
    assert col.sched.counts() == (1, 0, 1)
    col.sched.answerCard(col.sched.getCard(), 1)
    assert col.sched.counts() == (1, 1, 0)


def test_timing():
    col = getEmptyCol()
    # add a few review cards, due today
    for i in range(5):
        note = col.newNote()
        note["Front"] = f"num{str(i)}"
        col.addNote(note)
        c = note.cards()[0]
        c.type = CARD_TYPE_REV
        c.queue = QUEUE_TYPE_REV
        c.due = 0
        c.flush()
    # fail the first one
    col.reset()
    c = col.sched.getCard()
    col.sched.answerCard(c, 1)
    # the next card should be another review
    c2 = col.sched.getCard()
    assert c2.queue == QUEUE_TYPE_REV
    # if the failed card becomes due, it should show first
    c.due = int_time() - 1
    c.flush()
    col.reset()
    c = col.sched.getCard()
    assert c.queue == QUEUE_TYPE_LRN


def test_collapse():
    col = getEmptyCol()
    # add a note
    note = col.newNote()
    note["Front"] = "one"
    col.addNote(note)
    # and another, so we don't get the same twice in a row
    note = col.newNote()
    note["Front"] = "two"
    col.addNote(note)
    col.reset()
    # first note
    c = col.sched.getCard()
    col.sched.answerCard(c, 1)
    # second note
    c2 = col.sched.getCard()
    assert c2.nid != c.nid
    col.sched.answerCard(c2, 1)
    # first should become available again, despite it being due in the future
    c3 = col.sched.getCard()
    assert c3.due > int_time()
    col.sched.answerCard(c3, 4)
    # answer other
    c4 = col.sched.getCard()
    col.sched.answerCard(c4, 4)
    assert not col.sched.getCard()


def test_deckDue():
    col = getEmptyCol()
    # add a note with default deck
    note = col.newNote()
    note["Front"] = "one"
    col.addNote(note)
    # and one that's a child
    note = col.newNote()
    note["Front"] = "two"
    default1 = note.note_type()["did"] = col.decks.id("Default::1")
    col.addNote(note)
    # make it a review card
    c = note.cards()[0]
    c.queue = QUEUE_TYPE_REV
    c.due = 0
    c.flush()
    # add one more with a new deck
    note = col.newNote()
    note["Front"] = "two"
    note.note_type()["did"] = col.decks.id("foo::bar")
    col.addNote(note)
    # and one that's a sibling
    note = col.newNote()
    note["Front"] = "three"
    note.note_type()["did"] = col.decks.id("foo::baz")
    col.addNote(note)
    col.reset()
    assert len(col.decks.all_names_and_ids()) == 5
    tree = col.sched.deck_due_tree().children
    assert tree[0].name == "Default"
    # sum of child and parent
    assert tree[0].deck_id == 1
    assert tree[0].review_count == 1
    assert tree[0].new_count == 1
    # child count is just review
    child = tree[0].children[0]
    assert child.name == "1"
    assert child.deck_id == default1
    assert child.review_count == 1
    assert child.new_count == 0
    # code should not fail if a card has an invalid deck
    c.did = 12345
    c.flush()
    col.sched.deck_due_tree()


def test_deckTree():
    col = getEmptyCol()
    col.decks.id("new::b::c")
    col.decks.id("new2")
    # new should not appear twice in tree
    names = [x.name for x in col.sched.deck_due_tree().children]
    names.remove("new")
    assert "new" not in names


def test_deckFlow():
    col = getEmptyCol()
    # add a note with default deck
    note = col.newNote()
    note["Front"] = "one"
    col.addNote(note)
    # and one that's a child
    note = col.newNote()
    note["Front"] = "two"
    note.note_type()["did"] = col.decks.id("Default::2")
    col.addNote(note)
    # and another that's higher up
    note = col.newNote()
    note["Front"] = "three"
    default1 = note.note_type()["did"] = col.decks.id("Default::1")
    col.addNote(note)
    col.reset()
    assert col.sched.counts() == (3, 0, 0)
    # should get top level one first, then ::1, then ::2
    for i in "one", "three", "two":
        c = col.sched.getCard()
        assert c.note()["Front"] == i
        col.sched.answerCard(c, 3)


def test_reorder():
    col = getEmptyCol()
    # add a note with default deck
    note = col.newNote()
    note["Front"] = "one"
    col.addNote(note)
    note2 = col.newNote()
    note2["Front"] = "two"
    col.addNote(note2)
    assert note2.cards()[0].due == 2
    found = False
    # 50/50 chance of being reordered
    for i in range(20):
        col.sched.randomize_cards(1)
        if note.cards()[0].due != note.id:
            found = True
            break
    assert found
    col.sched.order_cards(1)
    assert note.cards()[0].due == 1
    # shifting
    note3 = col.newNote()
    note3["Front"] = "three"
    col.addNote(note3)
    note4 = col.newNote()
    note4["Front"] = "four"
    col.addNote(note4)
    assert note.cards()[0].due == 1
    assert note2.cards()[0].due == 2
    assert note3.cards()[0].due == 3
    assert note4.cards()[0].due == 4
    col.sched.reposition_new_cards(
        [note3.cards()[0].id, note4.cards()[0].id],
        starting_from=1,
        shift_existing=True,
        step_size=1,
        randomize=False,
    )
    assert note.cards()[0].due == 3
    assert note2.cards()[0].due == 4
    assert note3.cards()[0].due == 1
    assert note4.cards()[0].due == 2


def test_forget():
    col = getEmptyCol()
    note = col.newNote()
    note["Front"] = "one"
    col.addNote(note)
    c = note.cards()[0]
    c.queue = QUEUE_TYPE_REV
    c.type = CARD_TYPE_REV
    c.ivl = 100
    c.due = 0
    c.flush()
    col.reset()
    assert col.sched.counts() == (0, 0, 1)
    col.sched.forgetCards([c.id])
    col.reset()
    assert col.sched.counts() == (1, 0, 0)


def test_resched():
    col = getEmptyCol()
    note = col.newNote()
    note["Front"] = "one"
    col.addNote(note)
    c = note.cards()[0]
    col.sched.set_due_date([c.id], "0")
    c.load()
    assert c.due == col.sched.today
    assert c.ivl == 1
    assert c.queue == QUEUE_TYPE_REV and c.type == CARD_TYPE_REV
    # make it due tomorrow
    col.sched.set_due_date([c.id], "1")
    c.load()
    assert c.due == col.sched.today + 1
    assert c.ivl == 1


def test_norelearn():
    col = getEmptyCol()
    # add a note
    note = col.newNote()
    note["Front"] = "one"
    col.addNote(note)
    c = note.cards()[0]
    c.type = CARD_TYPE_REV
    c.queue = QUEUE_TYPE_REV
    c.due = 0
    c.factor = STARTING_FACTOR
    c.reps = 3
    c.lapses = 1
    c.ivl = 100
    c.start_timer()
    c.flush()
    col.reset()
    col.sched.answerCard(c, 1)
    col.sched._cardConf(c)["lapse"]["delays"] = []
    col.sched.answerCard(c, 1)


def test_failmult():
    col = getEmptyCol()
    note = col.newNote()
    note["Front"] = "one"
    note["Back"] = "two"
    col.addNote(note)
    c = note.cards()[0]
    c.type = CARD_TYPE_REV
    c.queue = QUEUE_TYPE_REV
    c.ivl = 100
    c.due = col.sched.today - c.ivl
    c.factor = STARTING_FACTOR
    c.reps = 3
    c.lapses = 1
    c.start_timer()
    c.flush()
    conf = col.sched._cardConf(c)
    conf["lapse"]["mult"] = 0.5
    col.decks.save(conf)
    c = col.sched.getCard()
    col.sched.answerCard(c, 1)
    assert c.ivl == 50
    col.sched.answerCard(c, 1)
    assert c.ivl == 25


# cards with a due date earlier than the collection should retain
# their due date when removed
def test_negativeDueFilter():
    col = getEmptyCol()

    # card due prior to collection date
    note = col.newNote()
    note["Front"] = "one"
    note["Back"] = "two"
    col.addNote(note)
    c = note.cards()[0]
    c.due = -5
    c.queue = QUEUE_TYPE_REV
    c.ivl = 5
    c.flush()

    # into and out of filtered deck
    did = col.decks.new_filtered("Cram")
    col.sched.rebuild_filtered_deck(did)
    col.sched.empty_filtered_deck(did)
    col.reset()

    c.load()
    assert c.due == -5


# hard on the first step should be the average of again and good,
# and it should be logged properly
def test_initial_repeat():
    col = getEmptyCol()
    note = col.newNote()
    note["Front"] = "one"
    note["Back"] = "two"
    col.addNote(note)

    col.reset()
    c = col.sched.getCard()
    col.sched.answerCard(c, 2)
    # should be due in ~ 5.5 mins
    expected = time.time() + 5.5 * 60
    assert expected - 10 < c.due < expected * 1.25

    ivl = col.db.scalar("select ivl from revlog")
    assert ivl == -5.5 * 60
