import pytest
import midas, midas.client
from playwright.sync_api import expect
import time
import conftest
import sys
import functools

sys.path.append('..')
from setup_odb import setup_odb

# global settings
url = 'http://localhost:8080'

# how long to allow for a database write to come back. generous on purpose: the round trip
# includes Scheduler.copy_state, which copies every file in the state directory
WRITE_TIMEOUT_MS = 10000

def by_id(page, element_id):
    """Locator matching an element by its exact id

    Used in place of a '#id' css selector because ids generated by the page contain
    characters css treats specially: the default role is '.' (ex: 'test0_1_1_.') and the
    midas program buttons contain a space (ex: 'start shiftschedule')
    """
    return page.locator(f'[id="{element_id}"]')

def css_value(locator, prop):
    """Computed value of a css property, as the string the browser reports"""
    return locator.evaluate("(el, p) => getComputedStyle(el).getPropertyValue(p)", prop)

# decorator to try a function repeatedly until timeout, then raise the last error raised
# useful because the webpage often takes a bit to load, we want to give it time, while also
# not delaying the execution of the test if the page is faster
def try_again(fn=None, timeout_s=5, sleep_s=0.5):
    def decorator(fhandle):

        @functools.wraps(fhandle) # passes docstring through the outer decorator
        def wrapper(*args, **kwargs):
            t0 = time.time()
            error = None
            while time.time()-t0 < timeout_s:
                try:
                    return fhandle(*args, **kwargs)
                except Exception as err:
                    error = err
                    time.sleep(sleep_s)

            # raise the last error. the loop condition is true on entry, so the body always
            # runs at least once, and reaching here means it never returned
            raise error from None

        return wrapper

    # if no arguments then do a normal decoration
    if fn:
        return decorator(fn)

    return decorator

def select_user(page, name):
    by_id(page, 'avail_select').select_option(name)
    time.sleep(0.5)

def select_role(page, name):
    """Choose a role, and run the handler that shows the matching dropdown entries"""

    by_id(page, 'roles_select').select_option(name)

def click_and_drag(page, path):
    """path: [(row, col)], cells to drag over"""
    cells = [by_id(page, f'availcell_{row}_{col}') for row, col in path]

    # the first cell is toggled by its mousedown handler, every later one by mouseenter,
    # so a single move onto each cell is all the page needs
    cells[0].hover()
    page.mouse.down()

    for cell in cells[1:]:
        cell.hover()

    page.mouse.up()
    time.sleep(0.1)

def assign(page, id, row, col, role='.'):
    """Toggle a shifter on or off a schedule cell, and wait for the write to land

    set_assign only sets current_stateid once the brpc reply comes back, so waiting for it
    to change is the observable end of the write. A fixed sleep raced it, and some of what
    happens after the reply matters to the caller - populate_name_highlight, for one, only
    creates the shifter's option then, and the next assign() hovering that name silently
    selects nothing if it is not there yet.
    """
    # baseline off a real state id. current_stateid starts null and is first filled by the
    # 500 ms poll, so on a freshly loaded page "!== null" would be satisfied by that poll
    # picking up the id the server already had - not by this write
    page.wait_for_function('current_stateid !== null', timeout=WRITE_TIMEOUT_MS)
    before = page.evaluate('current_stateid')

    by_id(page, f'cell_{row}_{col}').click()
    by_id(page, f'test{id}_{row}_{col}_{role}').click()

    # every write advances the state id, so this is a reliable "the reply arrived" signal.
    # timeout set explicitly: the page default is tuned for element lookups under try_again
    # and is far too short for a round trip that copies the whole state directory
    page.wait_for_function(f'current_stateid !== {before}', timeout=WRITE_TIMEOUT_MS)

def write_note(page, col, table, note):

    if table not in ('schedule', 'avail'):
        raise RuntimeError('table must be one of "schedule" or "avail"')

    # clicking the cell reveals its input and selects the existing text
    by_id(page, f'{table}_note_{col}').click()

    # type the note out rather than setting the value in one go: the input records its
    # previous value on each keystroke, and saves nothing if that value is unchanged
    field = by_id(page, f'{table}_note_input_{col}')
    field.press_sequentially(note)
    field.press('Enter')
    time.sleep(0.05)

def delete_note(page, col, table):
    if table not in ('schedule', 'avail'):
        raise RuntimeError('table must be one of "schedule" or "avail"')

    # clicking the cell reveals its input and selects the existing text
    by_id(page, f'{table}_note_{col}').click()

    field = by_id(page, f'{table}_note_input_{col}')
    field.press('Control+a')
    field.press('Backspace')
    field.press('Enter')
    time.sleep(0.05)

@pytest.fixture
def users_added(page, client):

    # create default users
    for i in range(10):
        client.odb_set(f"/Shifts/ContactInfo/test{i}/email", f'test{i}@test.ca')
        client.odb_set(f"/Shifts/ContactInfo/test{i}/affiliation", f'uni{i}')
        client.odb_set(f"/Shifts/ContactInfo/test{i}/phone_call", f'1 {i}{i}{i}-{i}{i}{i}-{i}{i}{i}{i}')

    # start_frontend has already navigated to the page by the time this runs, and nothing
    # bumps the state id when contacts change, so server_sync never redraws. the tests only
    # worked because setup() happens to make four round trips before it first reads
    # ContactInfo - reload and wait for a cell so that is not what we are relying on
    page.reload()
    expect(by_id(page, 'cell_1_1')).to_be_attached()

    yield

@pytest.fixture(scope="function", autouse=True)
def start_frontend(page, client):

    page.goto(f'{url}/?cmd=Programs')
    button_start = by_id(page, 'start shiftschedule')
    button_stop = by_id(page, 'stop shiftschedule')

    # the program table is drawn by javascript once the page has fetched its contents
    expect(button_start).to_be_attached(timeout=30000)

    # stop the frontend before clearing, not after. a frontend left running by an
    # interrupted test holds a Scheduler pointing at state directories that clear() is
    # about to delete, and its next ODB call aborts the whole pytest process
    # both buttons stay visible but disabled while the program starts or stops, so each
    # click waits for its button to be usable, and needs longer than the usual timeout
    if button_stop.is_visible():
        button_stop.click(timeout=30000)
        expect(button_start).to_be_visible(timeout=30000)

    # clear the ODB and data directory
    conftest.clear()
    setup_odb(client)

    button_start.click(timeout=30000)
    expect(button_stop).to_be_visible(timeout=30000)

    # navigate to shift schedule webpage
    page.goto(f'{url}/?cmd=custom&page=ShiftSchedule')

    yield

    # stop frontend
    page.goto(f'{url}/?cmd=Programs')
    button_start = by_id(page, 'start shiftschedule')
    button_stop = by_id(page, 'stop shiftschedule')

    expect(button_stop).to_be_attached(timeout=30000)
    button_stop.click(timeout=30000)
    expect(button_start).to_be_visible(timeout=30000)
