import operator
import pkgutil
import re
from io import StringIO

from selenium.webdriver.common.by import By

from cucu import (
    config,
    format_gherkin_table,
    fuzzy,
    helpers,
    logger,
    retry,
    step,
)
from cucu.browser.frames import run_in_all_frames


def find_tables(ctx):
    """
    find all the tables currently present on the page

    parameters:
      ctx(object): behave context object used to share data between steps

    returns:
      an array of arrays containing the HTML tables currently displayed
    """
    ctx.check_browser_initialized()
    tables_lib = pkgutil.get_data("cucu", "steps/tables.js")
    tables_lib = tables_lib.decode("utf8")

    def search_for_tables():
        ctx.browser.execute(tables_lib)
        return ctx.browser.execute("return findAllTables();")

    return run_in_all_frames(ctx.browser, search_for_tables)


def behave_table_to_array(table):
    """
    given a behave.model.Table object convert it to an array of rows

    parameters:
        table(behave.model.Table): the behave table to convert into an array

    returns:
        array of rows representing the behave table provided.
    """
    result = [table.headings]

    for row in table.rows:
        values = []
        for value in row:
            values.append(value)
        result.append(values)

    return result


def check_table_equals_table(table, expected_table):
    """
    check that table is equal to expected_table
    """
    return table == expected_table


def check_table_matches_table(table, expected_table):
    """
    check if table matches the regex patterns in expected table
    """

    table_matched = bool(len(table) == len(expected_table))
    if table_matched:
        for expected_row, row in zip(expected_table, table):
            for expected_value, value in zip(expected_row, row):
                if not re.match(expected_value, value):
                    table_matched = False
                    break

    return table_matched


def check_table_contains_table(table, expected_table):
    """
    check that table contains the rows in expected_table
    """
    return all(row in table for row in expected_table)


def check_table_contains_matching_rows_in_table(table, expected_table):
    """
    check that table contains the matching rows in expected_table
    """
    for expected_row in expected_table:
        found_row = False
        for row in table:
            good_cols = len(expected_row) == len(row)

            for expected_value, value in zip(expected_row, row):
                if not re.match(expected_value, value):
                    good_cols = False

            if good_cols:
                found_row = True
                break

        if not found_row:
            return False

    return True


def find_header_row_index(table, expected_headers, max_scan_rows=2):
    """
    Find first header row (within first 2 rows) where each expected header regex
    matches at least one actual header cell.
    """
    expected_patterns = [(h or "").strip(" ") for h in expected_headers]
    scan_rows = min(len(table), max_scan_rows)

    for idx in range(scan_rows):
        actual_headers = [(v or "").strip(" ") for v in table[idx]]

        all_headers_present = True
        for pattern in expected_patterns:
            if not any(re.match(pattern, actual) for actual in actual_headers):
                all_headers_present = False
                break

        if all_headers_present:
            return idx

    return None


def build_expected_header_to_indices(header_row, expected_headers):
    """
    For each expected header regex, return all matching column indices
    from the actual header row (supports duplicate headers / colspan).
    """
    actual_headers = [(h or "").strip(" ") for h in header_row]
    expected_patterns = [(h or "").strip(" ") for h in expected_headers]

    expected_to_indices = []
    for pattern in expected_patterns:
        indices = [
            idx
            for idx, actual in enumerate(actual_headers)
            if re.match(pattern, actual)
        ]
        if not indices:
            return None
        expected_to_indices.append(indices)

    return expected_to_indices


def contains_rows_by_named_columns(table, expected_table, match_value):
    """
    expected_table format (from behave_table_to_array(ctx.table)):
      expected_table[0] = expected header regexes (only columns you care about)
      expected_table[1:] = expected cell values/patterns for those headers

    For each expected row, find at least one actual data row that matches all
    specified columns. Extra columns and column order in the UI are ignored.
    """
    if not expected_table or len(expected_table) < 2:
        return False

    expected_headers = expected_table[0]
    expected_rows = expected_table[1:]

    header_row_idx = find_header_row_index(
        table, expected_headers, max_scan_rows=2
    )
    if header_row_idx is None:
        return False

    expected_to_indices = build_expected_header_to_indices(
        table[header_row_idx], expected_headers
    )
    if expected_to_indices is None:
        return False

    actual_data_rows = table[header_row_idx + 1 :]

    for expected_row in expected_rows:
        if len(expected_row) != len(expected_headers):
            return False

        matched_any_actual_row = False

        for actual_row in actual_data_rows:
            row_ok = True

            for expected_col_idx, expected_value in enumerate(expected_row):
                candidate_indices = expected_to_indices[expected_col_idx]

                matched_this_expected_column = False
                for col_idx in candidate_indices:
                    if col_idx < len(actual_row) and match_value(
                        expected_value, actual_row[col_idx]
                    ):
                        matched_this_expected_column = True
                        break

                if not matched_this_expected_column:
                    row_ok = False
                    break

            if row_ok:
                matched_any_actual_row = True
                break

        if not matched_any_actual_row:
            return False

    return True


def check_table_contains_rows_matching_only_these_columns(
    table, expected_table
):
    """Header and cell values matched via regex."""
    return contains_rows_by_named_columns(
        table,
        expected_table,
        lambda expected, actual: re.match(expected, actual),
    )


def check_table_contains_rows_with_only_these_columns(table, expected_table):
    """Header matched via regex; cell values matched by exact equality."""
    return contains_rows_by_named_columns(
        table, expected_table, lambda expected, actual: expected == actual
    )


def report_found_table(expected_table, found_table):
    logger.debug(
        f"found desired table\nexpected:\n{format_gherkin_table(expected_table, [], '  ')}\n\n"
        f"found:\n{format_gherkin_table(found_table, [], '  ')}\n"
    )


def report_unable_to_find_table(expected_table, found_tables):
    stream = StringIO()
    stream.write("\n")
    for index, table in enumerate(found_tables):
        print_index = helpers.nth_to_ordinal(index) or '"1st" '
        stream.write(
            f"{print_index}table:\n{format_gherkin_table(table, [], '  ')}\n"
        )

    stream.seek(0)
    raise AssertionError(
        f"unable to find desired table\nexpected:\n{format_gherkin_table(expected_table, [], '  ')}\n\nfound:{stream.read()}"
    )


def report_found_undesired_table(unexpected_tables, found_tables):
    stream = StringIO()
    stream.write("\n")
    for index, table in enumerate(found_tables):
        print_index = helpers.nth_to_ordinal(index) or '"1st" '
        stream.write(
            f"{print_index}table:\n{format_gherkin_table(table, [], '  ')}\n"
        )

    stream.seek(0)
    error_message = ""
    for table in unexpected_tables:
        error_message += (
            f"found undesired table\n\nundesired table:\n{format_gherkin_table(table, [], '  ')}\n\n"
            f"all tables found:{stream.read()}\n"
        )
    raise AssertionError(error_message)


def find_table(ctx, assert_func, nth=None):
    """
    validate we can find the table passed in the ctx object and assert it
    matches anyone of the tables on the current web page. If `nth` is set to
    something then we only check against the nth table of the available tables.

    parameters:
        ctx(object): behave context object
        assert_func(function): function used to assert two tables "match"
        nth(int): when set to an int specifies the exact table within the list
                  of available tables to match against.

    returns:
        array of arrays representing the matching HTML table

    raises:
        AssertionError when the desired table was not found
    """
    expected = behave_table_to_array(ctx.table)
    found_tables = find_tables(ctx)

    if nth is not None:
        if assert_func(found_tables[nth], expected):
            report_found_table(expected, found_tables[nth])
            return found_tables[nth]

    else:
        for table in found_tables:
            if assert_func(table, expected):
                report_found_table(expected, table)
                return table

    report_unable_to_find_table(expected, found_tables)


def do_not_find_table(ctx, assert_func, nth=None):
    """
    validate we can not find the table passed in the ctx object and assert it
    matches anyone of the tables on the current web page. If `nth` is set to
    something then we only check against the nth table of the available tables.

    paramters:
        ctx(object): behave context object
        assert_func(function): function used to assert two tables "match"
        nth(int): when set to an int specifies the exact table within the list
                  of available tables to match against.

    raises:
        AssertionError when the desired table was not found
    """
    expected = behave_table_to_array(ctx.table)
    tables = find_tables(ctx)
    matching_tables = []

    if nth is not None:
        if assert_func(tables[nth], expected):
            matching_tables.append(tables[nth])

    else:
        for table in tables:
            if assert_func(table, expected):
                matching_tables.append(table)

    # If none of the tables match the pattern, then return
    if len(matching_tables) == 0:
        return

    report_found_undesired_table(matching_tables, tables)


for thing, check_func in {
    "is": check_table_equals_table,
    "matches": check_table_matches_table,
    "contains": check_table_contains_table,
    "contains rows matching": check_table_contains_matching_rows_in_table,
}.items():

    @step(f"I should see a table that {thing} the following")
    def should_see_the_table(ctx, check_func=check_func):
        find_table(ctx, check_func)

    @step(f"I should not see a table that {thing} the following")
    def should_not_see_the_table(ctx, check_func=check_func):
        do_not_find_table(ctx, check_func)

    @step(f"I wait to see a table that {thing} the following")
    def wait_to_see_the_table(ctx, check_func=check_func):
        retry(find_table)(ctx, check_func)

    @step(f"I wait to not see a table that {thing} the following")
    def wait_to_not_see_the_table(ctx, check_func=check_func):
        retry(do_not_find_table)(ctx, check_func)

    @step(
        f'I wait up to "{{seconds}}" seconds to see a table that {thing} the following'
    )
    def wait_up_to_seconds_to_see_the_table(
        ctx, seconds, check_func=check_func
    ):
        seconds = float(seconds)
        retry(find_table, wait_up_to_s=seconds)(ctx, check_func)

    @step(
        f'I wait up to "{{seconds}}" seconds to not see a table that {thing} the following'
    )
    def wait_up_to_seconds_to_not_see_the_table(
        ctx, seconds, check_func=check_func
    ):
        seconds = float(seconds)
        retry(do_not_find_table, wait_up_to_s=seconds)(ctx, check_func)

    @step(f'I should see the "{{nth}}" table {thing} the following')
    def should_see_the_nth_table(ctx, nth, check_func=check_func):
        find_table(ctx, check_func, nth=nth)

    @step(f'I wait to see the "{{nth}}" table {thing} the following')
    def wait_to_see_the_nth_table(ctx, nth, check_func=check_func):
        retry(find_table)(ctx, check_func, nth=nth)

    @step(
        f'I wait up to "{{seconds}}" seconds to see the "{{nth}}" table {thing} the following'
    )
    def wait_up_to_seconds_to_see_the_nth_table(
        ctx, seconds, nth, check_func=check_func
    ):
        seconds = float(seconds)
        retry(find_table, wait_up_to_s=seconds)(ctx, check_func, nth=nth)


@step("I should see a table with rows matching these columns and values")
def should_see_table_rows_matching_only_these_columns(ctx):
    find_table(ctx, check_table_contains_rows_matching_only_these_columns)


@step(
    "I should not see a table that contains rows matching these columns and values"
)
def should_not_see_table_rows_matching_only_these_columns(ctx):
    do_not_find_table(
        ctx, check_table_contains_rows_matching_only_these_columns
    )


@step(
    "I wait to see a table that contains rows matching these columns and values"
)
def wait_to_see_table_rows_matching_only_these_columns(ctx):
    retry(find_table)(
        ctx, check_table_contains_rows_matching_only_these_columns
    )


@step(
    'I wait up to "{seconds}" seconds to see a table that contains rows matching these columns and values'
)
def wait_up_to_seconds_to_see_table_rows_matching_only_these_columns(
    ctx, seconds
):
    retry(find_table, wait_up_to_s=float(seconds))(
        ctx, check_table_contains_rows_matching_only_these_columns
    )


@step(
    'I should see the "{nth}" table containing rows matching these columns and values'
)
def should_see_nth_table_rows_matching_only_these_columns(ctx, nth):
    find_table(
        ctx, check_table_contains_rows_matching_only_these_columns, nth=nth
    )


@step(
    'I wait to see the "{nth}" table containing rows matching these columns and values'
)
def wait_to_see_nth_table_rows_matching_only_these_columns(ctx, nth):
    retry(find_table)(
        ctx, check_table_contains_rows_matching_only_these_columns, nth=nth
    )


def find_table_header(ctx, name, index=0):
    """
    find a table header with the provided name
    """
    ctx.check_browser_initialized()
    return fuzzy.find(ctx.browser, name, ["th"], index=index)


def click_table_header(ctx, header):
    """
    internal method used to simply click a table header element
    """
    ctx.check_browser_initialized()
    ctx.browser.click(header)


helpers.define_action_on_thing_with_name_steps(
    "table header",
    "click",
    find_table_header,
    click_table_header,
    with_nth=True,
)


def get_table_cell_value(ctx, table, row, column, variable_name):
    tables = find_tables(ctx)

    try:
        cell_value = tables[table][row][column]
    except IndexError:
        raise AssertionError(
            f"Cannot find table:{table + 1},row:{row + 1},column:{column + 1}. Please check your table data."
        )
    config.CONFIG[variable_name] = cell_value


@step(
    'I save "{table:nth}" table, "{row:nth}" row, "{column:nth}" column value to a variable "{variable_name}"'
)
def step_get_table_cell_value(ctx, table, row, column, variable_name):
    get_table_cell_value(ctx, table, row, column, variable_name)


@step(
    'I wait to save "{table:nth}" table, "{row:nth}" row, "{column:nth}" column value to a variable "{variable_name}"'
)
def wait_to_get_table_cell_value(ctx, table, row, column, variable_name):
    retry(get_table_cell_value)(ctx, table, row, column, variable_name)


def find_table_element(ctx, nth=1):
    """
    Return the nth table as a WebElement

    parameters:
      ctx(object): behave context object used to share data between steps
      nth(int): specifies the exact table within the list of available tables to match against.
                Defaults to 1st table.

    returns:
      A selenium WebElement associated with the table that was specified
    """
    ctx.check_browser_initialized()

    try:
        return ctx.browser.css_find_elements("table", missing_ok=True)[nth]
    except IndexError:
        raise AssertionError(
            f"Cannot find table:{nth + 1}. Please check your table data."
        )


def click_table_cell(ctx, row, column, table):
    """
    Clicks the cell corresponding to the given row and column

    parameters:
      ctx(object): behave context object used to share data between steps
      row(int): the row of the table to click
      column(int): the column of the table to click
      table(int): specifies the exact table within the list of available tables to match against.
    """
    table_element = find_table_element(ctx, table)

    try:
        row = table_element.find_elements(By.CSS_SELECTOR, "tbody tr")[row]
        cell = row.find_elements(By.CSS_SELECTOR, "td")[column]
    except IndexError:
        raise AssertionError(
            f"Cannot find table:{table + 1},row:{row + 1},column:{column + 1}. Please check your table data."
        )
    ctx.browser.click(cell)


@step('I wait to click the "{row:nth}" row in the "{table:nth}" table')
def wait_click_table_row(ctx, row, table):
    """
    Add 1 to the row number if the table has a header row.

    Note: Firefox is unable to click directly on a row <tr> if it has child columns <td>.
    In order to workaround this, the step just clicks the first column <td> of the row <tr>.
    Bug: https://bugzilla.mozilla.org/show_bug.cgi?id=1448825
    """
    retry(click_table_cell)(ctx, row, 1, table)


@step(
    'I wait to click the cell corresponding to the "{row:nth}" row and "{column:nth}" column in the "{table:nth}" table'
)
def wait_click_table_cell(ctx, row, column, table):
    """
    Add 1 to the row number if the table has a header row.
    """
    retry(click_table_cell)(ctx, row, column, table)


@step(
    'I wait to click the "{column:nth}" column within a row that contains the text "{match_text}" in the "{table:nth}" table'
)
def wait_click_table_cell_matching_text(ctx, column, match_text, table):
    def click_table_cell_matching_text(ctx, column, match_text, table):
        table_element = find_table_element(ctx, table)

        try:
            row = table_element.find_elements(
                By.XPATH, f'//td[.="{match_text}"]/parent::tr'
            )
            if len(row) > 1:
                logger.warning(
                    f'Found {len(row)} rows with matching text "{match_text}", using the first row.'
                )
            cell = row[0].find_elements(By.CSS_SELECTOR, "td")[column]
        except IndexError:
            raise AssertionError(
                f"Cannot find table:{table + 1},column:{column + 1},text:{match_text}. Please check your table data."
            )

        ctx.browser.click(cell)

    retry(click_table_cell_matching_text)(ctx, column, match_text, table)


def count_rows_in_table_element(table_element):
    """
    Count the amount of rows the provided table element has.

    Rows are defined as tr elements.
    Note that this is not a behave table nor an array of rows, unlike the result of func:`find_table`.

    parameters:
        table(WebElement): the table to convert find trs from
    """
    table_rows = table_element.find_elements(By.CSS_SELECTOR, "tr")
    return len(table_rows)


def find_nth_table_and_validate_row_count(
    ctx, table, thing, check_func, row_count
):
    """
    Find the nth table by index and validate its row count using the provided check function

    parameters:
        ctx(object): behave context object used to share data between steps
        table(int): the index of the table to check (0-based)
        thing(str): description of the comparison operator (e.g., "more than", "equals")
        check_func(function): operator function to perform the comparison (e.g., operator.gt, operator.eq)
        row_count(str): expected row count as a string

    raises:
        AssertionError: when the table row count does not meet the specified criteria
    """
    table_element = find_table_element(ctx, table)
    # table_element is a WebElement when search is done using find_table_element
    table_rows = count_rows_in_table_element(table_element)
    if not check_func(table_rows, int(row_count)):
        raise AssertionError(
            f"Expected {thing} {row_count} rows in table {table + 1}, but found {table_rows} instead."
        )


def find_table_matching_rows_and_validate_row_count(
    ctx, thing, check_func, row_count
):
    """
    Find a table containing matching rows and validate its row count using the provided check function

    parameters:
        ctx(object): behave context object used to share data between steps
        thing(str): description of the comparison operator (e.g., "more than", "equals")
        check_func(function): operator function to perform the comparison (e.g., operator.gt, operator.eq)
        row_count(str): expected row count as a string

    raises:
        AssertionError: when the table row count does not meet the specified criteria
    """
    table_element = find_table(
        ctx, check_table_contains_matching_rows_in_table
    )
    # table_element is an array of rows when search is done using find_table
    table_rows = len(table_element)
    if not check_func(table_rows, int(row_count)):
        raise AssertionError(
            f"Expected {thing} {row_count} rows in the table contaning matching entries, but found {table_rows} instead."
        )


@step('I wait to see there are "{row_count}" rows in the "{table:nth}" table')
def wait_table_row_count(ctx, row_count, table):
    """
    Add 1 to the row number if the table has a header row.
    """

    def find_table_row_count(ctx, row_count, table):
        table_element = find_table_element(ctx, table)
        table_rows = count_rows_in_table_element(table_element)

        if not int(row_count) == table_rows:
            raise AssertionError(
                f"Unable to find {row_count} rows in table {table + 1}. Please check your table data."
            )

    retry(find_table_row_count)(ctx, row_count, table)


for thing, check_func in {
    "more than": operator.gt,
    "at least": operator.ge,
    "exactly": operator.eq,
    "at most": operator.le,
    "less than": operator.lt,
    "not equal to": operator.ne,
}.items():

    @step(
        f'I wait to see there are {thing} "{{row_count}}" rows in the "{{table:nth}}" table'
    )
    def should_see_the_table_with_row_count(
        ctx, row_count, table, thing=thing, check_func=check_func
    ):
        """
        Add 1 to the row number if the table has a header row.
        """
        retry(find_nth_table_and_validate_row_count)(
            ctx, table, thing, check_func, row_count
        )

    @step(
        f'I wait to see that the table containing these rows has {thing} "{{row_count}}" rows'
    )
    def should_see_the_table_containing_rows_with_row_count(
        ctx, row_count, thing=thing, check_func=check_func
    ):
        """
        Add 1 to the row count number if the table has a header row.
        """
        retry(find_table_matching_rows_and_validate_row_count)(
            ctx, thing, check_func, row_count
        )
