"""
Find all code blocks in a given rst file, and execute all of their defined modes using the defined AWS Lambda function.
"""


import argparse
import json
import os
import sys
import traceback

from pathlib import Path
from typing import Dict, List, Set, Optional
from websocket import WebSocket

SPHINX_PATH = (Path(__file__).parent.parent / 'sphinx').resolve()
sys.path.append(str(SPHINX_PATH))

from widget.chop import manual_chop, real_gnatchop

from code_block import CodeBlock, get_blocks


FUNCTION_URLS = {'prod': 'wss://backend.learn.adacore.com',
                 'sandbox': 'wss://sandbox.backend.learn.adacore.com'}

EXPECT_FAILURE_CLASSES = {
    'ada': ['ada-expect-compile-error', 'ada-run-expect-failure', 'ada-expect-prove-error'],
    'c': ['c-expect-compile-error', 'c-run-expect-failure']}

EXPECT_FAILURE_CLASS_BTNS = {
    'ada-expect-compile-error': ['compile', 'run'],
    'ada-run-expect-failure': ['run'],
    'ada-expect-prove-error': ['prove', 'prove_flow', 'prove_flow_report_all', 'prove_report_all'],
    'c-expect-compile-error': ['compile', 'run'],
    'c-run-expect-failure': ['run']
}

class CodeBlockException(Exception):
    pass

def parse_args() -> argparse.Namespace:
    """Parse program arguments

    Returns:
        argparse.Namespace: parsed arguments object
    """
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('rst_files', type=str, nargs="+", help="The rst file from which to extract doc")
    parser.add_argument('--verbose', '-v', action='store_true', help='Show more information')
    parser.add_argument('--env', type=str, default='prod', choices=['sandbox', 'prod'],
                        help='Whether to use the prod or sandbox environment to run the code blocks')
    parser.add_argument('--projects', type=str, default='',
                        help='Comma seperated string of project names to run e.g. Proj_1,Proj_2,Other_proj')
    parser.add_argument('--halt-on-failure', action='store_true', help='Exit with non-zero exit status on test failure')
    args = parser.parse_args()
    args.rst_files = [os.path.abspath(f) for f in args.rst_files]
    args.projects = set() if len(args.projects) == 0 else set(args.projects.split(','))
    return args


def get_code_blocks(rst_file: str) -> List[CodeBlock]:
    """Reads the provided rst_file and extracts all CodeBlocks from it. Any
    CodeBlocks that have a language other than Ada or c are filtered out.
    The ordering of CodeBlocks is maintained relative to how they appear in the file.

    Args:
        rst_file (str): path to rst file to parsed

    Raises:
        CodeBlockException: if the project name of a CodeBlock is not set

    Returns:
        List[CodeBlock]: a List of CodeBlocks found in the rst file
    """
    with open(rst_file) as f:
        content = f.read()
    all_blocks = get_blocks(content, rst_file)
    filtered_blocks = []
    for b in all_blocks:
        if not isinstance(b, CodeBlock): continue
        if not b.language in ['ada', 'c']: continue
        if b.project is None:
            raise CodeBlockException(f"Error: project not set: {b.loc}")
        filtered_blocks.append(b)
    return filtered_blocks


def group_by_project(blocks: List[CodeBlock]) -> Dict[str, List[CodeBlock]]:
    """Group a list of CodeBlocks by their project name. The ordering of CodeBlocks is maintained

    Args:
        blocks (List[CodeBlock]): CodeBlocks ot be grouped

    Returns:
        Dict[str, List[CodeBlock]]: map of projects to a list of CodeBlocks that share that project name
    """
    projects: Dict[str, List[CodeBlock]] = {}
    for b in code_blocks:
        if b.project not in projects:
            projects[b.project] = []
        projects[b.project].append(b)
    return projects


def filter_projects(projects: Dict[str, List[CodeBlock]],
                    keep: Set[str]) -> Dict[str, List[CodeBlock]]:
    """Filter a dictionary of projects, only keeping projects whose name is in `keep`.
    If `keep` is emtpy, it will return all projects.

    Args:
        projects (Dict[str, List[CodeBlock]]): dict of projects to filter
        keep (Set[str]): Optional set of project names to keep.

    Returns:
        Dict[str, List[CodeBlock]]: filtered dict of projects
    """
    if len(keep) == 0:
        return projects
    return {k: v for k, v in projects.items() if k in keep}


def call_websocket_endpoint(name: str,
                            mode: str,
                            main_file: str,
                            switches: Dict[str, List[str]],
                            files: List[Dict[str, str]],
                            websocket_url: str) -> Optional[List[dict]]:
    """Call the websocket endpoint with the provided information

    Args:
        name (str): used to identify a run
        mode (str): mode to be executed. Relates the button being pressed
        main_file (str): file name of the main file. Empty if one is not defined
        switches (Dict[str, List[str]]): map of switch type, to a list of switches to be set
        files (List[Dict[str, str]]): List of dicts that contain filenames, and file contents
        websocket_url (str): websocket url to be used

    Returns:
        Optional[List[dict]]: list of responses if successful, None otherwise
    """
    msg = msg = {
        "action": "execute",
        "data": {
            "files": files,
            "main": main_file,
            "mode": mode,
            "switches": switches,
            "name": name,
            "lab": False
            }
        }
    retry_count = 5
    all_exceptions = []
    while retry_count > 0:
        retry_count -= 1
        try:
            ws = WebSocket()
            ws.connect(websocket_url)
            ws.send(json.dumps(msg))
            received = None
            result = []
            result_with_msg = [msg]
            while True:
                received = json.loads(ws.recv())
                result.append(received)
                if 'connectionId' in received:
                    break
                if received['completed']:
                    break
            ws.close()
            result_with_msg.extend(result)
            return result_with_msg
        except Exception as e:
            print(f"Retry: {name}")
            all_exceptions.append[e]
    print(f"Failed to run: {name}: {all_exceptions}")
    return None


def run_code_block(block: CodeBlock,
                   deps: Dict[str, str],
                   websocket_url: str,
                   verbose: bool,
                   halt_on_failure: bool):
    """Run a single code block using the provided url. `deps` should contain a mapping of file names to file
    contents from the same project, but earlier in the file. This is because projects files are accumulated if not
    overwritten. `deps` will also be updated with all files in the current CodeBlock.

    Args:
        block (CodeBlock): to be run
        deps (Dict[str, str]): dict of file names to file contents files from earlier CodeBlocks in the same project
        websocket_url (str): url of the websocket endpoint that should be used to execute a task
        verbose (bool): if True, extra log statements will be emitted
        halt_on_failure (bool): if True and a test fails, exit with a non-zero exit status
    """#
    # Extract source files from the current editor block
    if block.manual_chop:
        source_files = manual_chop(block.text.splitlines())
    else:
        source_files = real_gnatchop(block.text.splitlines())

    # Replace any existing project files with the same name
    for f in source_files:
        deps[f.basename] = f.content

    # Update the list of source files with the previously found files
    source_files = [{'basename': f, 'contents': deps[f]} for f in deps]

    # Skip the CodeBlock if ...
    no_check = any(sphinx_class in ["ada-nocheck", "c-nocheck"] for sphinx_class in block.classes)
    if no_check or not block.run or 'ada-syntax-only' in block.classes or 'ada-norun' in block.classes:
        if verbose:
            print(f"Skipping code block: {block.loc}")
        return

    # If there are no source files something went wrong
    if len(source_files) == 0:
        print(f"Skipping code block: Failed to chop example: {block.loc}")
        return

    main_file = "" if block.main_file is None else block.main_file

    switches = {}
    if len(block.compiler_switches) > 0:
        switches['Compiler'] = block.compiler_switches

    # For each button, run the CodeBlock using the lambda function
    for b in block.buttons:
        if b == 'no':
            continue
        name = f"{block.project}.{block.loc}.{b}"
        res = call_websocket_endpoint(name=name,
                                      mode=b,
                                      main_file=main_file,
                                      switches=switches,
                                      files=source_files,
                                      websocket_url=websocket_url)
        if res is None:
            continue
        if 'connectionId' in res[-1]:
            print(f"AWS error: {name}: {res}")
            continue
        # If it ran, check that the exit status matched the expectation
        exit_status = res[-1]['status']
        expect_failure = False
        for c in EXPECT_FAILURE_CLASSES[block.language]:
            if c in block.classes and b in EXPECT_FAILURE_CLASS_BTNS[c]:
                expect_failure = True
                break
        if expect_failure and exit_status == 0:
            print(f"Fail: {name}: expected failure, received zero exit status: {res}")
            if halt_on_failure:
                exit(1)
        elif not expect_failure and exit_status != 0:
            print(f"Fail: {name}: expected success, received non-zero exit status: {res}")
            if halt_on_failure:
                exit(2)
        elif verbose:
            print(f"Pass: {name}")


def run_all_projects(projects: Dict[str, List[CodeBlock]],
                     websocket_url: str,
                     verbose: bool,
                     halt_on_failure: bool):
    """Run all of the CodeBlocks in `projects` using the websocket url

    Args:
        projects (Dict[str, List[CodeBlock]]): dict of project names that each map to a list of CodeBlocks to be run
        websocket_url (str): url of the websocket endpoint that should be used to execute a task
        verbose (bool): if True, extra log statements will be emitted
        halt_on_failure (bool): if True and a test fails, exit with a non-zero exit status
    """
    all_deps = {}
    for p in projects:
        for b in projects[p]:
            if p not in all_deps:
                all_deps[p] = {}
            run_code_block(b, all_deps[p], websocket_url, verbose, halt_on_failure)


if __name__ == "__main__":
    args = parse_args()
    lambda_url = FUNCTION_URLS[args.env]
    for f in args.rst_files:
        code_blocks = get_code_blocks(f)
        projects = group_by_project(code_blocks)
        projects = filter_projects(projects, args.projects)
        run_all_projects(projects, lambda_url, args.verbose, args.halt_on_failure)
