from dataclasses import 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


class Plan:
    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 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"])
    yield Import("import httpretty")
    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")
    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 pytest:
        key = "test_client_call_pytest"
    else:
        key = "test_client_call"

    if key in section:
        yield Code(f"response = {section[key]}")
        yield EmptyLine()
        yield AssertEqual("response.status_code", section["response"]["status_code"])
        if section["response_json"]:
            yield AssertEqual("response.json()", section["response_json"])
        for template_name in section["template_names"]:
            if pytest:
                yield Code(f"assertTemplateUsed(response, {repr(template_name)})")
            else:
                yield Code(f"self.assertTemplateUsed(response, {repr(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("@httpretty.activate(allow_net_connect=False)")
    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, 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 RegisterHTTPretty.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


class Step(Protocol):
    indent_delta: ClassVar[int]

    def render(self, pytest): ...


@dataclass(frozen=True)
class CodeComment:
    comment: str
    indent_delta: ClassVar[int] = 0

    def render(self, pytest):
        return f"# {self.comment}\n"


@dataclass(frozen=True)
class EmptyLine:
    indent_delta: ClassVar[int] = 0

    def render(self, pytest):
        return "\n"


@dataclass(frozen=True)
class Import:
    import_path: str
    indent_delta: ClassVar[int] = 0

    def render(self, pytest):
        return f"{self.import_path}\n"


@dataclass(frozen=True)
class TestClass:
    name: str
    parents: str
    indent_delta: ClassVar[int] = 1

    def render(self, pytest):
        return f"class {self.name}({self.parents}):\n"


@dataclass(frozen=True)
class EndClass:
    indent_delta: ClassVar[int] = -1

    def render(self, pytest):
        return ""


@dataclass(frozen=True)
class Code:
    code: str
    indent_delta: ClassVar[int] = 0

    def render(self, pytest):
        return f"{self.code}\n"


@dataclass(frozen=True)
class Method:
    name: str
    indent_delta: ClassVar[int] = 1

    def render(self, pytest):
        return f"def {self.name}(self):\n"


@dataclass(frozen=True)
class EndMethod:
    indent_delta: ClassVar[int] = -1

    def render(self, pytest):
        return ""


@dataclass(frozen=True)
class TestFunction:
    name: str
    fixtures: Tuple[str]
    indent_delta: ClassVar[int] = 1

    def render(self, pytest):
        fixtures = ", ".join(self.fixtures)
        return f"def {self.name}({fixtures}):\n"


@dataclass(frozen=True)
class EndFunction:
    indent_delta: ClassVar[int] = -1

    def render(self, pytest):
        return ""


@dataclass(frozen=True)
class With:
    call: str
    args: str
    indent_delta: ClassVar[int] = 1

    def render(self, pytest):
        return f"with {self.call}({self.args}):\n"


@dataclass(frozen=True)
class EndWith:
    indent_delta: ClassVar[int] = -1

    def render(self, pytest):
        return ""


@dataclass(frozen=True)
class AssertEqual:
    left: str
    right: str
    indent_delta: ClassVar[int] = 0

    def render(self, pytest):
        if pytest:
            return f"assert {self.left} == {self.right}\n"
        return f"self.assertEqual({self.left}, {self.right})\n"


@dataclass(frozen=True)
class RegisterHTTPretty:
    method: str
    url: str
    status_code: int
    body: Optional[str]
    json_body: Optional[str]
    content_type: str
    indent_delta: ClassVar[int] = 0

    @classmethod
    def from_outbound_request(cls, outbound_request):
        request = outbound_request["request"]
        response = outbound_request["response"]
        return cls(
            request["method"],
            request["url"],
            response["status_code"],
            response["body"],
            response.get("json_body", None),
            response["content_type"],
        )

    def render(self, pytest):
        rendered = f"""\
httpretty.register_uri(
    httpretty.{self.method},
    "{self.url}",
    status={self.status_code},
"""
        if self.json_body:
            rendered += f"    body=json.dumps({self.json_body}),\n"
        elif self.body:
            rendered += f"    body={repr(self.body)},\n"
        rendered += f"""\
    content_type={repr(self.content_type)},
)
"""
        return rendered


@dataclass(frozen=True)
class DjangoField:
    name: str
    value: str


@dataclass(frozen=True)
class CreateModel:
    module: str
    model: str
    fields: List[DjangoField]
    defaults: Dict[str, Any]
    variable_name: str
    method: str = "get_or_create"
    indent_delta: ClassVar[int] = 0

    @property
    def import_path(self):
        return f"from {self.module} import {self.model}"

    @property
    def model_path(self):
        return f"{self.module}.{self.model}"

    @classmethod
    def from_fixture(cls, fixture):
        return cls(
            module=fixture.module,
            model=fixture.model,
            fields=[DjangoField(f.name, f.value_repr) for f in fixture.fields],
            defaults={fixture.primary_key.name: fixture.primary_key.raw_value},
            variable_name=fixture.variable_name,
        )

    def render(self, pytest):
        rendered = f"{self.variable_name}"
        if self.method == "get_or_create":
            rendered += ", _created"

        rendered += f" = {self.model}.objects.{self.method}(\n"
        for field in self.fields:
            rendered += f"    {field.name}={field.value},\n"

        rendered += f"    defaults={self.defaults},\n"
        rendered += ")\n"
        return rendered


@dataclass(frozen=True)
class FactoryCreate:
    module: str
    factory: str
    fields: List[DjangoField]
    variable_name: str
    indent_delta: ClassVar[int] = 0

    @classmethod
    def from_fixture(cls, fixture):
        return cls(
            module=fixture.module,
            factory=fixture.factory,
            fields=[DjangoField(f.name, f.value_repr) for f in fixture.fields],
            variable_name=fixture.variable_name,
        )

    def render(self, pytest):
        rendered = f"{self.variable_name} = {self.factory}.create(\n"
        for field in self.fields:
            rendered += f"    {field.name}={field.value},\n"
        rendered += ")\n"
        return rendered


@dataclass(frozen=True)
class AssertInsert:
    module: str
    model: str
    lookup_fields: List[DjangoField]
    assert_fields: List[DjangoField]
    variable_name: str
    indent_delta: ClassVar[int] = 0

    @property
    def import_path(self):
        return f"from {self.module} import {self.model}"

    @property
    def model_path(self):
        return f"{self.module}.{self.model}"

    @classmethod
    def from_fixture(cls, fixture):
        lookup_fields, assert_fields = fixture.get_fields()
        return cls(
            module=fixture.module,
            model=fixture.model,
            lookup_fields=[DjangoField(f.name, f.value_repr) for f in lookup_fields],
            assert_fields=[DjangoField(f.name, f.value_repr) for f in assert_fields],
            variable_name=fixture.variable_name,
        )

    def render(self, pytest):
        rendered = f"{self.variable_name} = {self.model}.objects.get(\n"
        for field in self.lookup_fields:
            rendered += f"    {field.name}={field.value},\n"
        rendered += ")\n"
        for field in self.assert_fields:
            if pytest:
                rendered += (
                    f"assert {self.variable_name}.{field.name} == {field.value}\n"
                )
            else:
                rendered += f"self.assertEqual({self.variable_name}.{field.name}, {field.value})\n"

        return rendered


@dataclass(frozen=True)
class AssertUpdate:
    model: str
    fields: List[DjangoField]
    variable_name: str
    indent_delta: ClassVar[int] = 0

    @classmethod
    def from_fixture(cls, fixture):
        return cls(
            model=fixture.model,
            fields=[DjangoField(f.name, f.value_repr) for f in fixture.fields],
            variable_name=fixture.variable_name,
        )

    def render(self, pytest):
        rendered = f"{self.variable_name}.refresh_from_db()\n"
        for field in self.fields:
            if pytest:
                rendered += (
                    f"assert {self.variable_name}.{field.name} == {field.value}\n"
                )
            else:
                rendered += f"self.assertEqual({self.variable_name}.{field.name}, {field.value})\n"
        return rendered


@dataclass(frozen=True)
class AssertDelete:
    model: str
    fields: List[DjangoField]
    indent_delta: ClassVar[int] = 0

    @classmethod
    def from_fixture(cls, fixture):
        return cls(
            model=fixture.model,
            fields=[DjangoField(f.name, f.value_repr) for f in fixture.fields],
        )

    def grouped_fields(self):
        fields: Dict[str, List[str]] = {}
        for field in self.fields:
            fields.setdefault(field.name, []).append(field.value)
        return fields

    def render(self, pytest):
        if pytest:
            rendered = "assert not "
        else:
            rendered = "self.assertFalse("
        rendered += f"{self.model}.objects.filter(\n"

        for name, values in self.grouped_fields().items():
            if len(values) == 1:
                value = values[0]
                rendered += f"    {name}={value},\n"
            else:
                joined_values = ", ".join(values)
                rendered += f"    {name}__in=({joined_values}),\n"
        if pytest:
            rendered += ").exists()\n"
        else:
            rendered += ").exists())\n"
        return rendered
