import dataclasses
import logging as log
import re
from typing import Any

from marge import gitlab
from tests import test_approvals, test_commit, test_project, test_user

GET = gitlab.GET
POST = gitlab.POST


def commit(commit_id, status):
    return {
        "id": commit_id,
        "short_id": commit_id,
        "author_name": "J. Bond",
        "author_email": "jbond@mi6.gov.uk",
        "message": "Shaken, not stirred",
        "status": status,
    }


class MockLab:  # pylint: disable=too-few-public-methods
    def __init__(
        self,
        initial_master_sha="505e",
        gitlab_url=None,
        fork=False,
        merge_request_options=None,
    ):
        self.gitlab_url = gitlab_url = gitlab_url or "http://git.example.com"
        self.api = api = Api(
            gitlab_url=gitlab_url, auth_token="no-token", initial_state="initial"
        )

        self.user_info = dict(test_user.INFO)
        self.user_id = self.user_info["id"]
        api.add_user(self.user_info, is_current=True)

        self.project_info = dict(test_project.INFO)
        api.add_project(self.project_info)

        self.commit_info = dict(test_commit.INFO)
        api.add_commit(self.project_info["id"], self.commit_info)

        self.author_id = 234234
        self.merge_request_info = {
            "id": 53,
            "iid": 54,
            "title": "a title",
            "project_id": 1234,
            "author": {"id": self.author_id},
            "assignees": [{"id": self.user_id}],
            "state": "opened",
            "detailed_merge_status": "mergeable",
            "sha": self.commit_info["id"],
            "source_project_id": 1234,
            "target_project_id": 1234,
            "source_branch": "useless_new_feature",
            "force_remove_source_branch": True,
            "target_branch": "master",
            "draft": False,
            "blocking_discussions_resolved": True,
            "web_url": "http://git.example.com/group/project/merge_request/666",
        }
        if merge_request_options is not None:
            self.merge_request_info.update(merge_request_options)

        if fork:
            self.forked_project_info = dict(
                self.project_info,
                id=4321,
                ssh_url_to_repo="ssh://some.other.project/stuff",
            )
            api.add_project(self.forked_project_info)
            self.merge_request_info.update({"iid": 55, "source_project_id": "4321"})
        else:
            self.forked_project_info = None

        api.add_merge_request(self.merge_request_info)

        self.initial_master_sha = initial_master_sha
        self.approvals_info = dict(
            test_approvals.INFO,
            id=self.merge_request_info["id"],
            iid=self.merge_request_info["iid"],
            project_id=self.merge_request_info["project_id"],
            approvals_left=0,
        )
        api.add_approvals(self.approvals_info)
        api.add_transition(
            GET(
                f"/projects/1234/repository/branches/"
                f'{self.merge_request_info["target_branch"]}'
            ),
            Ok({"commit": {"id": self.initial_master_sha}}),
        )


class Api(gitlab.Api):
    def __init__(self, gitlab_url, auth_token, initial_state):
        super().__init__(gitlab_url, auth_token)

        self._transitions = {}
        self.state = initial_state
        self.notes = []

    def call(self, command, sudo=None):
        sudo_str = sudo if sudo is not None else ""
        log.info("CALL: %s%s @ %s", f"sudo {sudo_str} ", command, self.state)
        try:
            response, next_state, side_effect = self._find(command, sudo)
        except KeyError as err:
            page = command.args.get("page")
            if page == 0:
                no_page_args = dict(
                    (k, v)
                    for k, v in command.args.items()
                    if k not in ["page", "per_page"]
                )
                try:
                    return self.call(dataclasses.replace(command, args=no_page_args))
                except MockedEndpointNotFound:
                    pass  # raise the right exception below
            elif page:  # page is not None
                try:
                    # only return an empty list if the command exists
                    self.call(command.for_page(0))
                except MockedEndpointNotFound:
                    pass  # raise the right exception below
                else:
                    return []

            raise MockedEndpointNotFound(command, sudo, self.state) from err

        if next_state:
            self.state = next_state

        if side_effect:
            side_effect()
        return response()

    def _find(self, command, sudo):
        more_specific = self._transitions.get(_key(command, sudo, self.state))
        return more_specific or self._transitions[_key(command, sudo, None)]

    def add_transition(
        self,
        command,
        response,
        sudo=None,
        from_state=None,
        to_state=None,
        side_effect=None,
    ):
        from_states = from_state if isinstance(from_state, list) else [from_state]

        for _from_state in from_states:
            show_from = "*" if _from_state is None else repr(_from_state)
            sudo_str = sudo if sudo is not None else ""
            log.info(
                "REGISTERING %s%s from %s to %s",
                f"sudo {sudo_str} ",
                command,
                show_from,
                show_from if to_state is None else repr(to_state),
            )
            self._transitions[_key(command, sudo, _from_state)] = (
                response,
                to_state,
                side_effect,
            )

    def add_resource(self, path, info, sudo=None, from_state=None, to_state=None):
        self.add_transition(
            GET(path.format(attrs(info))), Ok(info), sudo, from_state, to_state
        )

    def add_user(
        self, info, is_current=False, sudo=None, from_state=None, to_state=None
    ):
        self.add_resource("/users/{0.id}", info, sudo, from_state, to_state)
        if is_current:
            self.add_resource("/user", info, sudo, from_state, to_state)

    def add_project(self, info, sudo=None, from_state=None, to_state=None):
        self.add_resource("/projects/{0.id}", info, sudo, from_state, to_state)
        self.add_transition(
            GET(f"/projects/{attrs(info).id}/merge_requests"),
            List(r"/projects/\d+/merge_requests/\d+$", self),
            sudo,
            from_state,
            to_state,
        )

    def add_merge_request(self, info, sudo=None, from_state=None, to_state=None):
        self.add_resource(
            "/projects/{0.project_id}/merge_requests/{0.iid}",
            info,
            sudo,
            from_state,
            to_state,
        )
        self.add_transition(
            GET(
                f"/projects/{attrs(info).project_id}/merge_requests/{attrs(info).iid}",
                args={"include_rebase_in_progress": "true"},
            ),
            Ok(info),
            sudo,
            from_state,
            to_state,
        )

    def add_commit(self, project_id, info, sudo=None, from_state=None, to_state=None):
        path = f"/projects/{project_id}/repository/commits/{{0.id}}"
        self.add_resource(path, info, sudo, from_state, to_state)

    def add_approvals(self, info, sudo=None, from_state=None, to_state=None):
        path = "/projects/{0.project_id}/merge_requests/{0.iid}/approvals"
        self.add_resource(path, info, sudo, from_state, to_state)

    def add_pipelines(
        self,
        project_id,
        merge_request_iid,
        info,
        sudo=None,
        from_state=None,
        to_state=None,
    ):
        self.add_transition(
            GET(f"/projects/{project_id}/merge_requests/{merge_request_iid}/pipelines"),
            Ok([info]),
            sudo,
            from_state,
            to_state,
        )

    def expected_note(
        self, merge_request, note, sudo=None, from_state=None, to_state=None
    ):
        self.add_transition(
            POST(
                f"/projects/{attrs(merge_request).project_id}/merge_requests/"
                f"{attrs(merge_request).iid}/notes",
                args={"body": note},
            ),
            LeaveNote(note, self),
            sudo,
            from_state,
            to_state,
        )


def _key(command, sudo, state):
    return (
        dataclasses.replace(command, args=frozenset(command.args.items())),
        sudo,
        state,
    )


@dataclasses.dataclass
class Ok:
    result: dict[str, Any]

    def __call__(self):
        return self.result


@dataclasses.dataclass
class Error:
    exc: Exception

    def __call__(self):
        raise self.exc


@dataclasses.dataclass
class List:
    prefix: str
    api: Api

    def __call__(self):
        candidates = (
            command
            for command, _ in self.api._transitions  # pylint: disable=protected-access
            if isinstance(command, GET) and re.match(self.prefix, command.endpoint)
        )

        results = []
        for command in candidates:
            try:
                results.append(self.api.call(command))
            except MockedEndpointNotFound:
                pass

        return results


@dataclasses.dataclass
class LeaveNote:
    note: str
    api: Api

    def __call__(self):
        self.api.notes.append(self.note)
        return {}


class MockedEndpointNotFound(Exception):
    pass


def attrs(_dict):
    attrs_cls = dataclasses.make_dataclass("Attrs", fields=list(_dict.keys()))
    return attrs_cls(**_dict)
