# test undo and redo
# Derek Fujimoto
# Mar 2026

from fixtures import *
import time, os
import pandas as pd


def database_path(client, name):
    """Absolute path to a database file in the current state directory

    Built from __file__, not the working directory: an "os.path.exists('../data/...')"
    check silently passes from anywhere else, which would make every "the file is gone"
    assertion vacuous.

    Args:
        client: midas client
        name (str): database file name, ex: 'shift_database.csv'
    """
    current_state = client.odb_get("/Shifts/Variables/stateid")
    path = os.path.dirname(os.path.abspath(__file__))
    return os.path.join(path, '..', 'data', str(current_state), name)


# check that undo button is disabled on a fresh session with no changes
def test_undo_button_disabled_at_start(page, users_added):

    undo_button = by_id(page, 'undo_button')
    @try_again
    def check():
        assert undo_button.is_disabled() == True, \
            f"Expected undo button to be disabled on a fresh session with no changes, but it was enabled"
    check()

# check that the redo button tracks whether a future state exists
def test_redo_button_disabled_at_end(page, users_added):

    redo_button = by_id(page, 'redo_button')

    # make one assignment to create a state
    assign(page, 0, 1, 1)

    # asserting "disabled" on its own would pass on a fresh page, where redo is already
    # disabled, so drive it enabled first and back again
    @try_again
    def check_disabled_before_undo():
        assert redo_button.is_disabled() == True, \
            f"Expected redo button to be disabled after a single action with no future states, but it was enabled"
    check_disabled_before_undo()

    by_id(page, 'undo_button').click()

    @try_again
    def check_enabled_after_undo():
        assert redo_button.is_disabled() == False, \
            f"Expected redo button to be enabled after undoing, since there is now a future state, but it was disabled"
    check_enabled_after_undo()

    redo_button.click()

    @try_again
    def check_disabled_after_redo():
        assert redo_button.is_disabled() == True, \
            f"Expected redo button to be disabled again after redoing back to the newest state, but it was enabled"
    check_disabled_after_redo()

# check that clicking undo after an assignment removes it from the database and UI
def test_undo_assignment(page, client, users_added):

    # assign test0 to a cell
    assign(page, 0, 1, 1)
    time.sleep(0.05)

    # retry: the click has to reach the python client and the write has to land on disk,
    # which a fixed sleep does not reliably cover
    @try_again
    def wait_written():
        assert os.path.exists(database_path(client, 'shift_database.csv')), \
            f"Expected shift_database.csv to exist at " \
            f"'{database_path(client, 'shift_database.csv')}' after assignment, but it was not found"
    wait_written()

    # undo
    by_id(page, 'undo_button').click()
    time.sleep(0.1)

    @try_again
    def check():

        # database entry should be gone
        current_state = client.odb_get("/Shifts/Variables/stateid")
        assert not os.path.exists(database_path(client, 'shift_database.csv')), \
            f"Expected shift_database.csv to be removed after undoing assignment, but it still exists at {database_path(client, 'shift_database.csv')}"
    check()

    # checkbox in the UI should be unchecked
    by_id(page, 'cell_1_1').click()

    @try_again
    def check1():
        checkbox = by_id(page, 'test0_1_1_.')
        assert checkbox.is_checked() == False, \
            f"Expected test0 checkbox at (row 1, col 1) to be unchecked after undo, but it was still checked"

        # undo button should now be disabled (back to initial state)
        undo_button = by_id(page, 'undo_button')
        assert undo_button.is_disabled() == True, \
            f"Expected undo button to be disabled after undoing the only action, but it was still enabled"

        # redo button should now be enabled
        redo_button = by_id(page, 'redo_button')
        assert redo_button.is_disabled() == False, \
            f"Expected redo button to be enabled after undoing an action, but it was disabled"
    check1()

# check that clicking redo after undo restores the assignment
def test_redo_assignment(page, client, users_added):

    # assign and then undo
    assign(page, 0, 1, 1)
    time.sleep(0.05)
    by_id(page, 'undo_button').click()
    time.sleep(0.1)

    @try_again
    def check():
        current_state = client.odb_get("/Shifts/Variables/stateid")
        assert not os.path.exists(database_path(client, 'shift_database.csv')), \
            f"Expected shift_database.csv to be absent after undo, but it exists at {database_path(client, 'shift_database.csv')}"
    check()

    # redo
    by_id(page, 'redo_button').click()
    time.sleep(0.1)

    @try_again
    def check1():

        # database entry should be restored
        current_state = client.odb_get("/Shifts/Variables/stateid")
        assert os.path.exists(database_path(client, 'shift_database.csv')), \
            f"Expected shift_database.csv to be restored after redo, but it was not found at {database_path(client, 'shift_database.csv')}"

        df = pd.read_csv(database_path(client, 'shift_database.csv'))
        assert len(df.name) == 1, \
            f"Expected 1 row in database after redo, got {len(df.name)}: {df.name.tolist()}"
        assert df.name[0] == 'test0', \
            f"Expected restored assignment to be 'test0', got '{df.name[0]}'"
    check1()

    # checkbox in the UI should be checked again
    by_id(page, 'cell_1_1').click()

    @try_again
    def check2():
        checkbox = by_id(page, 'test0_1_1_.')
        assert checkbox.is_checked() == True, \
            f"Expected test0 checkbox at (row 1, col 1) to be checked after redo, but it was unchecked"

        # redo button should now be disabled again (no future states)
        redo_button = by_id(page, 'redo_button')
        assert redo_button.is_disabled() == True, \
            f"Expected redo button to be disabled after redoing the only undone action, but it was still enabled"
    check2()

# check that clicking undo after setting availability clears the avail cell and database
def test_undo_avail(page, client, users_added):

    # set availability
    select_user(page, 'test0')
    by_id(page, 'availcell_1_1').click()
    time.sleep(0.05)

    # wait for the write to land, but give up rather than spinning forever: an unbounded
    # loop here hangs the whole suite with no diagnostic if the write never happens
    @try_again
    def wait_written():
        assert os.path.exists(database_path(client, 'avail_database.csv')), \
            f"Timed out waiting for avail_database.csv to be written at " \
            f"'{database_path(client, 'avail_database.csv')}'"
    wait_written()

    # undo
    by_id(page, 'undo_button').click()
    time.sleep(0.1)

    @try_again
    def check():

        # avail database entry should be gone
        current_state = client.odb_get("/Shifts/Variables/stateid")
        assert not os.path.exists(database_path(client, 'avail_database.csv')), \
            f"Expected avail_database.csv to be removed after undoing availability set, but it still exists at {database_path(client, 'avail_database.csv')}"

        # cell color should be cleared
        target_color = page.evaluate('COLOR_AVAILBLE')
        target_color = target_color.lower()

        avail_cell = by_id(page, 'availcell_1_1')
        color = conftest.rgb2hex(css_value(avail_cell, 'background-color'))
        assert color != target_color, \
            f"Expected availcell at (row 1, col 1) to be cleared (not COLOR_AVAILBLE '{target_color}') after undo, got '{color}'"

    check()

# check that clicking undo after writing a note removes it from the database
def test_undo_note(page, client, users_added):

    # write a note
    write_note(page, 1, 'schedule', 'undo test note')
    time.sleep(0.05)

    # wait for the write to land, but give up rather than spinning forever: an unbounded
    # loop here hangs the whole suite with no diagnostic if the write never happens
    @try_again
    def wait_written():
        assert os.path.exists(database_path(client, 'notes_database.csv')), \
            f"Timed out waiting for notes_database.csv to be written at " \
            f"'{database_path(client, 'notes_database.csv')}'"
    wait_written()

    # undo
    by_id(page, 'undo_button').click()
    time.sleep(0.1)

    @try_again
    def check():

        # notes database should be gone
        current_state = client.odb_get("/Shifts/Variables/stateid")
        assert not os.path.exists(database_path(client, 'notes_database.csv')), \
            f"Expected notes_database.csv to be removed after undoing note write, but it still exists at {database_path(client, 'notes_database.csv')}"

        # note label in the UI should be cleared
        label = by_id(page, 'schedule_note_label_1')
        assert label.inner_html() == '', \
            f"Expected schedule note label at col 1 to be empty after undo, got '{label.inner_html()}'"
    check()

# check that a chain of 3 actions can be fully undone and then fully redone,
# with the correct state verified at each step
def test_undo_redo_chain(page, client, users_added):

    # action 1: assign test0 to (row 1, col 1)
    assign(page, 0, 1, 1)
    time.sleep(0.05)
    state1 = client.odb_get("/Shifts/Variables/stateid")

    # action 2: assign test1 to (row 1, col 2)
    assign(page, 1, 1, 2)
    time.sleep(0.05)
    state2 = client.odb_get("/Shifts/Variables/stateid")

    # action 3: assign test2 to (row 1, col 3)
    assign(page, 2, 1, 3)
    time.sleep(0.05)
    state3 = client.odb_get("/Shifts/Variables/stateid")

    @try_again
    def check():
        # not "state1 != state2 != state3": python reads that as a chained comparison and
        # never checks state1 against state3, so an allocator cycling A, B, A would pass
        assert len({state1, state2, state3}) == 3, \
            f"Expected each action to produce a new state ID, got state1={state1}, state2={state2}, state3={state3}"
    check()

    # --- undo all 3 ---

    # undo action 3
    by_id(page, 'undo_button').click()
    time.sleep(0.1)
    current_state = client.odb_get("/Shifts/Variables/stateid")

    @try_again
    def check2():
        df = pd.read_csv(database_path(client, 'shift_database.csv'))
        assert len(df.name) == 2, \
            f"Expected 2 assignments after undoing action 3, got {len(df.name)}: {df.name.tolist()}"
        assert 'test2' not in df.name.tolist(), \
            f"Expected test2 to be absent after undoing action 3, got {df.name.tolist()}"
    check2()

    # undo action 2
    by_id(page, 'undo_button').click()
    time.sleep(0.1)
    current_state = client.odb_get("/Shifts/Variables/stateid")

    @try_again
    def check3():
        df = pd.read_csv(database_path(client, 'shift_database.csv'))
        assert len(df.name) == 1, \
            f"Expected 1 assignment after undoing action 2, got {len(df.name)}: {df.name.tolist()}"
        assert df.name[0] == 'test0', \
            f"Expected only test0 to remain after undoing action 2, got {df.name.tolist()}"
    check3()

    # undo action 1
    by_id(page, 'undo_button').click()
    time.sleep(0.1)
    current_state = client.odb_get("/Shifts/Variables/stateid")

    @try_again
    def check4():
        assert not os.path.exists(database_path(client, 'shift_database.csv')), \
            f"Expected shift_database.csv to be absent after undoing all 3 actions, but it exists at {database_path(client, 'shift_database.csv')}"

        # undo button should now be disabled
        assert by_id(page, 'undo_button').is_disabled() == True, \
            f"Expected undo button to be disabled after undoing all actions, but it was enabled"
    check4()

    # --- redo all 3 ---

    # redo action 1
    by_id(page, 'redo_button').click()
    time.sleep(0.1)
    current_state = client.odb_get("/Shifts/Variables/stateid")

    @try_again
    def check5():
        df = pd.read_csv(database_path(client, 'shift_database.csv'))
        assert len(df.name) == 1, \
            f"Expected 1 assignment after redoing action 1, got {len(df.name)}: {df.name.tolist()}"
        assert df.name[0] == 'test0', \
        f"Expected test0 to be restored after redoing action 1, got {df.name.tolist()}"
    check5()

    # redo action 2
    by_id(page, 'redo_button').click()
    time.sleep(0.1)

    @try_again
    def check6():
        current_state = client.odb_get("/Shifts/Variables/stateid")
        df = pd.read_csv(database_path(client, 'shift_database.csv'))
        assert len(df.name) == 2, \
            f"Expected 2 assignments after redoing action 2, got {len(df.name)}: {df.name.tolist()}"
        assert 'test1' in df.name.tolist(), \
            f"Expected test1 to be restored after redoing action 2, got {df.name.tolist()}"
    check6()

    # redo action 3
    by_id(page, 'redo_button').click()
    time.sleep(0.1)

    @try_again
    def check7():
        current_state = client.odb_get("/Shifts/Variables/stateid")
        df = pd.read_csv(database_path(client, 'shift_database.csv'))
        assert len(df.name) == 3, \
            f"Expected 3 assignments after redoing action 3, got {len(df.name)}: {df.name.tolist()}"
        assert 'test2' in df.name.tolist(), \
            f"Expected test2 to be restored after redoing action 3, got {df.name.tolist()}"

        # redo button should now be disabled again
        assert by_id(page, 'redo_button').is_disabled() == True, \
            f"Expected redo button to be disabled after redoing all actions, but it was enabled"
    check7()

# making a change after undoing discards the redo stack: copy_state calls
# delete_states_future, which removes those state directories from disk too
def test_history_cleared_on_change_after_undo(page, client, users_added):

    assign(page, 0, 1, 1)
    assign(page, 1, 1, 2)

    state_before_undo = client.odb_get("/Shifts/Variables/stateid")

    # count the state directories: abandoning a branch must not leave one behind. note the
    # discarded id gets reused by the next change, so the directory itself reappearing is
    # expected - it is the count that shows whether the old branch was cleaned up
    data_dir = os.path.dirname(os.path.dirname(database_path(client, 'shift_database.csv')))
    ndirs_before = len(os.listdir(data_dir))

    # step back one
    by_id(page, 'undo_button').click()

    @try_again
    def check_undone():
        assert client.odb_get("/Shifts/Variables/stateid") != state_before_undo, \
            "Expected the state id to move back after undoing"
        assert by_id(page, 'redo_button').is_disabled() == False, \
            "Expected redo to be available immediately after an undo"
    check_undone()

    # now make a different change, which should throw the redo away
    assign(page, 2, 1, 3)

    @try_again
    def check_cleared():
        assert by_id(page, 'redo_button').is_disabled() == True, \
            "Expected redo to be disabled after making a change following an undo"

        # the abandoned branch's directory is deleted before the new one is written, so
        # the count is unchanged. without the cleanup it would have grown by one
        ndirs_after = len(os.listdir(data_dir))
        assert ndirs_after == ndirs_before, \
            f"Expected the abandoned future state to be deleted, leaving {ndirs_before} " \
            f"state directories, got {ndirs_after}: {sorted(os.listdir(data_dir))}"
    check_cleared()

    # and the schedule reflects the new branch, not the abandoned one
    @try_again
    def check_contents():
        names = sorted(pd.read_csv(database_path(client, 'shift_database.csv')).name.tolist())
        assert names == ['test0', 'test2'], \
            f"Expected test0 and test2 assigned on the new branch, got {names}"
    check_contents()
