import json
from dataclasses import asdict, dataclass
from importlib import import_module
from io import StringIO
from textwrap import indent
from typing import Any, Callable, ClassVar, Dict, List, Optional, Protocol, Tuple

from . import factories, queries
from .steps import (
    AssertDelete,
    AssertInsert,
    AssertResponseJson,
    AssertStatusCode,
    AssertTemplateUsed,
    AssertUpdate,
    Code,
    CodeComment,
    CreateModel,
    DjangoTestClient,
    EmptyLine,
    EndClass,
    EndFunction,
    EndMethod,
    EndWith,
    FactoryCreate,
    Import,
    Method,
    RegisterMocket,
    TestClass,
    TestFunction,
    UpdateModel,
    With,
)


class Plan:
    interesting_steps = (
        AssertInsert,
        AssertResponseJson,
        AssertStatusCode,
        AssertTemplateUsed,
        AssertUpdate,
        CreateModel,
        UpdateModel,
        DjangoTestClient,
        RegisterMocket,
    )

    def __init__(self, steps, pytest, context):
        self.steps = list(steps)
        self.pytest = pytest
        self.context = context

    def render(self):
        contents = StringIO()
        indent_count = 0
        for step in self.steps:
            indent_prefix = "    " * indent_count
            contents.write(indent(step.render(self.pytest), indent_prefix))
            indent_count += step.indent_delta

        return contents.getvalue()

    def as_json(self):
        steps = [
            (step.__class__.__name__, asdict(step))
            for step in self.steps
            if isinstance(step, self.interesting_steps)
        ]
        return json.dumps(steps)


def header_comments(context):
    if context["now"]:
        yield CodeComment(f"Auto generated by Kolo at {context['now']}.")
    else:
        yield CodeComment("Auto generated by Kolo.")
    yield CodeComment(f"Kolo version: {context['kolo_version']}")
    if context["django_version"]:
        yield CodeComment(f"Django version: {context['django_version']}")
    for trace_id in context["_traces"]:
        yield CodeComment(f"Trace ID: {trace_id}")


def imports(context, pytest):
    yield Import("import json")
    for value_import in context["value_imports"]:
        yield Import(value_import)

    yield EmptyLine()
    yield Import(context["time_travel_import"])
    if pytest:
        yield Import("import pytest")
    elif context["base_test_case"] == "TestCase":
        yield Import("from django.test import TestCase")
    yield Import("from django.utils.http import urlencode")
    yield Import("from mocket import mocketize")
    yield Import("from mocket.mockhttp import Entry")
    if pytest:
        yield Import("from pytest_django.asserts import assertTemplateUsed")
    yield EmptyLine()

    for project_import in context["imports"]:
        yield Import(project_import)


def start_time_travel(section, context):
    if "request_timestamp" in section:
        time_travel_call = context["time_travel_call"]
        request_timestamp = section["request_timestamp"]
        time_travel_tick = context["time_travel_tick"]
        yield With(time_travel_call, f'"{request_timestamp}"{time_travel_tick}')


def end_time_travel(section):
    if "request_timestamp" in section:
        yield EndWith()


def call_test_client(section, pytest):
    if "response" in section:
        request = section["request"]
        yield DjangoTestClient(
            request["method"].lower(),
            request["path_info"],
            section["query_params"],
            section["prettified_request_body"],
            section["request_headers"],
        )
        yield EmptyLine()
        yield AssertStatusCode(section["response"]["status_code"])
        if section["response_json"]:
            yield AssertResponseJson(section["response_json"])
        for template_name in section["template_names"]:
            yield AssertTemplateUsed(template_name)
        yield EmptyLine()


def build_steps(context, pytest):
    yield from header_comments(context)
    yield EmptyLine()
    yield from imports(context, pytest)
    yield EmptyLine()
    yield EmptyLine()
    if not pytest:
        yield TestClass(context["test_class"], context["base_test_case"])
    yield Code("@mocketize(strict_mode=True)")
    if pytest:
        yield Code("@pytest.mark.django_db()")
        yield TestFunction(context["test_name"], ("client",))
    else:
        yield Method(context["test_name"])

    for section in context["sections"]:
        yield from start_time_travel(section, context)

        for create_fixture in section["sql_fixtures"]:
            if isinstance(create_fixture, queries.DjangoCreate):
                yield CreateModel.from_fixture(create_fixture)
            elif isinstance(create_fixture, queries.DjangoUpdate):
                yield UpdateModel.from_fixture(create_fixture)
            elif isinstance(create_fixture, factories.Factory):
                yield FactoryCreate.from_fixture(create_fixture)
        if len(section["sql_fixtures"]) > 0:
            yield EmptyLine()

        for outbound_request in section["outbound_request_frames"]:
            yield RegisterMocket.from_outbound_request(outbound_request)
        if len(section["outbound_request_frames"]) > 0:
            yield EmptyLine()

        yield from call_test_client(section, pytest)

        for fixture in section["asserts"]:
            if isinstance(fixture, queries.AssertInsert):
                yield AssertInsert.from_fixture(fixture)
            elif isinstance(fixture, queries.AssertUpdate):
                yield AssertUpdate.from_fixture(fixture)
            elif isinstance(fixture, queries.AssertDelete):
                yield AssertDelete.from_fixture(fixture)

        yield from end_time_travel(section)

    if pytest:
        yield EndFunction()
    else:
        yield EndMethod()
        yield EndClass()


def run_plan_hooks(plan, hooks):
    for hook in hooks:
        plan = hook(plan)
    return plan


plan_hooks: List[Callable[[Plan], Plan]] = []


def load_hooks(config):
    imports = config.get("test_generation", {}).get("hook_imports", [])
    for import_path in imports:
        import_module(import_path)
    return plan_hooks


def register_plan_hook(func):
    plan_hooks.append(func)
    return func
