import csv
import re
import sys
import textwrap
from typing import Any, Callable, Union

import pytest
from fastapi.responses import PlainTextResponse

from nicegui import ElementFilter, app, events, ui
from nicegui.testing import User

# pylint: disable=missing-function-docstring


async def test_auto_index_page(user: User) -> None:
    @ui.page('/')
    def page():
        ui.label('Main page')

    await user.open('/')
    await user.should_see('Main page')


async def test_multiple_pages(create_user: Callable[[], User]) -> None:
    @ui.page('/')
    def index():
        ui.label('Main page')

    @ui.page('/other')
    def other():
        ui.label('Other page')

    user1 = create_user()
    user2 = create_user()

    await user1.open('/')
    await user1.should_see('Main page')
    await user1.should_not_see('Other page')

    await user2.open('/other')
    await user2.should_see('Other page')
    await user2.should_not_see('Main page')


async def test_source_element(user: User) -> None:
    @ui.page('/')
    def index():
        ui.image('/image.jpg')

    await user.open('/')
    await user.should_see('image.jpg')


async def test_button_click(user: User) -> None:
    @ui.page('/')
    def index():
        ui.button('click me', on_click=lambda: ui.label('clicked'))

    await user.open('/')
    user.find('click me').click()
    await user.should_see('clicked')


async def test_clicking_disabled_button(user: User) -> None:
    @ui.page('/')
    def page():
        button = ui.button('My Button', on_click=lambda: ui.notify('Button clicked'))
        button.disable()

    await user.open('/')
    user.find('My Button').click()
    await user.should_not_see('Button clicked')


async def test_assertion_raised_when_no_nicegui_page_is_returned(user: User) -> None:
    @app.get('/plain')
    def index() -> PlainTextResponse:
        return PlainTextResponse('Hello')

    with pytest.raises(ValueError):
        await user.open('/plain')


async def test_assertion_raised_when_element_not_found(user: User) -> None:
    @ui.page('/')
    def index():
        ui.label('Hello')

    await user.open('/')
    with pytest.raises(AssertionError):
        await user.should_see('World')


@pytest.mark.parametrize('storage_builder', [lambda: app.storage.browser, lambda: app.storage.user])
async def test_storage(user: User, storage_builder: Callable[[], dict]) -> None:
    @ui.page('/')
    def page():
        storage = storage_builder()
        storage['count'] = storage.get('count', 0) + 1
        ui.label().bind_text_from(storage, 'count')

    await user.open('/')
    await user.should_see('1')

    await user.open('/')
    await user.should_see('2')


async def test_navigation(user: User) -> None:
    @ui.page('/')
    def page():
        ui.label('Main page')
        ui.button('go to other', on_click=lambda: ui.navigate.to('/other'))
        ui.button('forward', on_click=ui.navigate.forward)

    @ui.page('/other')
    def other():
        ui.label('Other page')
        ui.button('back', on_click=ui.navigate.back)

    await user.open('/')
    await user.should_see('Main page')
    user.find('go to other').click()
    await user.should_see('Other page')
    user.find('back').click()
    await user.should_see('Main page')
    user.find('forward').click()
    await user.should_see('Other page')


async def test_multi_user_navigation(create_user: Callable[[], User]) -> None:
    @ui.page('/')
    def page():
        ui.label('Main page')
        ui.button('go to other', on_click=lambda: ui.navigate.to('/other'))
        ui.button('forward', on_click=ui.navigate.forward)

    @ui.page('/other')
    def other():
        ui.label('Other page')
        ui.button('back', on_click=ui.navigate.back)

    user1 = create_user()
    user2 = create_user()

    await user1.open('/')
    await user1.should_see('Main page')

    await user2.open('/')
    await user2.should_see('Main page')

    user1.find('go to other').click()
    await user1.should_see('Other page')
    await user2.should_see('Main page')

    user1.find('back').click()
    await user1.should_see('Main page')
    await user2.should_see('Main page')

    user1.find('forward').click()
    await user1.should_see('Other page')
    await user2.should_see('Main page')


async def test_reload(user: User) -> None:
    @ui.page('/')
    def page():
        ui.input('test input')
        ui.button('reload', on_click=ui.navigate.reload)

    await user.open('/')
    await user.should_not_see('Hello')
    user.find('test input').type('Hello')
    await user.should_see('Hello')
    user.find('reload').click()
    await user.should_not_see('Hello')


async def test_notification(user: User) -> None:
    @ui.page('/')
    def page():
        ui.button('notify', on_click=lambda: ui.notify('Hello'))

    await user.open('/')
    user.find('notify').click()
    await user.should_see('Hello')


@pytest.mark.parametrize('kind', [ui.checkbox, ui.switch])
async def test_checkbox_and_switch(user: User, kind: type) -> None:
    @ui.page('/')
    def page():
        element = kind('my element', on_change=lambda e: ui.notify(f'Changed: {e.value}'))
        ui.label().bind_text_from(element, 'value', lambda v: 'enabled' if v else 'disabled')

    await user.open('/')
    await user.should_see('disabled')

    user.find('element').click()
    await user.should_see('enabled')
    await user.should_see('Changed: True')

    user.find('element').click()
    await user.should_see('disabled')
    await user.should_see('Changed: False')


@pytest.mark.parametrize('kind', [ui.input, ui.editor, ui.codemirror])
async def test_input(user: User, kind: type) -> None:
    @ui.page('/')
    def page():
        element = kind(on_change=lambda e: ui.notify(f'Changed: {e.value}'))
        ui.label().bind_text_from(element, 'value', lambda v: f'Value: {v}')

    await user.open('/')
    await user.should_see('Value: ')

    user.find(kind).type('Hello')
    await user.should_see('Value: Hello')
    await user.should_see('Changed: Hello')

    user.find(kind).type(' World')
    await user.should_see('Value: Hello World')
    await user.should_see('Changed: Hello World')

    user.find(kind).clear()
    user.find(kind).type('Test')
    await user.should_see('Value: Test')
    await user.should_see('Changed: Test')


async def test_name_property(user: User) -> None:
    @ui.page('/')
    def page():
        ui.icon('sym-o-home')
        ui.chat_message('Hello NiceGUI!', name='my chat partner')

        with ui.carousel():
            with ui.carousel_slide(name='first slide'):
                ui.label('one')
            with ui.carousel_slide(name='second slide'):
                ui.label('two')

        with ui.tabs():
            ui.tab(name='home tab', label='Home', icon='home')
            ui.tab(name='about tab', label='About', icon='info')

        with ui.stepper():
            with ui.step(name='step 1'):
                ui.label('Make a plan')

    await user.open('/')

    # name is visible for icon and chat message
    await user.should_see('sym-o-home')
    await user.should_see('my chat partner')

    # name is purely internal to the carousel, tabs and stepper
    await user.should_not_see('first slide')
    await user.should_not_see('second slide')
    await user.should_not_see('home tab')
    await user.should_not_see('about tab')
    await user.should_not_see('step 1')


async def test_should_not_see(user: User) -> None:
    @ui.page('/')
    def page():
        ui.label('Hello')

    await user.open('/')
    await user.should_not_see('World')
    await user.should_see('Hello')


async def test_should_not_see_notification(user: User) -> None:
    @ui.page('/')
    def page():
        ui.button('Notify', on_click=lambda: ui.notification('Hello'))

    await user.open('/')
    await user.should_not_see('Hello')
    user.find('Notify').click()
    await user.should_see('Hello')
    with pytest.raises(AssertionError):
        await user.should_not_see('Hello')
    user.find('Hello').trigger('dismiss')
    await user.should_not_see('Hello')


async def test_trigger_event(user: User) -> None:
    @ui.page('/')
    def page():
        ui.input().on('keydown.enter', lambda: ui.notify('Enter pressed'))

    await user.open('/')
    user.find(ui.input).trigger('keydown.enter')
    await user.should_see('Enter pressed')


@pytest.mark.parametrize('args_value,expected', [
    ({'clientX': 100, 'clientY': 200}, "{'clientX': 100, 'clientY': 200}"),
    (False, 'False'),
    (True, 'True'),
    (0, '0'),
    (42, '42'),
    (-17, '-17'),
    (3.14, '3.14'),
    ('', "''"),
    ('hello', "'hello'"),
    ([], '[]'),
    ([1, 2, 3], '[1, 2, 3]'),
    ({}, '{}'),
    ({'nested': {'key': 'value'}}, "{'nested': {'key': 'value'}}"),
    (None, '{}'),
])
async def test_trigger_with_event_arguments(user: User, args_value: Any, expected: str) -> None:
    @ui.page('/')
    def page():
        ui.button('Click').on('click', lambda e: ui.notify(f'{e.args!r}'))

    await user.open('/')
    user.find('Click').trigger('click', args=args_value)
    await user.should_see(expected)


async def test_click_link(user: User) -> None:
    @ui.page('/')
    def page():
        ui.link('go to other', '/other')

    @ui.page('/other')
    def other():
        ui.label('Other page')

    await user.open('/')
    user.find('go to other').click()
    await user.should_see('Other page')


async def test_kind_content_marker_combinations(user: User) -> None:
    @ui.page('/')
    def page():
        ui.label('One')
        ui.button('Two')
        ui.button('Three').mark('three')

    await user.open('/')
    await user.should_see(content='One')
    await user.should_see(kind=ui.button)
    await user.should_see(kind=ui.button, content='Two')
    with pytest.raises(AssertionError):
        await user.should_see(kind=ui.button, content='One')
    await user.should_see(marker='three')
    await user.should_see(kind=ui.button, marker='three')
    with pytest.raises(AssertionError):
        await user.should_see(marker='three', content='One')


async def test_page_to_string_output_used_in_error_messages(user: User) -> None:
    @ui.page('/')
    def page():
        ui.label('Hello').mark('first')
        with ui.row():
            with ui.column():
                ui.button('World').mark('second')
                ui.icon('thumbs-up').mark('third')
        ui.avatar('star')
        ui.input('some input', placeholder='type here', value='typed')
        ui.markdown('''## Markdown
                    - A
                    - B
                    - C
                    ''')
        with ui.card().tight():
            ui.image('/image.jpg')

    await user.open('/')
    output = str(user.current_layout)
    pattern = textwrap.dedent(r'''
        q-layout
         q-page-container
          q-page
           div
            Label \[markers=first, text=Hello\]
            Row
             Column
              Button \[markers=second, label=World\]
              Icon \[markers=third, name=thumbs-up\]
            Avatar \[icon=star\]
            Input \[value=typed, label=some input, for=c10, placeholder=type here, type=text\]
            Markdown \[content=\#\# Markdown..., resource-name=[^\]]+\]
            Card
             Image \[src=/image.jpg\]
    ''').strip()
    assert re.fullmatch(pattern, output) is not None


async def test_combined_filter_parameters(user: User) -> None:
    @ui.page('/')
    def page():
        ui.input(placeholder='x', value='y')

    await user.open('/')
    await user.should_see('x')
    await user.should_see('y')
    await user.should_not_see('x y')


async def test_typing(user: User) -> None:
    @ui.page('/')
    def page():
        ui.label('Hello!')
        ui.button('World!')

    await user.open('/')
    # NOTE we have not yet found a way to test the typing suggestions automatically
    # to test, hover over the variable and verify that your IDE inferres the correct type
    _ = user.find(kind=ui.label).elements  # Set[ui.label]
    _ = user.find(ui.label).elements  # Set[ui.label]
    _ = user.find('World').elements  # Set[ui.element]
    _ = user.find('Hello').elements  # Set[ui.element]
    _ = user.find('!').elements  # Set[ui.element]


async def test_select(user: User) -> None:
    @ui.page('/')
    def page():
        ui.select(options=['A', 'B', 'C'], on_change=lambda e: ui.notify(f'Value: {e.value}'))

    await user.open('/')
    await user.should_not_see('A')
    await user.should_not_see('B')
    await user.should_not_see('C')
    user.find(ui.select).click()
    await user.should_see('B')
    await user.should_see('C')
    user.find('A').click()
    await user.should_see('Value: A')
    await user.should_see('A')
    await user.should_not_see('B')
    await user.should_not_see('C')


async def test_select_from_dict(user: User) -> None:
    @ui.page('/')
    def page():
        ui.select(options={'value A': 'label A', 'value B': 'label B', 'value C': 'label C'},
                  on_change=lambda e: ui.notify(f'Notify: {e.value}'))

    await user.open('/')
    await user.should_not_see('label A')
    await user.should_not_see('label B')
    await user.should_not_see('label C')

    user.find(ui.select).click()
    await user.should_see('label A')
    await user.should_see('label B')
    await user.should_see('label C')

    user.find('label A').click()
    await user.should_see('Notify: value A')


async def test_select_multiple_from_dict(user: User) -> None:
    @ui.page('/')
    def page():
        ui.select(options={'value A': 'label A', 'value B': 'label B', 'value C': 'label C'},
                  multiple=True, on_change=lambda e: ui.notify(f'Notify: {e.value}'))

    await user.open('/')
    await user.should_not_see('label A')
    await user.should_not_see('label B')
    await user.should_not_see('label C')

    user.find(ui.select).click()
    await user.should_see('label A')
    await user.should_see('label B')
    await user.should_see('label C')

    user.find('label A').click()
    await user.should_see("Notify: ['value A']")

    user.find('label B').click()
    await user.should_see("Notify: ['value A', 'value B']")


async def test_select_multiple_values(user: User):
    select = None

    @ui.page('/')
    def page():
        nonlocal select
        select = ui.select(['A', 'B'], value='A',
                           multiple=True, on_change=lambda e: ui.notify(f'Notify: {e.value}'))
        ui.label().bind_text_from(select, 'value', backward=lambda v: f'value = {v}')

    await user.open('/')
    await user.should_see("value = ['A']")

    user.find(ui.select).click()
    user.find('B').click()
    await user.should_see("Notify: ['A', 'B']")
    await user.should_see("value = ['A', 'B']")
    assert select.value == ['A', 'B']

    user.find('A').click()
    await user.should_see("Notify: ['B']")
    await user.should_see("value = ['B']")
    assert select.value == ['B']


async def test_upload_table(user: User) -> None:
    @ui.page('/')
    def page():
        async def receive_file(e: events.UploadEventArguments) -> None:
            reader = csv.DictReader((await e.file.text()).splitlines())
            ui.table(columns=[{'name': h, 'label': h.capitalize(), 'field': h} for h in reader.fieldnames or []],
                     rows=list(reader))
        ui.upload(on_upload=receive_file)

    await user.open('/')
    upload = user.find(ui.upload).elements.pop()
    await upload.handle_uploads([ui.upload.SmallFileUpload('data.csv', 'text/csv', b'name,age\nAlice,30\nBob,28')])
    await user.should_see(ui.table)
    table = user.find(ui.table).elements.pop()
    assert table.columns == [
        {'name': 'name', 'label': 'Name', 'field': 'name'},
        {'name': 'age', 'label': 'Age', 'field': 'age'},
    ]
    assert table.rows == [
        {'name': 'Alice', 'age': '30'},
        {'name': 'Bob', 'age': '28'},
    ]


@pytest.mark.parametrize('data', ['/data', b'Hello'])
async def test_download_file(user: User, data: Union[str, bytes]) -> None:
    @app.get('/data')
    def get_data() -> PlainTextResponse:
        return PlainTextResponse('Hello')

    @ui.page('/')
    def page():
        if isinstance(data, str):
            ui.button('Download', on_click=lambda: ui.download.file(data))
        else:
            ui.button('Download', on_click=lambda: ui.download.content(data))

    await user.open('/')
    assert len(user.download.http_responses) == 0
    user.find('Download').click()
    response = await user.download.next()
    assert len(user.download.http_responses) == 1
    assert response.status_code == 200
    assert response.text == 'Hello'


async def test_validation(user: User) -> None:
    @ui.page('/')
    def page():
        ui.input('Number', validation={'Not a number': lambda value: value.isnumeric()})

    await user.open('/')
    await user.should_not_see('Not a number')
    user.find(ui.input).type('some invalid entry')
    await user.should_see('Not a number')


async def test_trigger_autocomplete(user: User) -> None:
    @ui.page('/')
    def page():
        ui.input(label='fruit', autocomplete=['apple', 'banana', 'cherry'])

    await user.open('/')
    await user.should_not_see('apple')
    user.find('fruit').type('a').trigger('keydown.tab')
    await user.should_see('apple')


async def test_seeing_invisible_elements(user: User) -> None:
    visible_label = hidden_label = None

    @ui.page('/')
    def page():
        nonlocal visible_label, hidden_label
        visible_label = ui.label('Visible')
        hidden_label = ui.label('Hidden')
        hidden_label.visible = False

    await user.open('/')
    with pytest.raises(AssertionError):
        await user.should_see('Hidden')
    with pytest.raises(AssertionError):
        await user.should_not_see('Visible')

    visible_label.visible = False
    hidden_label.visible = True
    await user.should_see('Hidden')
    await user.should_not_see('Visible')


async def test_finding_invisible_elements(user: User) -> None:
    button = None

    @ui.page('/')
    def page():
        nonlocal button
        button = ui.button('click me', on_click=lambda: ui.label('clicked'))
        button.visible = False

    await user.open('/')
    with pytest.raises(AssertionError):
        user.find('click me').click()

    button.visible = True
    user.find('click me').click()
    await user.should_see('clicked')


async def test_page_to_string_output_for_invisible_elements(user: User) -> None:
    @ui.page('/')
    def page():
        ui.label('Visible')
        ui.label('Hidden').set_visibility(False)

    await user.open('/')
    output = str(user.current_layout)
    assert output == textwrap.dedent('''
        q-layout
         q-page-container
          q-page
           div
            Label [text=Visible]
            Label [text=Hidden, visible=False]
    ''').strip()


async def test_typing_to_disabled_element(user: User) -> None:
    initial_value = 'Hello first'
    given_new_input = 'Hello second'

    target = None

    @ui.page('/')
    def page():
        nonlocal target
        target = ui.input(value=initial_value)
        target.disable()

    await user.open('/')
    user.find(initial_value).type(given_new_input)

    assert target.value == initial_value
    await user.should_see(initial_value)
    await user.should_not_see(given_new_input)


async def test_clearing_disabled_element(user: User) -> None:
    initial_value = 'Cannot clear this'
    target = None

    @ui.page('/')
    def page():
        nonlocal target
        target = ui.input(value=initial_value)
        target.disable()

    await user.open('/')
    user.find(ui.input).clear()

    assert target.value == initial_value
    await user.should_see(initial_value)


async def test_drawer(user: User):
    @ui.page('/')
    def test_page():
        with ui.left_drawer() as drawer:
            ui.label('Hello')
        ui.label().bind_text_from(drawer, 'value', lambda v: f'Drawer: {v}')

    await user.open('/')
    await user.should_see('Hello')
    await user.should_see('Drawer: True')


async def test_run_javascript(user: User):
    @ui.page('/')
    async def page():
        await ui.context.client.connected()
        date = await ui.run_javascript('Math.sqrt(1764)')
        ui.label(date)

    user.javascript_rules[re.compile(r'Math.sqrt\((\d+)\)')] = lambda match: int(match.group(1))**0.5
    await user.open('/')
    await user.should_see('42')


async def test_context_manager(user: User) -> None:
    @ui.page('/')
    def page():
        ui.button('click me')

    await user.open('/')
    with user:
        elements = list(ElementFilter(kind=ui.button))
    assert len(elements) == 1 and isinstance(elements[0], ui.button)


async def test_tree_with_labels(user: User) -> None:
    tree = None

    @ui.page('/')
    def page():
        nonlocal tree
        tree = ui.tree([
            {'name': 'A', 'children': [
                {'name': 'A1'},
                {'name': 'A2', 'children': [
                    {'name': 'A21'},
                    {'name': 'A22'},
                ]},
            ]},
        ], node_key='name', label_key='name')

    await user.open('/')
    await user.should_see('A')
    await user.should_see('A1')
    await user.should_see('A2')
    await user.should_see('A21')
    await user.should_see('A22')

    user.find('A2').click()
    await user.should_not_see('A21')
    await user.should_not_see('A22')

    user.find('A').click()
    await user.should_not_see('A1')

    user.find('A').click()
    await user.should_see('A1')
    await user.should_not_see('A21')
    await user.should_not_see('A22')

    tree.expand()
    await user.should_see('A21')
    await user.should_see('A22')

    tree.collapse()
    await user.should_not_see('A1')
    await user.should_not_see('A21')
    await user.should_not_see('A22')


@pytest.mark.order(1)
async def test_module_import_isolation_first_test(user: User, tmp_path) -> None:  # pylint: disable=unused-argument
    """First test that imports a module with @ui.page() - should not be there in the next test.

    See https://github.com/zauberzeug/nicegui/pull/5300.
    """
    (tmp_path / 'test_isolation_module.py').write_text(textwrap.dedent('''\
        from nicegui import ui

        value = "from_first_test"

        @ui.page('/test_isolation')
        def test_page():
            ui.label('Test isolation page from first test')
    '''))

    sys.path.insert(0, str(tmp_path))
    import test_isolation_module  # type: ignore  # noqa: F401
    assert 'test_isolation_module' in sys.modules
    assert sys.modules['test_isolation_module'].value == 'from_first_test'  # type: ignore


@pytest.mark.order(2)
async def test_module_import_isolation_second_test(user: User, tmp_path) -> None:  # pylint: disable=unused-argument
    """Second test that should have a clean sys.modules without imports from previous test.

    See https://github.com/zauberzeug/nicegui/pull/5300.
    """
    assert 'test_isolation_module' not in sys.modules, \
        'test_isolation_module from previous test should not be in sys.modules'
