import asyncio
from dataclasses import dataclass
from dataclasses import field
from datetime import datetime
import json
from typing import List
from pathlib import Path
import sys

import click
from openapi_client.api import ThreedimodelsApi
from threedi_api_client.threedi_api_client import ThreediApiClient


import yaml

from cmd_client.commands.slack_notify import SlackNotifier
from cmd_client.models.scenario import FailedStep
from cmd_client.parser import ScenarioParser
from cmd_client.models.scenario import Scenario
from cmd_client.console import console
from cmd_client.errors import ExitCodes
from cmd_client.errors import LoadCredentialsError
from cmd_client.errors import LoadSuiteError
from cmd_client.commands.settings import WebSocketSettings


from rich.table import Table
from rich.padding import Padding
from rich import box


class SuiteError(BaseException):
    pass


@dataclass
class SlackNotify:
    channel: str
    crashes: bool = True
    all_success: bool = True


@dataclass
class User:
    username: str
    password: str

    def __repr__(self):
        return f"User(username='{self.username}', password='********')"


@dataclass
class Credentials:
    users: List[User]
    slack_token: str = None

    @classmethod
    def load_from_file(cls, file: Path = None):
        try:
            with file.open("r") as f:
                data = yaml.load(f, Loader=yaml.FullLoader) or {}
        except OSError:
            # settings file does not yet exist
            msg = f"Could not load credentials: {file}"
            raise LoadCredentialsError(msg)
        data["credentials"]["users"] = [
            User(**x) for x in data["credentials"].get("users", [])
        ]
        return Credentials(**data["credentials"])

    def get_user(self, username: str):
        found = [x for x in self.users if x.username == username]
        assert (
            len(found) == 1
        ), f"Did not find exactly one user for {username} in the credentials"

        return found[0]


@dataclass
class SuiteScenario:
    name: str
    scenario: Scenario
    finished: bool = False
    success: bool = False
    reason: str = "scenario is not finished"
    simulation_id = None


@dataclass
class Report:
    endpoint: str
    scenarios: List[SuiteScenario]

    def simulation_url(self, suite_scenario):
        return (
            f"<{self.endpoint}/simulations/"
            + f"{suite_scenario.simulation_id}|{suite_scenario.simulation_id}>"
        )

    @property
    def title(self):
        return f"Results framework suite run on {self.endpoint}"

    def get_slack_report(self):
        report_lines = [f"{self.endpoint}:"]
        for suite_scenario in self.scenarios:
            if suite_scenario.success:
                report_lines.append(
                    f':heavy_check_mark:   "{suite_scenario.name}" '
                    f"({self.simulation_url(suite_scenario)})"
                )
            else:
                report_lines.append(
                    f':x:   "{suite_scenario.name} "'
                    f"({self.simulation_url(suite_scenario)})"
                    f" errored "
                    f"due to: {suite_scenario.reason} "
                )
        return report_lines

    def print_console_report(self):
        console.print(Padding("", (1, 0)))
        console.rule(self.title, characters="•", style="black")
        console.print(Padding("", (1, 0)))
        grid = Table(
            show_header=True,
            box=box.SIMPLE,
            show_lines=False,
            width=console.width,
        )
        grid.add_column("Name")
        grid.add_column("URL", justify="left")
        grid.add_column("Details", justify="left")
        grid.add_column("Status", justify="right")
        for suite_scenario in self.scenarios:
            if suite_scenario.success:
                grid.add_row(
                    f"{suite_scenario.name}",
                    f"{self.endpoint}/simulations/{suite_scenario.simulation_id}",
                    "",
                    "[bold magenta]COMPLETED [green]:heavy_check_mark:",
                )
            else:
                grid.add_row(
                    f"{suite_scenario.name}",
                    f"{self.endpoint}/simulations/{suite_scenario.simulation_id}",
                    f"[bold gold3]{suite_scenario.reason}",
                    "[bold magenta]FAILED [red] :x:",
                )
        console.print(grid)


@dataclass
class Suite:
    endpoint: str
    path: str
    credentials: Credentials
    organisation_uuid: str
    # override_sessions: bool
    # sessions: int
    scenarios: List[SuiteScenario]
    user: str = None
    slack_notify: SlackNotify = None
    _resolved_models = None
    report: Report = field(init=False)

    def __post_init__(self):
        self.report = Report(endpoint=self.endpoint, scenarios=self.scenarios)

    @classmethod
    def resolve_model(cls, scenario_name, model_api, values, resolved_models):
        model = None

        if "threedimodel_filter" in values:
            cache_key = json.dumps(values["threedimodel_filter"])

            if cache_key in resolved_models:
                model = resolved_models[cache_key]
            else:
                res = model_api.threedimodels_list(
                    inp_success=True,
                    disabled=False,
                    **values["threedimodel_filter"],
                )
                if res.count == 0:
                    print(
                        f"Error: no models found for "
                        f"scenario: {scenario_name}"
                    )
                    return None

                if res.count > 0:
                    console.print(
                        f"! multiple models found for lookup: {values['threedimodel_filter']}",
                        style="warning",
                    )
                model = res.results[0].id

                resolved_models[cache_key] = model

        elif "threedimodel_id" in values:
            model = values["threedimodel_id"]
        else:
            print(f"No model defined for scenario: {scenario_name}")
        return model

    @classmethod
    def load_from_file(cls, file: Path = None):
        """
        :raises LoadCredentialsError if the credentials file could not be found or loaded
        :raises TypeError if the `file` argument is not of type `Path`
        :raises LoadSuiteError if the suite could not be loaded
        """
        console.rule(f"Loading suite {file.stem}", style="bold blue")

        if not isinstance(file, Path):
            raise TypeError(f"{file} must be of type pathlib.Path")
        try:
            with open(file, "r") as f:
                data = yaml.load(f, Loader=yaml.FullLoader) or {}
        except OSError:
            msg = f"Could not load suite: {file}"
            raise LoadSuiteError(msg)
        suite = data["suite"]
        base_path = file.parent

        if "slack_notify" in suite:
            suite["slack_notify"] = SlackNotify(**suite["slack_notify"])
            console.print(
                f"Suite results will be published in slack channel {suite['slack_notify'].channel}"
            )
        if "credentials" in suite:
            suite["credentials"] = Credentials.load_from_file(
                base_path / Path(suite["credentials"])
            )
        else:
            msg = "Could not find/load credentials file"
            raise LoadCredentialsError(msg)

        resolved_models = {}

        scenarios = []
        for scenario in suite.get("scenarios", []):
            for key, values in scenario.items():

                user = values.get("user", suite.get("user", None))

                if user is None:
                    if len(suite["credentials"].users) != 1:
                        msg = "No default user specified and multiple user options in credentials"
                        raise LoadCredentialsError(msg)
                    user = suite["credentials"].users[0]
                else:
                    user = suite["credentials"].get_user(user)

                config = {
                    "API_HOST": suite["endpoint"],
                    "API_USERNAME": user.username,
                    "API_PASSWORD": user.password,
                }

                client = ThreediApiClient(config=config)
                token = client.configuration.get_api_key_with_prefix(
                    "Authorization"
                )

                model_api = ThreedimodelsApi(client)

                model = cls.resolve_model(
                    key, model_api, values, resolved_models
                )

                # Try scenario.organisation__uuid, else suite organisation_uuid
                organisation_uuid = values.get(
                    "organisation_uuid", suite["organisation_uuid"]
                )

                context = {
                    "threedimodel_id": model,
                    "organisation_uuid": organisation_uuid,
                    "simulation_name": key,
                    "datetime_now": datetime.utcnow().isoformat(),
                }

                scenario_to_run = base_path / Path(values["file"])
                websocket_settings = WebSocketSettings(
                    api_base_url=suite["endpoint"], token=token
                )
                if model is not None:
                    parser = ScenarioParser(scenario_to_run, context)
                    scenario = parser.parse(
                        client, websocket_settings, base_path=base_path
                    )
                else:
                    scenario = None

                suite_scenario = SuiteScenario(name=key, scenario=scenario)

                if model is None:
                    suite_scenario.success = False
                    suite_scenario.reason = f"could not find the model"
                    suite_scenario.finished = True

                scenarios.append(suite_scenario)
        suite["scenarios"] = scenarios
        suite["path"] = base_path
        return Suite(**suite)


async def main(suite: Path):
    """runs the entire suite"""

    try:
        suite = Suite.load_from_file(suite)
    except (TypeError, LoadCredentialsError, LoadSuiteError) as err:
        console.print(err, style="error")
        sys.exit(ExitCodes.LOADING_SUITE_ERROR.value)

    for suite_scenario in suite.scenarios:
        if suite_scenario.finished:
            continue
        console.rule("Loading scenario...", style="bold blue")
        scenario = suite_scenario.scenario
        scenario.simulation.save()

        simulation_id = scenario.simulation.instance.id
        suite_scenario.simulation_id = simulation_id
        console.rule(f"Starting scenario run...", style="bold blue")
        try:
            await scenario.execute()
            suite_scenario.success = True
            suite_scenario.finished = True
            console.print(
                f":sparkles: Successfully finished scenario run :sparkles:",
                style="success",
            )
        except KeyboardInterrupt:
            pass
        except FailedStep as e:
            console.print(
                ":collision: Error running sim: {str(e)}", style="error"
            )
            suite_scenario.success = False
            suite_scenario.finished = True
            suite_scenario.reason = str(e)
        except Exception as e:
            console.print(
                ":collision: Error running sim: {str(e)}", style="error"
            )
            suite_scenario.success = False
            suite_scenario.finished = True
            suite_scenario.reason = str(e)

    # Print report
    suite.report.print_console_report()
    if suite.slack_notify:
        notifier = SlackNotifier(suite.credentials.slack_token)
        notifier.send_message(
            "\n".join(suite.report.get_slack_report()),
            channel=suite.slack_notify.channel,
        )


@click.command(help="Run a given suite.")
@click.option(
    "--suite",
    help="path to the suite file",
    type=click.Path(exists=True),
    required=True,
)
@click.pass_context
def run(ctx: click.Context, suite: str):
    s = Path(suite)
    asyncio.run(main(s))


if __name__ == "__main__":
    run()
