from typing import List
from crystal import __version__ as crystal_version
from crystal.tests.util.asserts import assertEqual, assertIn, assertNotIn
from crystal.tests.util.cli import PROJECT_PROXY_REPR_STR, WINDOW_PROXY_REPR_STR, close_main_window, close_open_or_create_dialog, create_new_empty_project, delay_between_downloads_minimized, drain, py_eval, py_eval_literal, read_until, wait_for_crystal_to_exit, crystal_shell
from crystal.tests.util.server import served_project
from crystal.tests.util.subtests import SubtestsContext, with_subtests
from crystal.tests.util.wait import (
    DEFAULT_WAIT_TIMEOUT, wait_for_sync,
)
from crystal.util.xthreading import fg_call_and_wait
from io import TextIOBase
import os
import re
import sys
import tempfile
import textwrap
import time
from unittest import skip
from unittest.mock import ANY
import urllib

_EXPECTED_PROXY_PUBLIC_MEMBERS = []  # type: List[str]

_EXPECTED_PROJECT_PUBLIC_MEMBERS = [
    'FILE_EXTENSION',
    'OPENER_FILE_EXTENSION',
    'PARTIAL_FILE_EXTENSION',
    'add_task',
    'close',
    'default_url_prefix',
    'get_display_url',
    'get_resource',
    'get_resource_group',
    'get_root_resource',
    'hibernate_tasks',
    'html_parser_type',
    'is_dirty',
    'is_untitled',
    'is_valid',
    'listeners',
    'load_urls',
    'major_version',
    'min_fetch_date',
    'path',
    'readonly',
    'request_cookie',
    'request_cookie_applies_to',
    'request_cookies_in_use',
    'resource_groups',
    'resources',
    'resources_matching_pattern',
    'root_resources',
    'root_task',
    'save_as',
    'unhibernate_tasks',
    'urls_matching_pattern',
]

_EXPECTED_WINDOW_PUBLIC_MEMBERS = [
    'close',
    'entity_tree',
    'project',
    'start_server',
    'task_tree',
    'try_close',
]

# ------------------------------------------------------------------------------
# Tests

@with_subtests
def test_can_launch_with_shell(subtests: SubtestsContext) -> None:
    with crystal_shell() as (crystal, banner):
        with subtests.test(msg='with informative banner'):
            # ...containing Crystal's version
            assertIn(f'Crystal {crystal_version}', banner)
            # ...containing the Python version
            python_version = '.'.join([str(x) for x in sys.version_info[:3]])
            assertIn(f'Python {python_version}', banner)
            # ...mentioning the "help" command
            assertIn('"help"', banner)
            # ...mentioning the "project" and "window" variables
            assertIn('"project"', banner)
            assertIn('"window"', banner)
            # ...mentioning how to exit (with both "exit" and Ctrl-D (or Ctrl-Z plus Return))
            assert (
                'Use exit() or Ctrl-D (i.e. EOF) to exit.' in banner or
                'Use exit() or Ctrl-Z plus Return to exit.' in banner
            ), banner
        
        with subtests.test(msg='and {project, window} can be used as placeholders, before main window appears'):
            assertEqual(PROJECT_PROXY_REPR_STR, py_eval(crystal, 'project'))
            assertEqual(WINDOW_PROXY_REPR_STR, py_eval(crystal, 'window'))
            
            assertIn('Help on _Proxy in module ', py_eval(crystal, 'help(project)'))
            assertIn('Help on _Proxy in module ', py_eval(crystal, 'help(window)'))
            
            # Ensure public members match expected set
            assertEqual(repr(_EXPECTED_PROXY_PUBLIC_MEMBERS) + '\n',
                py_eval(crystal, "[x for x in dir(project) if not x.startswith('_')]"))
            assertEqual(repr(_EXPECTED_PROXY_PUBLIC_MEMBERS) + '\n',
                py_eval(crystal, "[x for x in dir(window) if not x.startswith('_')]"))
        
        # Open MainWindow by creating new empty project
        create_new_empty_project(crystal)
        
        with subtests.test(msg='and {project, window} can be used for real, after main window appears'):
            assert re.fullmatch(
                r'^<crystal\.model\.Project object at 0x[0-9a-f]+>\n$',
                py_eval(crystal, 'project'))
            assert re.fullmatch(
                r'^<crystal\.browser\.MainWindow object at 0x[0-9a-f]+>\n$',
                py_eval(crystal, 'window'))
        
        with subtests.test(msg='and {project, window} can be used with help()'):
            assertIn('Help on Project in module crystal.model object:', py_eval(crystal, 'help(project)'))
            assertIn('Help on MainWindow in module crystal.browser object:', py_eval(crystal, 'help(window)'))


def test_can_use_pythonstartup_file() -> None:
    with tempfile.NamedTemporaryFile(suffix='.py') as startup_file:
        startup_file.write(textwrap.dedent('''\
            EXCLUDED_URLS = ['a', 'b', 'c']
            '''
        ).encode('utf-8'))
        startup_file.flush()
        
        with crystal_shell(env_extra={'PYTHONSTARTUP': startup_file.name}) as (crystal, _):
            assertEqual(
                ['a', 'b', 'c'],
                py_eval_literal(crystal, 'EXCLUDED_URLS'),
                'Expected variables written at top-level by '
                    '$PYTHONSTARTUP file to be accessible from shell'
            )


# NOTE: This test code was split out of the test_can_launch_with_shell() test above
#       because it is particularly easy to break and having a separate test function
#       makes the break type quicker to identify.
@with_subtests
def test_builtin_globals_have_stable_public_api(subtests: SubtestsContext) -> None:
    with crystal_shell() as (crystal, _):
        # Open MainWindow by creating new empty project
        create_new_empty_project(crystal)
        
        with subtests.test(global_name='project'):
            assertEqual(_EXPECTED_PROJECT_PUBLIC_MEMBERS,
                py_eval_literal(crystal, "[x for x in dir(project) if not x.startswith('_')]"),
                'Public API of Project class has changed')
        
        with subtests.test(global_name='window'):
            assertEqual(_EXPECTED_WINDOW_PUBLIC_MEMBERS,
                py_eval_literal(crystal, "[x for x in dir(window) if not x.startswith('_')]"),
                'Public API of MainWindow class has changed')


@with_subtests
def test_shell_exits_with_expected_message(subtests: SubtestsContext) -> None:
    with subtests.test(case='test when first open/create dialog is closed given shell is running then shell remains running'):
        with crystal_shell() as (crystal, _):
            close_open_or_create_dialog(crystal)
            
            try:
                assertEqual('4\n', py_eval(crystal, '2 + 2'))
            except AssertionError as e:
                raise AssertionError(f'{e} Trailing output: {drain(crystal)!r}') from None
    
    with subtests.test(case='test when main window or non-first open/create dialog is closed given shell is running then shell remains running'):
        with crystal_shell() as (crystal, _):
            create_new_empty_project(crystal)
            close_main_window(crystal)
            
            close_open_or_create_dialog(crystal)
            assertEqual(
                4,
                py_eval_literal(crystal, '2 + 2', timeout=5.0)  # took >4.0s in Linux CI
            )
    
    for exit_method in ('exit()', 'Ctrl-D'):
        with subtests.test(case=f'test when {exit_method} given first open/create dialog is already closed then exits'):
            with crystal_shell() as (crystal, _):
                assert isinstance(crystal.stdin, TextIOBase)
                
                close_open_or_create_dialog(crystal)
                
                if exit_method == 'exit()':
                    py_eval(crystal, 'exit()', stop_suffix='')
                elif exit_method == 'Ctrl-D':
                    crystal.stdin.close()  # Ctrl-D
                else:
                    raise AssertionError()
                
                wait_for_crystal_to_exit(
                    crystal,
                    timeout=DEFAULT_WAIT_TIMEOUT)
        
        with subtests.test(case=f'test when {exit_method} given non-first open/create dialog is already closed then exits'):
            with crystal_shell() as (crystal, _):
                assert isinstance(crystal.stdin, TextIOBase)
                
                create_new_empty_project(crystal)
                close_main_window(crystal)
                
                close_open_or_create_dialog(crystal)
                
                if exit_method == 'exit()':
                    py_eval(
                        crystal, 'exit()', stop_suffix='',
                        timeout=5.0)  # took 4.0s in Linux CI
                elif exit_method == 'Ctrl-D':
                    crystal.stdin.close()  # Ctrl-D
                else:
                    raise AssertionError()
                
                wait_for_crystal_to_exit(
                    crystal,
                    timeout=5.0)  # took >4.0s in Linux CI
    
    for exit_method in ('exit()', 'Ctrl-D'):
        with subtests.test(case=f'test when {exit_method} given first open/create dialog still open then prints waiting message and does not exit'):
            with crystal_shell() as (crystal, _):
                assert isinstance(crystal.stdin, TextIOBase)
                
                close_open_or_create_dialog(crystal, after_delay=.5)
                
                if exit_method == 'exit()':
                    py_eval(crystal, 'exit()', stop_suffix='now waiting for all windows to close...\n')
                elif exit_method == 'Ctrl-D':
                    crystal.stdin.close()  # Ctrl-D
                    read_until(crystal.stdout, 'now waiting for all windows to close...\n')
                else:
                    raise AssertionError()
                
                wait_for_crystal_to_exit(
                    crystal,
                    timeout=.5 + DEFAULT_WAIT_TIMEOUT)
        
        with subtests.test(case=f'test when {exit_method} given main window still open then prints waiting message and does not exit'):
            with crystal_shell() as (crystal, _):
                assert isinstance(crystal.stdin, TextIOBase)
                
                create_new_empty_project(crystal)
                
                close_main_window(crystal, after_delay=.5)
                close_open_or_create_dialog(crystal, after_delay=.5*2)
                
                if exit_method == 'exit()':
                    py_eval(crystal, 'exit()', stop_suffix='now waiting for all windows to close...\n')
                elif exit_method == 'Ctrl-D':
                    crystal.stdin.close()  # Ctrl-D
                    read_until(crystal.stdout, 'now waiting for all windows to close...\n')
                else:
                    raise AssertionError()
                
                wait_for_crystal_to_exit(
                    crystal,
                    timeout=.5*2 + DEFAULT_WAIT_TIMEOUT)
        
        with subtests.test(case=f'test when {exit_method} given non-first open/create dialog still open then prints waiting message and does not exit'):
            with crystal_shell() as (crystal, _):
                assert isinstance(crystal.stdin, TextIOBase)
                
                create_new_empty_project(crystal)
                close_main_window(crystal)
                
                close_open_or_create_dialog(crystal, after_delay=.5)
                
                if exit_method == 'exit()':
                    py_eval(crystal, 'exit()', stop_suffix='now waiting for all windows to close...\n')
                elif exit_method == 'Ctrl-D':
                    crystal.stdin.close()  # Ctrl-D
                    read_until(crystal.stdout, 'now waiting for all windows to close...\n')
                else:
                    raise AssertionError()
                
                wait_for_crystal_to_exit(
                    crystal,
                    timeout=.5 + DEFAULT_WAIT_TIMEOUT)


def test_when_typed_code_raises_exception_then_print_traceback() -> None:
    with crystal_shell() as (crystal, _):
        expected_traceback = (
            'Traceback (most recent call last):\n'
            '  File "<console>", line 1, in <module>\n'
            'NameError: name \'Resource\' is not defined\n'
        )
        assertEqual(expected_traceback, py_eval(crystal, 'Resource'))


@with_subtests
def test_can_read_project_with_shell(subtests: SubtestsContext) -> None:
    with served_project('testdata_xkcd.crystalproj.zip') as sp:
        # Define URLs
        home_url = 'https://xkcd.com/'
        
        project_path = sp.project.path  # capture
        fg_call_and_wait(lambda: sp.project.close())
        
        with crystal_shell() as (crystal, _):
            with subtests.test(case='test can open project', return_if_failure=True):
                # Test can import Project
                assertEqual('', py_eval(crystal, 'from crystal.model import Project'))
                # Test can open project
                assertEqual('', py_eval(crystal, f'p = Project({sp.project.path!r})'))
            
            with subtests.test(case='test can list project entities'):
                assertEqual(
                    "[RootResource('Home','https://xkcd.com/')]\n",
                    py_eval(crystal, 'list(p.root_resources)[:1]'))
                assertEqual(
                    "[ResourceGroup('Comics','https://xkcd.com/#/')]\n",
                    py_eval(crystal, 'list(p.resource_groups)'))
                assertEqual(
                    '71\n',
                    py_eval(crystal, 'len(p.resources)'))
                assertEqual(
                    "Resource('https://xkcd.com/')\n",
                    py_eval(crystal, 'list(p.resources)[0]'))
            
            with subtests.test(case='test can get project entities', return_if_failure=True):
                assertEqual(
                    "Resource('https://xkcd.com/')\n",
                    py_eval(crystal, f'r = p.get_resource({home_url!r}); r'))
                
                assertEqual(
                    "[<ResourceRevision 1 for 'https://xkcd.com/'>]\n",
                    py_eval(crystal, f'list(r.revisions())'))
                assertEqual(
                    "<ResourceRevision 1 for 'https://xkcd.com/'>\n",
                    py_eval(crystal, f'rr = r.default_revision(); rr'))
                
                assertEqual(
                    "RootResource('Home','https://xkcd.com/')\n",
                    py_eval(crystal, f'root_r = p.get_root_resource(r); root_r'))
                
                assertEqual(
                    "ResourceGroup('Comics','https://xkcd.com/#/')\n",
                    py_eval(crystal, f'rg = p.get_resource_group("Comics"); rg'))
                assertEqual(
                    '14\n',
                    py_eval(crystal, f'len(rg.members)'))
                assertEqual(
                    "Resource('https://xkcd.com/1/')\n",
                    py_eval(crystal, f'list(rg.members)[0]'))
            
            with subtests.test(case='test can read content of resource revision'):
                assertEqual(
                    {
                        'http_version': 11,
                        'status_code': 200,
                        'reason_phrase': 'OK',
                        'headers': ANY
                    },
                    py_eval_literal(crystal, f'rr.metadata'))
                py_eval(crystal, f'with rr.open() as f:\n    body = f.read()\n', stop_suffix='>>> ')
                assertEqual(
                    r"""b'<!DOCTYPE html>\n<html>\n<head>\n<link rel="stylesheet" type="text/css" href="/s/7d94e0.css" title="Default"/>\n<title>xkcd: Air Gap</title>\n'""" + '\n',
                    py_eval(crystal, f'body[:137]'))
            
            with subtests.test(case='test can serve resource revision'):
                # Test can import ProjectServer
                assertEqual('', py_eval(crystal, 'from crystal.server import ProjectServer'))
                assertEqual('', py_eval(crystal, 'from io import StringIO'))
                # Test can start ProjectServer
                assertEqual(
                    "",
                    py_eval(
                        crystal, f'server = ProjectServer(p, stdout=StringIO())',
                        timeout=8.0  # 2.0s and 4.0s isn't long enough for macOS test runners on GitHub Actions
                    ))
                port = py_eval_literal(crystal, f'server.port')
                request_url = py_eval_literal(crystal, f'server.get_request_url({home_url!r})')
                
                # Test ProjectServer serves resource revision
                assertIn(str(port), request_url)
                with urllib.request.urlopen(request_url) as response:
                    response_bytes = response.read()
                assertIn(b'<title>xkcd: Air Gap</title>', response_bytes)


@with_subtests
def test_can_write_project_with_shell(subtests: SubtestsContext) -> None:
    with served_project('testdata_xkcd.crystalproj.zip') as sp:
        # Define URLs
        if True:
            home_url = sp.get_request_url('https://xkcd.com/')
            
            comic1_url = sp.get_request_url('https://xkcd.com/1/')
            comic2_url = sp.get_request_url('https://xkcd.com/2/')
            comic_pattern = sp.get_request_url('https://xkcd.com/#/')
            
            atom_feed_url = sp.get_request_url('https://xkcd.com/atom.xml')
            rss_feed_url = sp.get_request_url('https://xkcd.com/rss.xml')
            feed_pattern = sp.get_request_url('https://xkcd.com/*.xml')
        
        # Create named temporary directory that won't be deleted automatically
        with tempfile.NamedTemporaryFile(suffix='.crystalproj', delete=False) as project_td:
            pass
        os.remove(project_td.name)
        project_dirpath = project_td.name
        
        with crystal_shell() as (crystal, _):
            with subtests.test(case='test can create project', return_if_failure=True):
                # Test can import Project
                assertEqual('', py_eval(crystal, 'from crystal.model import Project'))
                # Test can create project
                assertEqual('', py_eval(crystal, f'p = Project({project_dirpath!r})',
                    # 2.0s isn't long enough for macOS test runners on GitHub Actions
                    timeout=4.0))
            
            with subtests.test(case='test can create project entities', return_if_failure=True):
                # Test can import Resource
                assertEqual('', py_eval(crystal, 'from crystal.model import Resource'))
                # Test can create Resource
                assertEqual(
                    "Resource('http://127.0.0.1:2798/_/https/xkcd.com/')\n",
                    py_eval(crystal, f'r = Resource(p, {home_url!r}); r'))
                
                # Test can import RootResource
                assertEqual('', py_eval(crystal, 'from crystal.model import RootResource'))
                # Test can create RootResource
                assertEqual(
                    "RootResource('Home','http://127.0.0.1:2798/_/https/xkcd.com/')\n",
                    py_eval(crystal, f'root_r = RootResource(p, "Home", r); root_r'))
                
                # Test can download ResourceRevision
                with delay_between_downloads_minimized(crystal):
                    assertEqual('', py_eval(crystal, 'rr_future = r.download()'))
                    # TODO: Use wait_for_sync() rather than a manual loop
                    while True:
                        is_done = (py_eval_literal(crystal, 'rr_future.done()') == True)
                        if is_done:
                            break
                        time.sleep(.2)
                    assertIn('<ResourceRevision ', py_eval(crystal, 'rr = rr_future.result(); rr'))
                
                # Test can import ResourceGroup
                assertEqual('', py_eval(crystal, 'from crystal.model import ResourceGroup'))
                # Test can create ResourceGroup
                assertEqual(
                    "ResourceGroup('Comic','http://127.0.0.1:2798/_/https/xkcd.com/#/')\n",
                    py_eval(crystal, f'rg = ResourceGroup(p, "Comic", {comic_pattern!r}); rg'))
                # Ensure ResourceGroup includes some members discovered by downloading resource Home
                def rg_member_count() -> int:
                    count = py_eval_literal(crystal, f'len(rg.members)')
                    assert isinstance(count, int)
                    return count
                wait_for_sync(lambda: 9 == rg_member_count())
            
            with subtests.test(case='test can delete project entities', return_if_failure=True):
                # Test can delete ResourceGroup
                assertEqual('', py_eval(crystal, f'rg_m = list(rg.members)[0]'))
                assertEqual('', py_eval(crystal, f'rg.delete()'))
                # Ensure ResourceGroup itself is deleted
                assertEqual('', py_eval(crystal, f'p.get_resource_group(rg.name)'))
                # Ensure former members of ResourceGroup still exist
                assertEqual('True\n', py_eval(crystal, f'p.get_resource(rg_m.url) == rg_m'))
                
                # Test can delete RootResource
                assertEqual('', py_eval(crystal, f'root_r_r = root_r.resource'))
                assertEqual('', py_eval(crystal, f'root_r.delete()'))
                # Ensure RootResource itself is deleted
                assertEqual('', py_eval(crystal, f'p.get_root_resource(root_r_r)'))
                # Ensure former target of RootResource still exists
                assertEqual('True\n', py_eval(crystal, f'p.get_resource(root_r_r.url) == root_r_r'))
                
                # Test can delete ResourceRevision
                assertEqual('', py_eval(crystal, f'rr_r = rr.resource'))
                assertEqual('1\n', py_eval(crystal, f'len(list(rr_r.revisions()))'))
                assertEqual('', py_eval(crystal, f'rr.delete()'))
                # Ensure ResourceRevision itself is deleted
                assertEqual('0\n', py_eval(crystal, f'len(list(rr_r.revisions()))'))
                
                # Test can delete Resource
                assertEqual('', py_eval(crystal, f'r.delete()'))
                # Ensure Resource itself is deleted
                assertEqual('', py_eval(crystal, f'p.get_resource(r.url)'))
            
            with subtests.test(case='test can download project entities', return_if_failure=True):
                # Recreate home Resource
                assertEqual(
                    "Resource('http://127.0.0.1:2798/_/https/xkcd.com/')\n",
                    py_eval(crystal, f'r = Resource(p, {home_url!r}); r'))
                # Recreate home RootResource
                assertEqual(
                    "RootResource('Home','http://127.0.0.1:2798/_/https/xkcd.com/')\n",
                    py_eval(crystal, f'root_r = RootResource(p, "Home", r); root_r'))
                
                # Test can download RootResource
                with delay_between_downloads_minimized(crystal):
                    assertEqual('', py_eval(crystal, 'rr_future = root_r.download()'))
                    # TODO: Use wait_for_sync() rather than a manual loop
                    while True:
                        is_done = (py_eval_literal(crystal, 'rr_future.done()') == True)
                        if is_done:
                            break
                        time.sleep(.2)
                    assertIn('<ResourceRevision ', py_eval(crystal, 'rr = rr_future.result(); rr'))
                
                # Create feed ResourceGroup
                assertEqual(
                    "ResourceGroup('Feed','http://127.0.0.1:2798/_/https/xkcd.com/*.xml')\n",
                    py_eval(crystal, f'rg = ResourceGroup(p, "Feed", {feed_pattern!r}); rg'))
                assertEqual(
                    "",
                    py_eval(crystal, f'rg.source = root_r'))
                # Ensure ResourceGroup includes some members discovered by downloading resource Home
                assertEqual(
                    2,
                    py_eval_literal(crystal, f'len(rg.members)'))
                
                # Test can download ResourceGroup
                with delay_between_downloads_minimized(crystal):
                    assertEqual('', py_eval(crystal, 'drgt = rg.download()'))
                    # TODO: Use wait_for_sync() rather than a manual loop
                    while True:
                        is_done = (py_eval_literal(crystal, 'drgt.complete') == True)
                        if is_done:
                            break
                        time.sleep(.2)
                assertEqual(
                    [True] * 2,
                    py_eval_literal(crystal, '[r.has_any_revisions() for r in rg.members]'))


@skip('covered by: test_can_write_project_with_shell')
def test_can_open_or_create_project() -> None:
    pass


@skip('covered by: test_can_write_project_with_shell')
def test_can_create_project_entities() -> None:
    pass


@skip('covered by: test_can_read_project_with_shell')
def test_can_read_project_entities() -> None:
    pass


@skip('covered by: test_can_write_project_with_shell')
def test_can_download_project_entities() -> None:
    pass


@skip('covered by: test_can_write_project_with_shell')
def test_can_delete_project_entities() -> None:
    pass


def test_can_import_guppy_in_shell() -> None:
    with crystal_shell() as (crystal, _):
        # Ensure can import guppy
        import_result = py_eval_literal(crystal, 'import guppy; guppy.__version__')
        assert isinstance(import_result, str)
        
        # Ensure can create hpy instance
        assertEqual('', py_eval(crystal, 'from guppy import hpy; h = hpy()'))
        
        # Ensure can take memory sample
        result = py_eval(crystal, 'import gc; gc.collect(); heap = h.heap(); heap; _.more')
        assertNotIn('Traceback', result)
        
        # Ensure can create checkpoint
        assertEqual('', py_eval(crystal, 'h.setref()'))
        
        # Ensure can take memory sample since checkpoint
        result = py_eval(crystal, 'import gc; gc.collect(); heap = h.heap(); heap; _.more')
        assertNotIn('Traceback', result)


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