""" Helper utilities for the test suite. """

import os
import urllib.request
import json
import time
import itertools
import uuid
import base64

import sqlalchemy
import sqlalchemy.orm
import sqlalchemy.sql.expression
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.remote.webelement import WebElement
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.common.by import By
from selenium.common.exceptions import NoSuchElementException, StaleElementReferenceException, TimeoutException

from asl_articles.utils import to_bool
import asl_articles.models

_webdriver = None
_flask_app = None # nb: this may not be set (if we're talking to an existing Flask server)

# ---------------------------------------------------------------------

def init_tests( webdriver, flask_app, dbconn, **kwargs ):
    """Prepare to run tests."""

    # initialize
    global _webdriver, _flask_app
    _webdriver = webdriver
    _flask_app = flask_app
    fixtures_dir = os.path.join( os.path.dirname( __file__ ), "fixtures/" )

    # initialize the database
    fixtures = kwargs.pop( "fixtures", None )
    if dbconn:
        Session = sqlalchemy.orm.sessionmaker( bind=dbconn )
        session = Session()
        load_fixtures( session, fixtures )
    else:
        assert fixtures is None
        session = None

    # re-initialize the search engine
    if flask_app:
        url = flask_app.url_for( "init_search_for_test" )
        with urllib.request.urlopen( url ) as resp:
            _ = resp.read()

    # initialize the documents directory
    dname = kwargs.pop( "docs", None )
    if dname:
        flask_app.config[ "EXTERNAL_DOCS_BASEDIR" ] = os.path.join( fixtures_dir, dname )
    else:
        if flask_app:
            flask_app.config.pop( "EXTERNAL_DOCS_BASEDIR", None )

    # never highlight search results unless explicitly enabled
    if "no_sr_hilite" not in kwargs:
        kwargs[ "no_sr_hilite" ] = 1

    # load the home page
    if webdriver:
        if to_bool( kwargs.pop( "disable_constraints", True ) ):
            kwargs[ "disable_constraints" ] = 1
        if to_bool( kwargs.pop( "disable_confirm_discard_changes", True ) ):
            kwargs[ "disable_confirm_discard_changes" ] = 1
        webdriver.get( webdriver.make_url( "", **kwargs ) )
        # FUDGE! Since we switched from running the test Flask server with app.run() to make_server().serve_forever(),
        # stopping and starting the server seems to be much quicker, but refreshing the page can be slower when
        # running multiple tests :shrug:
        wait_for_elem( 10, "#search-form" )

    return session

# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

def load_fixtures( session, fname ):
    """Load fixtures into the database."""

    # load the fixtures
    if fname:
        dname = os.path.join( os.path.split(__file__)[0], "fixtures/" )
        fname = os.path.join( dname, fname )
        with open( fname, "r", encoding="utf-8" ) as fp:
            data = json.load( fp )
    else:
        data = {}

    # save the fixture data in the database
    table_names = [ "publisher", "publication", "article" ]
    table_names.extend( [ "author", "article_author" ] )
    table_names.extend( [ "publisher_image", "publication_image", "article_image" ] )
    table_names.extend( [ "scenario", "article_scenario" ] )
    for table_name in table_names:
        model = asl_articles.models.get_model_from_table_name( table_name )
        session.query( model ).delete()
        if table_name in data:
            session.bulk_insert_mappings( model, data[table_name] )
    session.commit()

# ---------------------------------------------------------------------

def do_search( query ):
    """Run a search."""

    # submit the search query
    curr_seqno = get_search_seqno()
    form = find_child( "#search-form" )
    assert form
    elem = find_child( ".query", form )
    # FUDGE! Calling elem.clear() then send_keys(query) has a weird effect in Chromium if the query
    # is empty. The previous query gets repeated instead - is the browser auto-filling the field?
    actions = ActionChains( _webdriver ).move_to_element( elem ).click() \
        .key_down( Keys.CONTROL ).send_keys( "a" ).key_up( Keys.CONTROL ) \
        .send_keys( Keys.DELETE )
    if query:
        actions = actions.send_keys( query )
    actions.perform()
    find_child( "button[type='submit']", form ).click()

    # return the results
    wait_for( 2, lambda: get_search_seqno() != curr_seqno )
    return get_search_results()

def get_search_results():
    """Get the search results."""
    return find_children( "#search-results .search-result" )

def get_search_result_names( results=None ):
    """Get the names from the search results."""
    if not results:
        results = get_search_results()
    return [ find_child( ".name", r ).text for r in results ]

def find_search_result( name, results=None, wait=0 ):
    """Find a search result."""
    def find_sr():
        matches = [
            r for r in results or get_search_results()
            if find_child( ".name", r ).text == name
        ]
        return matches[0] if len(matches) == 1 else None
    return wait_for( wait, find_sr )

def check_search_result( sr, check, expected ):
    """Check a search result in the UI."""

    # figure out which search result to check
    if not sr:
        # NOTE: If the caller doesn't explicitly provide a search result, we assume we're working with
        # a single search result that is already on-screen.
        results = get_search_results()
        assert len(results) == 1
        sr = results[0]
    elif isinstance( sr, str ):
        sr = find_search_result( sr )
    else:
        assert isinstance( sr, WebElement )

    # wait for the search result to match what we expect
    def check_sr():
        try:
            if check( sr, expected ):
                return sr
            return None
        except StaleElementReferenceException:
            return None # nb: the web page updated while we were checking it
    return wait_for( 2, check_sr )

def get_search_seqno():
    """Get the current search seq#."""
    elem = find_child( "#search-results" )
    if not elem:
        return None
    return elem.get_attribute( "seqno" )

# ---------------------------------------------------------------------

def do_test_confirm_discard_changes( menu_id, update_fields=None ): #pylint: disable=too-many-statements
    """Test confirmation of discarding changes made to a dialog."""

    # initialize
    image_fname = os.path.join( os.path.split(__file__)[0], "fixtures/images/1.gif" )

    def get_input_fields( dlg ):
        input_fields = itertools.chain(
            find_children( "input", dlg ),
            find_children( "textarea", dlg )
        )
        input_fields = { get_field_id(f): f for f in input_fields if f.is_displayed() }
        # NOTE: Publishers, publications and articles all have an image, but requires special handling.
        input_fields[ "image" ] = None
        return input_fields
    def get_field_id( elem ):
        if elem.get_attribute( "class" ) == "edition":
            # FUDGE! The publication dialog has a row with two fields ("name" and "edition").
            # We return the "edition" field, the "name" field is handled as a ReactSelect.
            return "edition"
        if elem.get_attribute( "class" ) == "pageno":
            # FUDGE! The article dialog has a row with two fields ("publication" and "pageno").
            # We return the "pageno" field, the "publication" field is handled as a ReactSelect.
            return "pageno"
        elem = find_parent_by_class( elem, "row" )
        classes = set( elem.get_attribute( "class" ).split() )
        classes.remove( "row" )
        assert len(classes) == 1
        return classes.pop()

    # locate all the input fields
    select_main_menu_option( menu_id )
    dlg = wait_for_elem( 2, ".MuiDialog-root" )
    field_ids = get_input_fields( dlg ).keys()
    find_child( ".cancel", dlg ).click()

    def update_field( field_id, dlg, elem, setVal, val=None ):
        # check if we're updating the image
        if field_id == "image":
            if setVal:
                change_image( dlg, image_fname )
            else:
                remove_image( dlg )
            return None
        # check if a custom update function has been provided
        if update_fields and field_id in update_fields:
            update_fields[ field_id ][ 0 if setVal else 1 ]( elem )
            return None
        # update the field as text
        prev_val = elem.get_attribute( "value" )
        if val is None:
            val = "TEST: {}".format( field_id ) if setVal else ""
        set_elem_text( elem, val )
        elem.send_keys( Keys.RETURN ) # nb: in case we have a ReactSelect
        return prev_val

    def do_test( open_dialog, setVals ):

        # test each input field
        for field_id in field_ids:

            # NOTE: We can't unset a publication's name once it's been set, so there's no point continuing.
            if menu_id == "new-publication" and field_id == "name" and not setVals:
                continue

            # open the form dialog
            open_dialog()
            dlg = wait_for_elem( 2, ".MuiDialog-root" )
            input_fields = get_input_fields( dlg )

            # change the next input field
            prev_val = update_field( field_id, dlg, input_fields[field_id], setVals )

            # try to cancel the dialog (should get a confirmation dialog)
            find_child( ".cancel", dlg ).click()
            ask = wait_for_elem( 2, "#ask" )
            assert "Do you want to discard your changes?" in find_child( ".MuiDialogContent-root", ask ).text
            find_child( ".cancel", ask ).click()

            # NOTE: We can't unset a publication's name once it's been set, so there's no point continuing.
            if menu_id == "new-publication" and field_id == "name":
                find_child( ".cancel", dlg ).click()
                ask = wait_for_elem( 2, "#ask" )
                find_child( ".ok", ask ).click()
                continue
            # NOTE: Changing the image will always trigger a confirmation dialog, so there's no point continuing.
            if field_id == "image" and not setVals:
                find_child( ".cancel", dlg ).click()
                ask = wait_for_elem( 2, "#ask" )
                find_child( ".ok", ask ).click()
                continue

            # restore the original value
            if isinstance( prev_val, str ):
                prev_val = "   {}   ".format( prev_val )
            update_field( field_id, dlg, input_fields[field_id], not setVals, prev_val )

            # try to cancel the dialog (should work without confirmation)
            find_child( ".cancel", dlg ).click()
            ask = wait_for_not_elem( 2, ".MuiDialog-root" )

    # test using a blank object
    do_test( lambda: select_main_menu_option( menu_id ), True )

    # test using an object with every field filled in
    select_main_menu_option( menu_id )
    dlg = wait_for_elem( 2, ".MuiDialog-root" )
    input_fields = get_input_fields( dlg )
    for field_id in input_fields:
        update_field( field_id, dlg, input_fields[field_id], True )
    find_child( ".ok", dlg ).click()
    results = wait_for( 2, get_search_results )
    assert len(results) == 1
    do_test( lambda: select_sr_menu_option( results[0], "edit" ), False )

# ---------------------------------------------------------------------

def wait_for( timeout, func ):
    """Wait for a condition to become true."""
    return WebDriverWait( _webdriver, timeout, 0.1 ).until(
        lambda wd: func()
    )

def wait_for_elem( timeout, sel, visible=True ):
    """Wait for an element to appear in the DOM."""
    func = EC.visibility_of_element_located if visible else EC.presence_of_element_located
    return WebDriverWait( _webdriver, timeout, 0.1 ).until(
        func( ( By.CSS_SELECTOR, sel ) )
    )

def wait_for_not_elem( timeout, sel ):
    """Wait for an element to be removed from the DOM."""
    return WebDriverWait( _webdriver, timeout, 0.1 ).until(
        EC.invisibility_of_element_located( ( By.CSS_SELECTOR, sel ) )
    )

# ---------------------------------------------------------------------

def find_child( sel, parent=None ):
    """Find a child element."""
    try:
        return (parent if parent else _webdriver).find_element( By.CSS_SELECTOR, sel )
    except NoSuchElementException:
        return None

def find_children( sel, parent=None ):
    """Find child elements."""
    try:
        return (parent if parent else _webdriver).find_elements( By.CSS_SELECTOR, sel )
    except NoSuchElementException:
        return None

def find_parent_by_class( elem, class_name ):
    """Find a parent element with the specified class."""
    while True:
        elem = elem.find_element( By.XPATH, ".." )
        if not elem:
            return None
        classes = set( elem.get_attribute( "class" ).split() )
        if class_name in classes:
            return elem

# ---------------------------------------------------------------------

def get_stored_msg( msg_id ):
    """Get a message stored for us by the front-end."""
    elem = find_child( _make_stored_msg_elem_id( msg_id ), _webdriver )
    assert elem.tag_name == "textarea"
    return elem.get_attribute( "value" )

def set_stored_msg( msg_id, val ):
    """Set a message for the front-end."""
    elem = find_child( _make_stored_msg_elem_id( msg_id ), _webdriver )
    assert elem.tag_name == "textarea"
    _webdriver.execute_script( "arguments[0].value = arguments[1]", elem, val )

def set_stored_msg_marker( msg_id ):
    """Store something in the message buffer (so we can tell if the front-end changes it)."""
    marker = "marker:{}:{}".format( msg_id, uuid.uuid4() )
    set_stored_msg( msg_id, marker )
    return marker

def _make_stored_msg_elem_id( msg_id ):
    return "#_stored_msg-{}_".format( msg_id )

# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

_TOAST_TYPES = [ "info", "warning", "error" ]

def get_toast( toast_type ):
    """Get a toast message stored for us by the front-end."""
    buf = get_stored_msg( _make_toast_stored_msg_id( toast_type ) )
    if buf.startswith( "<div>" ) and buf.endswith( "</div>" ):
        buf = buf[5:-6].strip()
    return buf

def set_toast_marker( toast_type, clear_others=True ):
    """Store marker text in the toast message buffer."""
    marker = None
    for t in _TOAST_TYPES:
        msg_id = _make_toast_stored_msg_id( t )
        if t == toast_type:
            # set the specified stored message marker
            marker = set_stored_msg_marker( msg_id )
        else:
            if clear_others:
                # clear all other stored messages
                set_stored_msg( msg_id, "" )
    assert marker
    return marker

def check_toast( toast_type, expected, contains=False, check_others=True ):
    """Check the contents of a stored toast message."""
    rc = None
    for t in _TOAST_TYPES:
        if t == toast_type:
            # check the specified toast message
            rc = check_string( get_toast(t), expected, contains=contains )
        else:
            # check that all other toast messages have not been set
            if check_others:
                assert get_toast( t ) == ""
    assert rc is not None
    return rc

def _make_toast_stored_msg_id( toast_type ):
    return "{}_toast".format( toast_type )

# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

def send_upload_data( data, func ):
    """Send data to the front-end, to simulate uploading a file.

    Because Selenium can't control a browser's native "open file" dialog, we need a different mechanism
    to test features that require uploading a file. We store the data we want to upload as a base64-encoded
    string in a hidden textarea, and the front-end Javascript loads it from there.
    """
    # send the data to the front-end
    set_stored_msg( "upload", data )
    func() # nb: the caller must initiate the upload process
    # wait for the front-end to acknowledge receipt of the data
    wait_for( 2, lambda: get_stored_msg("upload") == "" )

# ---------------------------------------------------------------------

def select_sr_menu_option( sr, menu_id ):
    """Select an option from a search result's menu."""
    _do_select_menu_option( find_child("button.sr-menu",sr), "."+menu_id )

def select_main_menu_option( menu_id ):
    """Select an option from the main application menu."""
    _do_select_menu_option( find_child("#menu-button--app"), "#menu-"+menu_id )

def _do_select_menu_option( menu, sel ):
    """Select an option from a dropdown menu."""
    for _ in range(0,5):
        # FUDGE! This is very weird, clicking on the menu button doesn't always register?!?
        menu.click()
        portal = None
        try:
            portal = wait_for_elem( 1, "reach-portal" )
        except TimeoutException:
            continue
        assert portal
        # FUDGE! Also very weird, the menu seems to occasionally close up by itself (especially when running headless).
        try:
            find_child( sel, portal ).click()
            return
        except StaleElementReferenceException:
            continue
    assert False, "Couldn't select menu option: {}".format( sel )

# ---------------------------------------------------------------------

# FUDGE! We can't use prepared statements here, since the syntax is different for SQLite and Postgres :-/

def get_publisher_row( dbconn, publ_id, fields ):
    """Get a row from the publisher table."""
    assert publ_id
    return dbconn.execute(
        "SELECT {} FROM publisher WHERE publ_id={}".format(
            ",".join(fields), publ_id
        )
    ).fetchone()

def get_publication_row( dbconn, pub_id, fields ):
    """Get a row from the publication table."""
    assert pub_id
    return dbconn.execute(
        "SELECT {} FROM publication WHERE pub_id={}".format(
            ",".join(fields), pub_id
        )
    ).fetchone()

def get_article_row( dbconn, article_id, fields ):
    """Get a row from the article table."""
    assert article_id
    return dbconn.execute(
        "SELECT {} FROM article WHERE article_id={}".format(
            ",".join(fields), article_id
        )
    ).fetchone()

# ---------------------------------------------------------------------

def call_with_retry( func, expected_exceptions, max_retries=10, delay=0.1 ):
    """Try to call a function, with retries if it fails."""
    for _ in range(0,max_retries):
        try:
            return func()
        except Exception as exc: #pylint: disable=broad-except
            if type(exc) not in expected_exceptions: #pylint: disable=unidiomatic-typecheck
                raise
            time.sleep( delay )
            continue
    assert False

def change_image( dlg, fname ):
    """Click on an image to change it."""
    # NOTE: This is a bit tricky since we started overlaying the image with the "remove image" icon :-/
    with open( fname, "rb" ) as fp:
        data = base64.b64encode( fp.read() )
    data = "{}|{}".format( os.path.split(fname)[1], data.decode("ascii") )
    elem = find_child( ".row.image img.image", dlg )
    _webdriver.execute_script( "arguments[0].scrollTo( 0, 0 )", find_child( ".MuiDialogContent-root", dlg ) )
    send_upload_data( data,
        lambda: ActionChains( _webdriver ) \
                .move_to_element_with_offset( elem, 1, 1 ) \
                .click().perform()
    )

def remove_image( dlg ):
    """Remove an image."""
    find_child( ".row.image .remove-image", dlg ).click()

def set_elem_text( elem, val ):
    """Set the text for an element."""
    elem.clear()
    elem.send_keys( val )

def check_ask_dialog( expected, click_on ):
    """Check that the ASK dialog is being shown, and its contents."""
    # check the ASK dialog
    elem = wait_for_elem( 2, "#ask .MuiPaper-root" )
    buf = elem.get_attribute( "innerHTML" )
    for e in [expected] if isinstance(expected,str) else expected:
        assert e in buf
    # dismiss the dialog
    if click_on:
        find_child( "button.{}".format( click_on ), elem ).click()
        wait_for( 2, lambda: find_child( "#ask" ) is None )

def check_error_msg( expected ):
    """Check that an error dialog is being shown, and its contents."""
    check_ask_dialog( expected, None )
    # check that the error icon is shown
    elem = find_child( "#ask img.icon" )
    assert elem.get_attribute( "src" ).endswith( "/error.png" )
    find_child( "#ask .MuiDialogActions-root button.ok" ).click()
    wait_for( 2, lambda: find_child( "#ask" ) is None )

def check_constraint_warnings( expected_caption, expected_constraints, click_on ):
    """Check that a constraints warning dialog is being shown, and its contents."""
    dlg = wait_for_elem( 2, "#ask" )
    assert find_child( ".caption", dlg ).text == expected_caption
    constraints = [ c.text for c in find_children( ".constraint", dlg ) ]
    assert set( constraints ) == set( expected_constraints )
    find_child( ".MuiDialogActions-root button.{}".format( click_on ), dlg ).click()

def check_string( val, expected, contains=False ):
    """Compare a value with its expected value."""
    if contains:
        return expected in val
    return val == expected
