from datetime import datetime
from typing import Any, List, Optional
from unittest import TestCase

import yaml
from simplehttprouter import Route

from edpopenapibuilder.builder.dto import build_params
from edpopenapibuilder.builder.dto import SchemaConflictStrategy
from edpopenapibuilder.builder.impl import OpenApiModelBuilderImpl
from edpopenapibuilder.interactor import AutoOpenApiYamlBuilderInteractor
from edpopenapibuilder.interactor.dto import AutoOpenApiYamlBuilderRequest
from edpopenapibuilder.model import (
    RefSchemaModel,
    SchemaArrayPropertyModel,
    SchemaPropertyFormatEnum,
    SchemaPropertyModel,
    SchemaPropertyTypeEnum,
)


class TestSchemaPropertyModelDeterminism(TestCase):
    """Unit tests for deterministic uuid/name on SchemaPropertyModel."""

    def test_get_schema_uuid_is_deterministic(self):
        model = SchemaPropertyModel(type=SchemaPropertyTypeEnum.string)
        self.assertEqual(model.get_schema_uuid(), model.get_schema_uuid())

    def test_get_schema_uuid_differs_by_type(self):
        string_model = SchemaPropertyModel(type=SchemaPropertyTypeEnum.string)
        integer_model = SchemaPropertyModel(type=SchemaPropertyTypeEnum.integer)
        self.assertNotEqual(string_model.get_schema_uuid(), integer_model.get_schema_uuid())

    def test_get_schema_uuid_differs_by_format(self):
        plain = SchemaPropertyModel(type=SchemaPropertyTypeEnum.string)
        dated = SchemaPropertyModel(type=SchemaPropertyTypeEnum.string, format=SchemaPropertyFormatEnum.date)
        self.assertNotEqual(plain.get_schema_uuid(), dated.get_schema_uuid())

    def test_get_schema_uuid_same_across_instances(self):
        a = SchemaPropertyModel(type=SchemaPropertyTypeEnum.integer)
        b = SchemaPropertyModel(type=SchemaPropertyTypeEnum.integer)
        self.assertEqual(a.get_schema_uuid(), b.get_schema_uuid())

    def test_get_schema_uuid_respects_explicit_value(self):
        model = SchemaPropertyModel(type=SchemaPropertyTypeEnum.string)
        model.set_schema_uuid("explicit-uuid")
        self.assertEqual(model.get_schema_uuid(), "explicit-uuid")

    def test_get_schema_name_string(self):
        model = SchemaPropertyModel(type=SchemaPropertyTypeEnum.string)
        self.assertEqual(model.get_schema_name(), "string")

    def test_get_schema_name_integer(self):
        model = SchemaPropertyModel(type=SchemaPropertyTypeEnum.integer)
        self.assertEqual(model.get_schema_name(), "integer")

    def test_get_schema_name_boolean(self):
        model = SchemaPropertyModel(type=SchemaPropertyTypeEnum.boolean)
        self.assertEqual(model.get_schema_name(), "boolean")

    def test_get_schema_name_number(self):
        model = SchemaPropertyModel(type=SchemaPropertyTypeEnum.number)
        self.assertEqual(model.get_schema_name(), "number")

    def test_get_schema_name_with_date_format(self):
        model = SchemaPropertyModel(type=SchemaPropertyTypeEnum.string, format=SchemaPropertyFormatEnum.date)
        self.assertEqual(model.get_schema_name(), "string-date")

    def test_get_schema_name_with_datetime_format(self):
        model = SchemaPropertyModel(type=SchemaPropertyTypeEnum.string, format=SchemaPropertyFormatEnum.datetime)
        self.assertEqual(model.get_schema_name(), "string-date-time")

    def test_get_schema_name_respects_explicit_value(self):
        model = SchemaPropertyModel(type=SchemaPropertyTypeEnum.string)
        model.set_schema_name("explicit-name")
        self.assertEqual(model.get_schema_name(), "explicit-name")

    def test_default_does_not_affect_uuid(self):
        model_a = SchemaPropertyModel(type=SchemaPropertyTypeEnum.string, default="foo")
        model_b = SchemaPropertyModel(type=SchemaPropertyTypeEnum.string, default="bar")
        self.assertEqual(model_a.get_schema_uuid(), model_b.get_schema_uuid())

    def test_default_does_not_affect_name(self):
        model_a = SchemaPropertyModel(type=SchemaPropertyTypeEnum.string, default="foo")
        model_b = SchemaPropertyModel(type=SchemaPropertyTypeEnum.string, default=None)
        self.assertEqual(model_a.get_schema_name(), model_b.get_schema_name())


class TestListScalarDeduplication(TestCase):
    """Integration tests: List[scalar] returns inline array (no $ref, no component)."""

    def setUp(self):
        self.builder = OpenApiModelBuilderImpl()
        self.params = build_params(route=Route("GET", "/test"))

    # --- Scalar are inline, not registered in components ---

    def test_list_str_no_component_registered(self):
        self.builder.build_schema_model(List[str], self.params)
        self.builder.build_schema_model(List[str], self.params)
        schemas = self.builder.get_component_model()._schemas
        self.assertTrue(schemas is None or len(schemas) == 0)

    def test_list_int_no_component_registered(self):
        self.builder.build_schema_model(List[int], self.params)
        self.builder.build_schema_model(List[int], self.params)
        schemas = self.builder.get_component_model()._schemas
        self.assertTrue(schemas is None or len(schemas) == 0)

    def test_list_str_and_list_int_no_components(self):
        self.builder.build_schema_model(List[str], self.params)
        self.builder.build_schema_model(List[int], self.params)
        schemas = self.builder.get_component_model()._schemas
        self.assertTrue(schemas is None or len(schemas) == 0)

    def test_list_str_returns_schema_array_property_model(self):
        result = self.builder.build_schema_model(List[str], self.params)
        self.assertIsInstance(result, SchemaArrayPropertyModel)

    def test_list_int_returns_schema_array_property_model(self):
        result = self.builder.build_schema_model(List[int], self.params)
        self.assertIsInstance(result, SchemaArrayPropertyModel)

    def test_list_str_inline_schema_structure(self):
        result = self.builder.build_schema_model(List[str], self.params)
        self.assertIsInstance(result, SchemaArrayPropertyModel)
        self.assertIsInstance(result.items, SchemaPropertyModel)
        self.assertEqual(result.items.type, SchemaPropertyTypeEnum.string)

    def test_list_int_inline_schema_structure(self):
        result = self.builder.build_schema_model(List[int], self.params)
        self.assertIsInstance(result, SchemaArrayPropertyModel)
        self.assertIsInstance(result.items, SchemaPropertyModel)
        self.assertEqual(result.items.type, SchemaPropertyTypeEnum.integer)

    def test_list_str_second_call_returns_same_structure(self):
        result_a = self.builder.build_schema_model(List[str], self.params)
        result_b = self.builder.build_schema_model(List[str], self.params)
        self.assertEqual(result_a.to_dict(), result_b.to_dict())

    # --- YAML output: inline arrays in responses, no components section ---

    def test_list_str_yaml_inline_in_response(self):
        self.builder.add_interactor_path(
            route_params=build_params(route=Route("GET", "/items"), response_type=List[str])
        )
        data = yaml.safe_load(self.builder.build_open_api_yaml())
        schema = data['paths']['/items']['get']['responses']['200']['content']['application/json']['schema']
        self.assertEqual(schema, {'type': 'array', 'items': {'type': 'string'}})

    def test_list_str_yaml_no_components(self):
        self.builder.add_interactor_path(
            route_params=build_params(route=Route("GET", "/items"), response_type=List[str])
        )
        data = yaml.safe_load(self.builder.build_open_api_yaml())
        self.assertNotIn('components', data)

    def test_list_int_yaml_inline_in_response(self):
        self.builder.add_interactor_path(
            route_params=build_params(route=Route("GET", "/counts"), response_type=List[int])
        )
        data = yaml.safe_load(self.builder.build_open_api_yaml())
        schema = data['paths']['/counts']['get']['responses']['200']['content']['application/json']['schema']
        self.assertEqual(schema, {'type': 'array', 'items': {'type': 'integer'}})

    def test_list_int_yaml_no_components(self):
        self.builder.add_interactor_path(
            route_params=build_params(route=Route("GET", "/counts"), response_type=List[int])
        )
        data = yaml.safe_load(self.builder.build_open_api_yaml())
        self.assertNotIn('components', data)

    def test_list_date_yaml_inline_in_response(self):
        from datetime import date
        self.builder.add_interactor_path(
            route_params=build_params(route=Route("GET", "/dates"), response_type=List[date])
        )
        data = yaml.safe_load(self.builder.build_open_api_yaml())
        schema = data['paths']['/dates']['get']['responses']['200']['content']['application/json']['schema']
        self.assertEqual(schema, {'type': 'array', 'items': {'type': 'string', 'format': 'date'}})

    def test_list_date_yaml_no_components(self):
        from datetime import date
        self.builder.add_interactor_path(
            route_params=build_params(route=Route("GET", "/dates"), response_type=List[date])
        )
        data = yaml.safe_load(self.builder.build_open_api_yaml())
        self.assertNotIn('components', data)


class TestServerDeduplication(TestCase):
    """Tests that repeated execute() calls do not accumulate duplicate servers."""

    def _make_interactor(self):
        return AutoOpenApiYamlBuilderInteractor()

    def test_same_host_added_once(self):
        interactor = self._make_interactor()
        interactor.execute(AutoOpenApiYamlBuilderRequest(host="http://example.com"))
        data = yaml.safe_load(
            interactor.execute(AutoOpenApiYamlBuilderRequest(host="http://example.com"))
        )
        self.assertEqual(len(data['servers']), 1)
        self.assertEqual(data['servers'][0]['url'], "http://example.com")

    def test_different_hosts_each_added(self):
        interactor = self._make_interactor()
        interactor.execute(AutoOpenApiYamlBuilderRequest(host="http://host-a.com"))
        data = yaml.safe_load(
            interactor.execute(AutoOpenApiYamlBuilderRequest(host="http://host-b.com"))
        )
        urls = [s['url'] for s in data['servers']]
        self.assertIn("http://host-a.com", urls)
        self.assertIn("http://host-b.com", urls)
        self.assertEqual(len(urls), 2)

    def test_no_host_produces_no_servers(self):
        interactor = self._make_interactor()
        data = yaml.safe_load(interactor.execute(AutoOpenApiYamlBuilderRequest()))
        self.assertNotIn('servers', data)


class StatusDelivery:
    """Self-referencing DTO: brands is List["StatusDelivery"]."""
    def __init__(
        self,
        status: Optional[str] = None,
        date: Optional[datetime] = None,
        name: Optional[str] = None,
        brands: List["StatusDelivery"] = None,
        error_payload: Optional[Any] = None,
        error_code: Optional[Any] = None,
        message: Optional[Any] = None,
    ):
        self.status = status
        self.date = date
        self.name = name
        self.brands = brands if brands is not None else []
        self.error_payload = error_payload
        self.error_code = error_code
        self.message = message


class TestSelfReferencingDto(TestCase):
    """Tests that a class with List['Self'] produces a $ref back to itself, not ForwardRef."""

    def setUp(self):
        self.builder = OpenApiModelBuilderImpl()
        self.params = build_params(route=Route("POST", "/delivery"))

    def test_self_ref_schema_model_is_ref(self):
        result = self.builder.build_schema_model(StatusDelivery, self.params)
        self.assertIsInstance(result, RefSchemaModel)

    def test_self_ref_schema_name(self):
        result = self.builder.build_schema_model(StatusDelivery, self.params)
        self.assertEqual(result.schema.get_schema_name(), 'StatusDelivery')

    def test_self_ref_brands_property_is_inline_array(self):
        """brands should be an inline array with items $ref to StatusDelivery."""
        self.builder.add_interactor_path(
            route_params=build_params(
                route=Route("POST", "/delivery"),
                request_type=StatusDelivery,
                response_type=StatusDelivery,
            )
        )
        data = yaml.safe_load(self.builder.build_open_api_yaml())
        schema = data['components']['schemas']['StatusDelivery']
        self.assertIn('brands', schema['properties'])
        brands_prop = schema['properties']['brands']
        self.assertEqual(brands_prop, {
            'type': 'array',
            'items': {'$ref': '#/components/schemas/StatusDelivery'},
        })

    def test_self_ref_no_array_component(self):
        """With ref_array_types=False (default), no ArrayOf component should exist."""
        self.builder.add_interactor_path(
            route_params=build_params(
                route=Route("POST", "/delivery"),
                request_type=StatusDelivery,
                response_type=StatusDelivery,
            )
        )
        data = yaml.safe_load(self.builder.build_open_api_yaml())
        schema_names = list(data['components']['schemas'].keys())
        self.assertFalse(any('ArrayOf' in n for n in schema_names))

    def test_self_ref_no_forward_ref_in_yaml(self):
        """The YAML output must not contain 'ForwardRef' anywhere."""
        self.builder.add_interactor_path(
            route_params=build_params(
                route=Route("POST", "/delivery"),
                request_type=StatusDelivery,
                response_type=StatusDelivery,
            )
        )
        yaml_output = self.builder.build_open_api_yaml()
        self.assertNotIn('ForwardRef', yaml_output)


class _ConflictStrategyTestBase(TestCase):
    """Base for testing conflict strategies with a DTO used on two routes (with/without path param)."""

    strategy: SchemaConflictStrategy = None

    def setUp(self):
        self.builder = OpenApiModelBuilderImpl(conflict_strategy=self.strategy)

    def _build_yaml(self) -> dict:
        from samplehttpmodule.interactor.parameters.dto import AllTypesDto
        # POST routes pass request_type through build_schema_model with path_model,
        # which triggers the skipped_params logic for path parameters.
        self.builder.add_interactor_path(
            route_params=build_params(
                route=Route("POST", "/allTypes"),
                request_type=AllTypesDto,
                response_type=AllTypesDto,
            )
        )
        self.builder.add_interactor_path(
            route_params=build_params(
                route=Route("POST", "/allTypes/{integer_param}"),
                request_type=AllTypesDto,
                response_type=AllTypesDto,
            )
        )
        return yaml.safe_load(self.builder.build_open_api_yaml())


class TestConflictStrategyReadable(_ConflictStrategyTestBase):
    """readable strategy: produces 'Without{Params}' suffix for skipped-param variants."""

    strategy = SchemaConflictStrategy.readable

    def test_both_schemas_present(self):
        schemas = self._build_yaml()['components']['schemas']
        self.assertIn('AllTypesDto', schemas)
        self.assertIn('AllTypesDtoWithoutIntegerParam', schemas)

    def test_full_schema_has_integer_param(self):
        schema = self._build_yaml()['components']['schemas']['AllTypesDto']
        self.assertIn('integer_param', schema['properties'])

    def test_without_schema_lacks_integer_param(self):
        schema = self._build_yaml()['components']['schemas']['AllTypesDtoWithoutIntegerParam']
        self.assertNotIn('integer_param', schema['properties'])

    def test_path_param_route_request_body_ref_points_to_without_variant(self):
        data = self._build_yaml()
        schema = data['paths']['/allTypes/{integer_param}']['post']['requestBody']['content']['application/json']['schema']
        self.assertEqual(schema, {'$ref': '#/components/schemas/AllTypesDtoWithoutIntegerParam'})

    def test_no_uuid_hash_in_schema_names(self):
        schemas = self._build_yaml()['components']['schemas']
        for name in schemas:
            self.assertNotRegex(name, r'-[0-9a-f]{10}$')


class TestConflictStrategyReuse(_ConflictStrategyTestBase):
    """reuse strategy: same DTO always produces one schema (the full object)."""

    strategy = SchemaConflictStrategy.reuse

    def test_single_schema_for_dto(self):
        schemas = self._build_yaml()['components']['schemas']
        all_types_schemas = [n for n in schemas if n.startswith('AllTypesDto')]
        self.assertEqual(len(all_types_schemas), 1)
        self.assertEqual(all_types_schemas[0], 'AllTypesDto')

    def test_full_schema_has_all_fields(self):
        schema = self._build_yaml()['components']['schemas']['AllTypesDto']
        self.assertIn('integer_param', schema['properties'])
        self.assertIn('string_param', schema['properties'])

    def test_both_routes_request_body_ref_same_schema(self):
        data = self._build_yaml()
        ref_plain = data['paths']['/allTypes']['post']['requestBody']['content']['application/json']['schema']
        ref_param = data['paths']['/allTypes/{integer_param}']['post']['requestBody']['content']['application/json']['schema']
        self.assertEqual(ref_plain, ref_param)
        self.assertEqual(ref_plain, {'$ref': '#/components/schemas/AllTypesDto'})


class TestConflictStrategyUuid(_ConflictStrategyTestBase):
    """uuid strategy: conflict resolved with hash suffix (legacy behavior)."""

    strategy = SchemaConflictStrategy.uuid

    def test_two_schemas_with_uuid_suffix(self):
        schemas = self._build_yaml()['components']['schemas']
        all_types_schemas = [n for n in schemas if n.startswith('AllTypesDto')]
        self.assertEqual(len(all_types_schemas), 2)
        for name in all_types_schemas:
            self.assertRegex(name, r'AllTypesDto-[0-9a-f]{10}')

    def test_no_without_suffix(self):
        schemas = self._build_yaml()['components']['schemas']
        for name in schemas:
            self.assertNotIn('Without', name)


# --- Cross-module name collision ---

class TestCrossModuleNameCollision(TestCase):
    """When two different classes share the same name but live in different modules,
    the builder should disambiguate using a module-derived prefix."""

    @staticmethod
    def _make_cls(module: str, name: str, fields: dict):
        """Dynamically create a class with __module__ and __init__ typed params."""
        import inspect

        annotations = {k: v for k, v in fields.items()}
        params = [inspect.Parameter("self", inspect.Parameter.POSITIONAL_OR_KEYWORD)]
        for field_name, field_type in fields.items():
            params.append(inspect.Parameter(
                field_name,
                inspect.Parameter.POSITIONAL_OR_KEYWORD,
                annotation=field_type,
            ))

        sig = inspect.Signature(params)

        def init(self, **kwargs):
            for k, v in kwargs.items():
                setattr(self, k, v)

        init.__signature__ = sig
        init.__annotations__ = annotations

        cls = type(name, (), {"__init__": init})
        cls.__module__ = module
        return cls

    def _build(self):
        DtoA = self._make_cls("myapp.dto", "SimpleEntity", {"name": str, "age": int})
        DtoB = self._make_cls("myapp.interactor", "SimpleEntity", {"code": str})

        builder = OpenApiModelBuilderImpl(config={
            "conflict_strategy": SchemaConflictStrategy.readable,
        })

        interactor = AutoOpenApiYamlBuilderInteractor(
            routes={
                Route("POST", "/endpoint-a"): type("ProcA", (), {
                    "__init__": lambda self, interactor=None: None,
                    "interactor": type("IntA", (), {
                        "execute": lambda self, request: None,
                    }),
                }),
                Route("POST", "/endpoint-b"): type("ProcB", (), {
                    "__init__": lambda self, interactor=None: None,
                    "interactor": type("IntB", (), {
                        "execute": lambda self, request: None,
                    }),
                }),
            },
            builder=builder,
        )

        # Build schemas directly to control the types
        params_a = build_params(route=Route("POST", "/endpoint-a"))
        params_b = build_params(route=Route("POST", "/endpoint-b"))

        builder.build_schema_model(schema_type=DtoA, parameters=params_a)
        builder.build_schema_model(schema_type=DtoB, parameters=params_b)

        return builder, DtoA, DtoB

    def test_both_schemas_registered(self):
        builder, _, _ = self._build()
        schemas = builder.get_component_model()._build_schemas()
        entity_schemas = [n for n in schemas if "SimpleEntity" in n]
        self.assertEqual(len(entity_schemas), 2)

    def test_second_schema_has_module_prefix(self):
        builder, _, _ = self._build()
        schemas = builder.get_component_model()._build_schemas()
        entity_names = sorted(n for n in schemas if "SimpleEntity" in n)
        # First registered keeps clean name, second gets module prefix
        self.assertIn("SimpleEntity", entity_names)
        self.assertTrue(
            any(n != "SimpleEntity" and "SimpleEntity" in n for n in entity_names),
            f"Expected a prefixed variant, got: {entity_names}",
        )

    def test_no_uuid_hash_in_names(self):
        builder, _, _ = self._build()
        schemas = builder.get_component_model()._build_schemas()
        entity_names = [n for n in schemas if "SimpleEntity" in n]
        for name in entity_names:
            self.assertNotRegex(name, r'-[0-9a-f]{10}$')

    def test_schemas_have_correct_properties(self):
        builder, _, _ = self._build()
        schemas = builder.get_component_model()._build_schemas()
        entity_schemas = {n: s for n, s in schemas.items() if "SimpleEntity" in n}

        props_per_schema = {}
        for name, schema in entity_schemas.items():
            d = schema.to_dict()
            props_per_schema[name] = set(d.get("properties", {}).keys())

        all_props = list(props_per_schema.values())
        self.assertEqual(len(all_props), 2)
        self.assertNotEqual(all_props[0], all_props[1])
