import os
from selenium.webdriver import ActionChains
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.remote.webelement import WebElement

from contextlib import contextmanager

pjoin = os.path.join


def wait_for_selector(driver, selector, timeout=10, visible=False, single=False, wait_for_n=1, obscures=False):
    if wait_for_n > 1:
        return _wait_for_multiple(
            driver, By.CSS_SELECTOR, selector, timeout, wait_for_n, visible)
    return _wait_for(driver, By.CSS_SELECTOR, selector, timeout, visible, single, obscures)


def wait_for_tag(driver, tag, timeout=10, visible=False, single=False, wait_for_n=1, obscures=False):
    if wait_for_n > 1:
        return _wait_for_multiple(
            driver, By.TAG_NAME, tag, timeout, wait_for_n, visible)
    return _wait_for(driver, By.TAG_NAME, tag, timeout, visible, single, obscures)


def wait_for_xpath(driver, xpath, timeout=10, visible=False, single=False, wait_for_n=1, obscures=False):
    if wait_for_n > 1:
        return _wait_for_multiple(
            driver, By.XPATH, xpath, timeout, wait_for_n, visible)
    return _wait_for(driver, By.XPATH, xpath, timeout, visible, single, obscures)


def wait_for_script_to_return_true(driver, script, timeout=10):
    WebDriverWait(driver, timeout).until(lambda d: d.execute_script(script))


def _wait_for(driver, locator_type, locator, timeout=10, visible=False, single=False, obscures=False):
    """Waits `timeout` seconds for the specified condition to be met. Condition is
    met if any matching element is found. Returns located element(s) when found.

    Args:
        driver: Selenium web driver instance
        locator_type: type of locator (e.g. By.CSS_SELECTOR or By.TAG_NAME)
        locator: name of tag, class, etc. to wait for
        timeout: how long to wait for presence/visibility of element
        visible: if True, require that element is not only present, but visible
        single: if True, return a single element, otherwise return a list of matching
        elements
        obscures: if True, waits until the element becomes invisible
    """
    wait = WebDriverWait(driver, timeout)
    if obscures:
        conditional = EC.invisibility_of_element_located
    elif single:
        if visible:
            conditional = EC.visibility_of_element_located
        else:
            conditional = EC.presence_of_element_located
    else:
        if visible:
            conditional = EC.visibility_of_all_elements_located
        else:
            conditional = EC.presence_of_all_elements_located
    return wait.until(conditional((locator_type, locator)))


def _wait_for_multiple(driver, locator_type, locator, timeout, wait_for_n, visible=False):
    """Waits until `wait_for_n` matching elements to be present (or visible).
    Returns located elements when found.

    Args:
        driver: Selenium web driver instance
        locator_type: type of locator (e.g. By.CSS_SELECTOR or By.TAG_NAME)
        locator: name of tag, class, etc. to wait for
        timeout: how long to wait for presence/visibility of element
        wait_for_n: wait until this number of matching elements are present/visible
        visible: if True, require that elements are not only present, but visible
    """
    wait = WebDriverWait(driver, timeout)

    def multiple_found(driver):
        elements = driver.find_elements(locator_type, locator)
        if visible:
            elements = [e for e in elements if e.is_displayed()]
        if len(elements) < wait_for_n:
            return False
        return elements

    return wait.until(multiple_found)


class CellTypeError(ValueError):
    
    def __init__(self, message=""):
        self.message = message


class Notebook:
    
    def __init__(self, browser):
        self.browser = browser
        self._wait_for_start()
        self.disable_autosave_and_onbeforeunload()
        
    def __len__(self):
        return len(self.cells)
        
    def __getitem__(self, key):
        return self.cells[key]
    
    def __setitem__(self, key, item):
        if isinstance(key, int):
            self.edit_cell(index=key, content=item, render=False)
        # TODO: re-add slicing support, handle general python slicing behaviour
        # includes: overwriting the entire self.cells object if you do 
        # self[:] = []
        # elif isinstance(key, slice):
        #     indices = (self.index(cell) for cell in self[key])
        #     for k, v in zip(indices, item):
        #         self.edit_cell(index=k, content=v, render=False)

    def __iter__(self):
        return (cell for cell in self.cells)

    def _wait_for_start(self):
        """Wait until the notebook interface is loaded and the kernel started"""
        wait_for_selector(self.browser, '.cell')
        WebDriverWait(self.browser, 10).until(
            lambda drvr: self.is_kernel_running()
        )

    @property
    def body(self):
        return self.browser.find_element_by_tag_name("body")

    @property
    def cells(self):
        """Gets all cells once they are visible.
        
        """
        return self.browser.find_elements_by_class_name("cell")
    
    @property
    def current_index(self):
        return self.index(self.current_cell)
    
    def index(self, cell):
        return self.cells.index(cell)

    def disable_autosave_and_onbeforeunload(self):
        """Disable request to save before closing window and autosave.
        
        This is most easily done by using js directly.
        """
        self.browser.execute_script("window.onbeforeunload = null;")
        self.browser.execute_script("Jupyter.notebook.set_autosave_interval(0)")

    def to_command_mode(self):
        """Changes us into command mode on currently focused cell
        
        """
        self.body.send_keys(Keys.ESCAPE)
        self.browser.execute_script("return Jupyter.notebook.handle_command_mode("
                                       "Jupyter.notebook.get_cell("
                                           "Jupyter.notebook.get_edit_index()))")

    def focus_cell(self, index=0):
        cell = self.cells[index]
        cell.click()
        self.to_command_mode()
        self.current_cell = cell
    
    def select_cell_range(self, initial_index=0, final_index=0):
        self.focus_cell(initial_index)
        self.to_command_mode()
        for i in range(final_index - initial_index):
            shift(self.browser, 'j')

    def find_and_replace(self, index=0, find_txt='', replace_txt=''):
        self.focus_cell(index)
        self.to_command_mode()
        self.body.send_keys('f')
        wait_for_selector(self.browser, "#find-and-replace", single=True)
        self.browser.find_element_by_id("findreplace_allcells_btn").click()
        self.browser.find_element_by_id("findreplace_find_inp").send_keys(find_txt)
        self.browser.find_element_by_id("findreplace_replace_inp").send_keys(replace_txt)
        self.browser.find_element_by_id("findreplace_replaceall_btn").click()

    def convert_cell_type(self, index=0, cell_type="code"):
        # TODO add check to see if it is already present
        self.focus_cell(index)
        cell = self.cells[index]
        if cell_type == "markdown":
            self.current_cell.send_keys("m")
        elif cell_type == "raw":
            self.current_cell.send_keys("r")
        elif cell_type == "code":
            self.current_cell.send_keys("y")
        else:
            raise CellTypeError(("{} is not a valid cell type,"
                                 "use 'code', 'markdown', or 'raw'").format(cell_type))
                                 
        self.wait_for_stale_cell(cell)
        self.focus_cell(index)
        return self.current_cell

    def wait_for_stale_cell(self, cell):
        """ This is needed to switch a cell's mode and refocus it, or to render it.

        Warning: there is currently no way to do this when changing between 
        markdown and raw cells.
        """
        wait = WebDriverWait(self.browser, 10)
        element = wait.until(EC.staleness_of(cell))

    def wait_for_element_availability(self, element):
        _wait_for(self.browser, By.CLASS_NAME, element, visible=True)

    def get_cells_contents(self):
        JS = 'return Jupyter.notebook.get_cells().map(function(c) {return c.get_text();})'
        return self.browser.execute_script(JS)

    def get_cell_contents(self, index=0, selector='div .CodeMirror-code'):
        return self.cells[index].find_element_by_css_selector(selector).text

    def get_cell_output(self, index=0, output='output_subarea'):
        return self.cells[index].find_elements_by_class_name(output)

    def wait_for_cell_output(self, index=0, timeout=10):
        return WebDriverWait(self.browser, timeout).until(
            lambda b: self.get_cell_output(index)
        )

    def set_cell_metadata(self, index, key, value):
        JS = 'Jupyter.notebook.get_cell({}).metadata.{} = {}'.format(index, key, value)
        return self.browser.execute_script(JS)

    def get_cell_type(self, index=0):
        JS = 'return Jupyter.notebook.get_cell({}).cell_type'.format(index)
        return self.browser.execute_script(JS)
        
    def set_cell_input_prompt(self, index, prmpt_val):
        JS = 'Jupyter.notebook.get_cell({}).set_input_prompt({})'.format(index, prmpt_val)
        self.browser.execute_script(JS)

    def edit_cell(self, cell=None, index=0, content="", render=False):
        """Set the contents of a cell to *content*, by cell object or by index
        """
        if cell is not None:
            index = self.index(cell)
        self.focus_cell(index)

        # Select & delete anything already in the cell
        self.current_cell.send_keys(Keys.ENTER)
        cmdtrl(self.browser, 'a')
        self.current_cell.send_keys(Keys.DELETE)

        for line_no, line in enumerate(content.splitlines()):
            if line_no != 0:
                self.current_cell.send_keys(Keys.ENTER, "\n")
            self.current_cell.send_keys(Keys.ENTER, line)
        if render:
            self.execute_cell(self.current_index)

    def execute_cell(self, cell_or_index=None):
        if isinstance(cell_or_index, int):
            index = cell_or_index
        elif isinstance(cell_or_index, WebElement): 
            index = self.index(cell_or_index)
        else:
            raise TypeError("execute_cell only accepts a WebElement or an int")
        self.focus_cell(index)
        self.current_cell.send_keys(Keys.CONTROL, Keys.ENTER)

    def add_cell(self, index=-1, cell_type="code", content=""):
        self.focus_cell(index)
        self.current_cell.send_keys("b")
        new_index = index + 1 if index >= 0 else index
        if content:
            self.edit_cell(index=index, content=content)
        if cell_type != 'code':
            self.convert_cell_type(index=new_index, cell_type=cell_type)

    def add_and_execute_cell(self, index=-1, cell_type="code", content=""):
        self.add_cell(index=index, cell_type=cell_type, content=content)
        self.execute_cell(index)

    def delete_cell(self, index):
        self.focus_cell(index)
        self.to_command_mode()
        self.current_cell.send_keys('dd')

    def add_markdown_cell(self, index=-1, content="", render=True):
        self.add_cell(index, cell_type="markdown")
        self.edit_cell(index=index, content=content, render=render)
    
    def append(self, *values, cell_type="code"):
        for i, value in enumerate(values):
            if isinstance(value, str):
                self.add_cell(cell_type=cell_type,
                              content=value)
            else:
                raise TypeError("Don't know how to add cell from %r" % value)
    
    def extend(self, values):
        self.append(*values)
    
    def run_all(self):
        for cell in self:
            self.execute_cell(cell)

    def trigger_keydown(self, keys):
        trigger_keystrokes(self.body, keys)

    def is_kernel_running(self):
        return self.browser.execute_script(
            "return Jupyter.notebook.kernel && Jupyter.notebook.kernel.is_connected()"
        )

    def clear_cell_output(self, index):
        JS = 'Jupyter.notebook.clear_output({})'.format(index)
        self.browser.execute_script(JS)

    @classmethod
    def new_notebook(cls, browser, kernel_name='kernel-python3'):
        with new_window(browser):
            select_kernel(browser, kernel_name=kernel_name)
        return cls(browser)


def select_kernel(browser, kernel_name='kernel-python3'):
    """Clicks the "new" button and selects a kernel from the options.
    """
    wait = WebDriverWait(browser, 10)
    new_button = wait.until(EC.element_to_be_clickable((By.ID, "new-dropdown-button")))
    new_button.click()
    kernel_selector = '#{} a'.format(kernel_name)
    kernel = wait_for_selector(browser, kernel_selector, single=True)
    kernel.click()


@contextmanager
def new_window(browser):
    """Contextmanager for switching to & waiting for a window created. 
    
    This context manager gives you the ability to create a new window inside 
    the created context and it will switch you to that new window.
    
    Usage example:
    
        from notebook.tests.selenium.utils import new_window, Notebook
        
        ⋮ # something that creates a browser object
        
        with new_window(browser):
            select_kernel(browser, kernel_name=kernel_name)
        nb = Notebook(browser)

    """
    initial_window_handles = browser.window_handles
    yield
    new_window_handles = [window for window in browser.window_handles
                          if window not in initial_window_handles]
    if not new_window_handles:
        raise Exception("No new windows opened during context")
    browser.switch_to.window(new_window_handles[0])

def shift(browser, k):
    """Send key combination Shift+(k)"""
    trigger_keystrokes(browser, "shift-%s"%k)

def cmdtrl(browser, k):
    """Send key combination Ctrl+(k) or Command+(k) for MacOS"""
    trigger_keystrokes(browser, "command-%s"%k) if os.uname()[0] == "Darwin" else trigger_keystrokes(browser, "control-%s"%k)

def alt(browser, k):
    """Send key combination Alt+(k)"""
    trigger_keystrokes(browser, 'alt-%s'%k)

def trigger_keystrokes(browser, *keys):
    """ Send the keys in sequence to the browser.
    Handles following key combinations
    1. with modifiers eg. 'control-alt-a', 'shift-c'
    2. just modifiers eg. 'alt', 'esc'
    3. non-modifiers eg. 'abc'
    Modifiers : http://seleniumhq.github.io/selenium/docs/api/py/webdriver/selenium.webdriver.common.keys.html
    """
    for each_key_combination in keys:
        keys = each_key_combination.split('-')
        if len(keys) > 1:  # key has modifiers eg. control, alt, shift
            modifiers_keys = [getattr(Keys, x.upper()) for x in keys[:-1]]
            ac = ActionChains(browser)
            for i in modifiers_keys: ac = ac.key_down(i)
            ac.send_keys(keys[-1])
            for i in modifiers_keys[::-1]: ac = ac.key_up(i)
            ac.perform()
        else:              # single key stroke. Check if modifier eg. "up"
            browser.send_keys(getattr(Keys, keys[0].upper(), keys[0]))

def validate_dualmode_state(notebook, mode, index):
    '''Validate the entire dual mode state of the notebook.  
    Checks if the specified cell is selected, and the mode and keyboard mode are the same.
    Depending on the mode given:
        Command: Checks that no cells are in focus or in edit mode.
        Edit:    Checks that only the specified cell is in focus and in edit mode.
    '''
    def is_only_cell_edit(index):
        JS = 'return Jupyter.notebook.get_cells().map(function(c) {return c.mode;})'
        cells_mode = notebook.browser.execute_script(JS)
        #None of the cells are in edit mode
        if index is None:
            for mode in cells_mode:
                if mode == 'edit':
                    return False
            return True
        #Only the index cell is on edit mode
        for i, mode in enumerate(cells_mode):
            if i == index:
                if mode != 'edit':
                    return False
            else:
                if mode == 'edit':
                    return False
        return True

    def is_focused_on(index):
        JS = "return $('#notebook .CodeMirror-focused textarea').length;"
        focused_cells = notebook.browser.execute_script(JS)
        if index is None:
            return focused_cells == 0

        if focused_cells != 1: #only one cell is focused
            return False

        JS = "return $('#notebook .CodeMirror-focused textarea')[0];"
        focused_cell = notebook.browser.execute_script(JS)
        JS = "return IPython.notebook.get_cell(%s).code_mirror.getInputField()"%index
        cell = notebook.browser.execute_script(JS)
        return focused_cell == cell


    #general test
    JS = "return IPython.keyboard_manager.mode;"
    keyboard_mode = notebook.browser.execute_script(JS)
    JS = "return IPython.notebook.mode;"
    notebook_mode = notebook.browser.execute_script(JS)

    #validate selected cell
    JS = "return Jupyter.notebook.get_selected_cells_indices();"
    cell_index = notebook.browser.execute_script(JS)
    assert cell_index == [index] #only the index cell is selected

    if mode != 'command' and mode != 'edit':
        raise Exception('An unknown mode was send: mode = "%s"'%mode) #An unknown mode is send

    #validate mode
    assert mode == keyboard_mode #keyboard mode is correct

    if mode == 'command':
        assert is_focused_on(None) #no focused cells

        assert is_only_cell_edit(None) #no cells in edit mode
    
    elif mode == 'edit':
        assert is_focused_on(index) #The specified cell is focused

        assert is_only_cell_edit(index) #The specified cell is the only one in edit mode
