import uuid
import json
import flask
import shutil
import tempfile
import webbrowser
from pathlib import Path
import concurrent.futures as futures
from werkzeug.datastructures import FileStorage
from typing import Any, Dict, List, Optional, Union

from ...widgets.apis import get_random_filepath, internal_path
from ...execution.script_execution import ScriptExecution
from ...cloud_api import get_ai_messages, get_auth_info
from ...repositories.stage_run import StageRun
from ...repositories import StageRunRepository
from ...cli.deploy import deploy
from ...settings import Settings
from ...utils import random_id

from ...credentials import (
    delete_credentials,
    get_credentials,
    resolve_headers,
    set_credentials,
)

from ...templates import (
    new_script_code,
    new_dash_layout,
    new_dash_code,
    new_form_code,
    new_hook_code,
    new_job_code,
)

from ...repositories.json.classes import (
    WorkflowTransitionJSON,
    WorkspaceJSON,
    RuntimeJSON,
    ScriptJSON,
    AbstraJSON,
    LayoutJSON,
    ScriptJSON,
    DashJSON,
    FormJSON,
    HookJSON,
    JobJSON,
    AbstraJSONRepository,
)


class UnknownNodeTypeError(Exception):
    def __init__(self, node_type: str):
        self.node_type = node_type

    def __str__(self):
        return f"Unknown node type {self.node_type}"


class SelfTransitionError(Exception):
    def __init__(self, node_type: str, node_id: str):
        self.node_type = node_type
        self.node_id = node_id

    def __str__(self):
        return f"You can't add a transition to itself."


class TransitionToJobError(Exception):
    def __init__(
        self, source_type: str, source_id: str, target_type: str, target_id: str
    ):
        self.source_type = source_type
        self.source_id = source_id
        self.target_type = target_type
        self.target_id = target_id

    def __str__(self):
        return f"You can't add a transition to a job."


class DoubleTransitionError(Exception):
    def __init__(
        self, source_type: str, source_id: str, target_type: str, target_id: str
    ):
        self.source_type = source_type
        self.source_id = source_id
        self.target_type = target_type
        self.target_id = target_id

    def __str__(self):
        return f"You can't add the same transition twice."


## TODO: rename to controller
class API:
    def __init__(self):
        self.executor = futures.ThreadPoolExecutor()
        AbstraJSONRepository.initialize_on_empty()

    def deploy(self):
        deploy()

    def get_workspace(self) -> WorkspaceJSON:
        abstra_json = AbstraJSONRepository.load()
        return abstra_json.workspace

    def get_page_runtime(self, path) -> Union[DashJSON, FormJSON, None]:
        abstra_json = AbstraJSONRepository.load()

        for form in abstra_json.forms:
            if path == form.path:
                return form

        for dash in abstra_json.dashes:
            if path == dash.path:
                return dash

        return None

    def open_file(self, file_path: str, create_if_not_exists: bool = False):
        complete_file_path = Settings.root_path.joinpath(file_path)

        if (
            create_if_not_exists
            and file_path.endswith(".py")
            and not complete_file_path.is_file()
        ):
            complete_file_path.touch()

        webbrowser.open(complete_file_path.absolute().as_uri())

    def check_file(self, file_path: str):
        return Settings.root_path.joinpath(file_path).is_file()

    def update_workspace(self, changes: Dict[str, Any]):
        abstra_json = AbstraJSONRepository.load()
        abstra_json.workspace.update(changes, abstra_json.dashes, abstra_json.forms)
        AbstraJSONRepository.save(abstra_json)
        return abstra_json.workspace

    # Script CRUDL

    def run_initial_script(self, script: ScriptJSON):
        # This was added to allow script to run without a stage run while testing
        execution = ScriptExecution(script, True)

        execution.run_sync()

        self.run_waiting_scripts(execution.stage_run)

        return {
            "stdout": "".join(execution.stdout if execution else []),
            "stderr": "".join(execution.stderr if execution else []),
        }

    def run_waiting_scripts(self, parent_stage_run: Optional[StageRun]):
        if not parent_stage_run:
            return

        next_stage_runs = StageRunRepository.find(
            {"parent_id": parent_stage_run.id, "status": "waiting"}
        )

        if len(next_stage_runs) == 0:
            return

        for next_stage_run in next_stage_runs:
            script = self.get_script(next_stage_run.stage)

            if not script:
                continue

            def run_next(script: ScriptJSON, api: API):
                execution = ScriptExecution.create_with_stage_run(
                    script, next_stage_run.id
                )
                execution.run_sync()
                api.run_waiting_scripts(execution.stage_run)

            self.executor.submit(run_next, script, self)

    def create_script(self) -> ScriptJSON:
        abstra_json = AbstraJSONRepository.load()

        file_name = f"new_script_{random_id()}"
        file = f"{file_name}.py"
        Settings.root_path.joinpath(file).write_text("", encoding="utf-8")

        script = ScriptJSON(
            path=str(uuid.uuid4()),
            file=file,
            title="Untitled Form",
            workflow_transitions=[],
            workflow_position=(0, 0),
        )

        abstra_json.scripts.append(script)

        AbstraJSONRepository.save(abstra_json)

        return script

    def get_scripts(self) -> List[ScriptJSON]:
        abstra_json = AbstraJSONRepository.load()
        return abstra_json.scripts

    def get_script(self, path: str) -> Optional[ScriptJSON]:
        abstra_json = AbstraJSONRepository.load()

        for script in abstra_json.scripts:
            if path == script.path:
                return script

        return None

    def delete_script(self, path: str):
        abstra_json = AbstraJSONRepository.load()
        scripts = abstra_json.scripts
        abstra_json.scripts = [f for f in scripts if f.path != path]

        AbstraJSONRepository.save(abstra_json)

    # Forms CRUDL

    def create_form(self) -> FormJSON:
        abstra_json = AbstraJSONRepository.load()

        file_name = f"new_form_{random_id()}"
        file = f"{file_name}.py"
        Settings.root_path.joinpath(file).write_text(new_form_code, encoding="utf-8")
        form = FormJSON(
            file=file, path=file_name, title="Untitled Form", workflow_transitions=[]
        )

        abstra_json.forms.append(form)

        AbstraJSONRepository.save(abstra_json)

        return form

    def get_forms(self) -> List[FormJSON]:
        abstra_json = AbstraJSONRepository.load()
        return abstra_json.forms

    def get_form(self, path: str) -> Optional[FormJSON]:
        abstra_json = AbstraJSONRepository.load()

        for form in abstra_json.forms:
            if path == form.path:
                return form

        return None

    def delete_form(self, path: str):
        abstra_json = AbstraJSONRepository.load()
        forms = abstra_json.forms
        abstra_json.forms = [f for f in forms if f.path != path]

        AbstraJSONRepository.save(abstra_json)

    # Dashes CRUDL

    def create_dash(self) -> DashJSON:
        abstra_json = AbstraJSONRepository.load()

        file_name = f"new_dash_{random_id()}"
        file = f"{file_name}.py"

        Settings.root_path.joinpath(file).write_text(
            encoding="utf-8", data=new_dash_code
        )
        dash = DashJSON(
            file=file,
            path=f"new-dash_{random_id()}",
            title="Untitled Dash",
            layout=LayoutJSON.from_dict(new_dash_layout),
        )

        abstra_json.dashes.append(dash)

        AbstraJSONRepository.save(abstra_json)

        return dash

    def get_dashes(self) -> List[DashJSON]:
        abstra_json = AbstraJSONRepository.load()
        return abstra_json.dashes

    def get_dash(self, path: str) -> Optional[DashJSON]:
        abstra_json = AbstraJSONRepository.load()

        for dash in abstra_json.dashes:
            if path == dash.path:
                return dash

        return None

    def delete_dash(self, path: str):
        abstra_json = AbstraJSONRepository.load()
        dashes = abstra_json.dashes
        abstra_json.dashes = [d for d in dashes if d.path != path]

        AbstraJSONRepository.save(abstra_json)

    # Hooks CRUDL

    def create_hook(self) -> HookJSON:
        abstract_json = AbstraJSONRepository.load()

        file_name = f"new_hook_{random_id()}"
        file = f"{file_name}.py"
        Settings.root_path.joinpath(file).write_text(new_hook_code, encoding="utf-8")
        hook = HookJSON(
            file=file, path=file_name, title="Untitled Hook", workflow_transitions=[]
        )

        abstract_json.hooks.append(hook)

        AbstraJSONRepository.save(abstract_json)

        return hook

    def get_hooks(self) -> List[HookJSON]:
        abstra_json = AbstraJSONRepository.load()
        return abstra_json.hooks

    def get_hook(self, path: str) -> Optional[HookJSON]:
        abstra_json = AbstraJSONRepository.load()

        for hook in abstra_json.hooks:
            if path == hook.path:
                return hook

        return None

    def delete_hook(self, path: str):
        abstra_json = AbstraJSONRepository.load()
        hooks = abstra_json.hooks
        abstra_json.hooks = [h for h in hooks if h.path != path]

        AbstraJSONRepository.save(abstra_json)

    # Jobs CRUDL

    def get_jobs(self):
        abstra_json = AbstraJSONRepository.load()
        return abstra_json.jobs

    def get_job(self, identifier: str):
        abstra_json = AbstraJSONRepository.load()
        for job in abstra_json.jobs:
            if identifier == job.identifier:
                return job
        return None

    def create_job(self):
        abstra_json = AbstraJSONRepository.load()

        file_name = f"new_job_{random_id()}"
        file = f"{file_name}.py"
        Settings.root_path.joinpath(file).write_text(new_job_code, encoding="utf-8")
        job = JobJSON(
            file=file,
            identifier=random_id(),
            schedule="0 * * * *",
            title="Untitled Job",
            workflow_position=(0, 0),
            workflow_transitions=[],
        )

        abstra_json.jobs.append(job)

        AbstraJSONRepository.save(abstra_json)

        return job

    def update_runtime(self, path: str, changes: Dict[str, Any]) -> RuntimeJSON:
        abstra_json = AbstraJSONRepository.load()
        runtime = abstra_json.update_runtime(path, changes)
        AbstraJSONRepository.save(abstra_json)

        return runtime

    def delete_job(self, identifier: str):
        abstra_json = AbstraJSONRepository.load()
        jobs = abstra_json.jobs
        abstra_json.jobs = [j for j in jobs if j.identifier != identifier]

        AbstraJSONRepository.save(abstra_json)

    def get_stage_runs(self):
        stage = flask.request.args.get("stage")
        return [
            stage_run.to_dto()
            for stage_run in StageRunRepository.find(dict(stage=stage))
        ]

    # workflow visual editor

    def workflow_initial_data(self):
        abstra_json = AbstraJSONRepository.load()
        result = []
        for job in abstra_json.jobs:
            result.append(
                {
                    "id": job.identifier,
                    "type": "jobs",
                    "label": job.title,
                    "position": job.workflow_position,
                    "transitions": [
                        {
                            "id": t.id,
                            "label": t.label,
                            "targetPath": t.target_path,
                            "targetType": t.target_type,
                        }
                        for t in job.workflow_transitions
                    ],
                }
            )
        for form in abstra_json.forms:
            result.append(
                {
                    "id": form.path,
                    "type": "forms",
                    "label": form.title,
                    "position": form.workflow_position,
                    "transitions": [
                        {
                            "id": t.id,
                            "label": t.label,
                            "targetPath": t.target_path,
                            "targetType": t.target_type,
                        }
                        for t in form.workflow_transitions
                    ],
                }
            )
        for hook in abstra_json.hooks:
            result.append(
                {
                    "id": hook.path,
                    "type": "hooks",
                    "label": hook.title,
                    "position": hook.workflow_position,
                    "transitions": [
                        {
                            "id": t.id,
                            "label": t.label,
                            "targetPath": t.target_path,
                            "targetType": t.target_type,
                        }
                        for t in hook.workflow_transitions
                    ],
                }
            )
        for script in abstra_json.scripts:
            result.append(
                {
                    "id": script.path,
                    "path": script.path,
                    "type": "scripts",
                    "label": script.title,
                    "position": script.workflow_position,
                    "transitions": [
                        {
                            "id": t.id,
                            "label": t.label,
                            "targetPath": t.target_path,
                            "targetType": t.target_type,
                        }
                        for t in script.workflow_transitions
                    ],
                }
            )

        return result

    def workflow_move(self, payload):
        abstra_json = AbstraJSONRepository.load()
        for move in payload:
            node = abstra_json.get_workflow_runtime_by_path(move["id"])
            node.workflow_position = move["position"]
        AbstraJSONRepository.save(abstra_json)

    def workflow_duplicate_nodes(self, payload):
        abstra_json = AbstraJSONRepository.load()
        for item in payload:
            node = abstra_json.get_workflow_runtime_by_path(item["original_id"])
            duplicated = node.duplicate()
            if isinstance(duplicated, FormJSON):
                duplicated.path = item["new_id"]
                abstra_json.forms.append(duplicated)
            elif isinstance(duplicated, JobJSON):
                duplicated.identifier = item["new_id"]
                abstra_json.jobs.append(duplicated)
            elif isinstance(duplicated, HookJSON):
                duplicated.path = item["new_id"]
                abstra_json.hooks.append(duplicated)

            duplicated.workflow_transitions = [
                WorkflowTransitionJSON(
                    target_path=p["new_id"],
                    id=random_id(),
                    label=t.label,
                    target_type=p["type"],
                )
                for t in duplicated.workflow_transitions
                for p in payload
                if t.target_path == p["original_id"] and t.target_type == p["type"]
            ]
            duplicated.title = item["title"]
            duplicated.workflow_position = item["position"]
            base_file_name = ".".join(node.file.split(".")[:-1])
            duplicated.file = f"{base_file_name}-copy.py"
            original_file_path = Settings.root_path.joinpath(node.file)
            duplicated_file_path = Settings.root_path.joinpath(duplicated.file)
            if original_file_path.exists() and not duplicated_file_path.exists():
                shutil.copy(
                    Settings.root_path.joinpath(node.file),
                    Settings.root_path.joinpath(duplicated.file),
                )

        AbstraJSONRepository.save(abstra_json)

    def workflow_add_nodes(self, payload):
        abstra_json = AbstraJSONRepository.load()
        for node in payload:
            node_type = node["type"]
            node_id = node["id"]
            node_position = node["position"]
            node_title = node["title"]

            if node_type == "jobs":
                job = JobJSON(
                    file=f"new_job_{node_id}.py",
                    identifier=node_id,
                    schedule="0 * * * *",
                    title=node["title"],
                    workflow_position=node_position,
                    workflow_transitions=[],
                )
                abstra_json.jobs.append(job)
                Settings.root_path.joinpath(job.file).write_text(new_job_code)
            elif node_type == "forms":
                form = FormJSON(
                    file=f"new_form_{node_id}.py",
                    path=node_id,
                    title=node["title"],
                    workflow_position=node_position,
                    workflow_transitions=[],
                )
                abstra_json.forms.append(form)
                Settings.root_path.joinpath(form.file).write_text(new_form_code)
            elif node_type == "hooks":
                hook = HookJSON(
                    file=f"new_hook_{node_id}.py",
                    path=node_id,
                    title=node["title"],
                    workflow_position=node_position,
                    workflow_transitions=[],
                )
                abstra_json.hooks.append(hook)
                Settings.root_path.joinpath(hook.file).write_text(new_hook_code)
            elif node_type == "scripts":
                script = ScriptJSON(
                    file=f"new_script_{node_id}.py",
                    path=node_id,
                    title=node["title"],
                    workflow_position=node_position,
                    workflow_transitions=[],
                )
                abstra_json.scripts.append(script)
                Settings.root_path.joinpath(script.file).write_text(new_script_code)
            else:
                raise UnknownNodeTypeError(node_type)
        AbstraJSONRepository.save(abstra_json)

    def workflow_delete(self, payload):
        abstra_json = AbstraJSONRepository.load()

        for item in payload:
            if item["type"] == "transitions":
                transition = abstra_json.get_transition(item["id"])
                abstra_json.delete_transition(transition.id)
            else:
                node = abstra_json.get_workflow_runtime_by_path(item["id"])
                if Settings.root_path.joinpath(node.file).exists():
                    Settings.root_path.joinpath(node.file).unlink()
                if isinstance(node, FormJSON):
                    abstra_json.forms = [
                        f for f in abstra_json.forms if f.path != node.path
                    ]
                elif isinstance(node, JobJSON):
                    abstra_json.jobs = [
                        j for j in abstra_json.jobs if j.identifier != node.identifier
                    ]
                elif isinstance(node, HookJSON):
                    abstra_json.hooks = [
                        h for h in abstra_json.hooks if h.path != node.path
                    ]
                abstra_json.delete_transition_by_target(item["id"])
        AbstraJSONRepository.save(abstra_json)

    def workflow_add_transition(self, payload):
        if len(payload) == 0:
            return
        abstra_json = AbstraJSONRepository.load()
        for transition in payload:
            double_transition = abstra_json.find_transition(
                transition["source"]["id"],
                transition["target"]["id"],
            )
            if double_transition:
                raise DoubleTransitionError(
                    transition["source"]["type"],
                    transition["source"]["id"],
                    transition["target"]["type"],
                    transition["target"]["id"],
                )

            if transition["target"]["type"] == "jobs":
                raise TransitionToJobError(
                    transition["source"]["type"],
                    transition["source"]["id"],
                    transition["target"]["type"],
                    transition["target"]["id"],
                )
            if (
                transition["source"]["type"] == transition["target"]["type"]
                and transition["source"]["id"] == transition["target"]["id"]
            ):
                raise SelfTransitionError(
                    transition["source"]["type"], transition["source"]["id"]
                )

            source = abstra_json.get_workflow_runtime_by_path(
                transition["source"]["id"]
            )
            abstra_json.get_workflow_runtime_by_path(transition["target"]["id"])
            source.workflow_transitions.append(
                WorkflowTransitionJSON(
                    target_path=transition["target"]["id"],
                    id=transition["id"],
                    label="",
                    target_type=transition["target"]["type"],
                )
            )
        AbstraJSONRepository.save(abstra_json)

    # Login

    def get_credentials(self):
        return get_credentials()

    def get_login(self):
        headers = resolve_headers()
        if not headers:
            return {"logged": False, "reason": "NO_API_TOKEN"}
        return get_auth_info(headers)

    def create_login(self, token):
        set_credentials(token)
        return self.get_login()

    def delete_login(self):
        delete_credentials()
        return self.get_login()

    # AI
    def send_ai_message(self, messages, runtime):
        headers = resolve_headers() or {}
        yield from get_ai_messages(messages, runtime, headers)

    # files
    def save_file(self, file: FileStorage, filename: str):
        name, path = get_random_filepath(filename)
        file.save(path)
        return name

    def get_file(self, path: str):
        return internal_path(path)
