import ast
from pathlib import Path
from textwrap import dedent
from unittest.mock import patch  # Moved from test_scan_module_file_size_limit

import pytest

from hipr.cli.generate_stubs import main
from hipr.constraints import validate_constraint_conflicts
from hipr.stubs.constraints import (
  extract_constraint_value,
  extract_constraints_from_annotation,
)
from hipr.stubs.extraction import (
  extract_all_params,
  extract_all_params_from_class,
  extract_hyperparams,
  extract_hyperparams_from_dataclass,
  extract_hyperparams_from_init,
  scan_module,
)
from hipr.stubs.generation import (
  generate_config_class,
  generate_configurable_class,
  generate_stub_content,
  generate_stub_header,
  has_default_usage,
  process_file,
)
from hipr.stubs.imports import (
  collect_constant_references,
  collect_type_references_from_ast,
  collect_used_names_from_stub,
  extract_imports,
  extract_local_type_definitions,
  extract_module_constants,
  extract_public_functions,
  filter_imports_by_usage,
)
from hipr.stubs.models import FunctionInfo, HyperParam
from hipr.stubs.utils import (
  extract_default_value,
  is_configurable_decorated,
  is_dataclass_decorated,
)


@pytest.fixture
def temp_source_file(tmp_path: Path) -> Path:
  source_code = dedent("""
        from hipr import configurable, Hyper, Ge

        @configurable
        def my_func(
            data: list[float],
            period: Hyper[int, Ge[1]] = 14,
            alpha: float = 0.5,
        ) -> list[float]:
            return [x * alpha for x in data]

        @configurable
        class MyIndicator:
            def __init__(self, window: Hyper[int] = 10):
                self.window = window

            def __call__(self, data: list[float]) -> list[float]:
                return data
    """)
  file_path = tmp_path / "test_source.py"
  file_path.write_text(source_code, encoding="utf-8")
  return file_path


def test_is_configurable_decorated():
  code = "@configurable\ndef foo(): pass"
  tree = ast.parse(code)
  func_def = tree.body[0]
  assert isinstance(func_def, ast.FunctionDef)
  assert is_configurable_decorated(func_def)

  code = "def bar(): pass"
  tree = ast.parse(code)
  func_def = tree.body[0]
  assert isinstance(func_def, ast.FunctionDef)
  assert not is_configurable_decorated(func_def)


def test_extract_hyperparams():
  code = dedent("""
        def foo(
            data,
            p1: Hyper[int] = 1,
            p2: Hyper[float, Ge[0.0]] = 0.5,
            other: int = 10
        ): pass
    """)
  tree = ast.parse(code)
  func_def = tree.body[0]
  assert isinstance(func_def, ast.FunctionDef)

  params, has_data = extract_hyperparams(func_def)
  assert has_data
  assert len(params) == 2
  assert params[0] == HyperParam("p1", "int", "1")
  assert params[1] == HyperParam("p2", "float", "0.5")


def test_extract_all_params():
  code = dedent("""
        def foo(
            data: list[int],
            p1: Hyper[int] = 1,
            other: str = "s"
        ): pass
    """)
  tree = ast.parse(code)
  func_def = tree.body[0]
  assert isinstance(func_def, ast.FunctionDef)

  all_params = extract_all_params(func_def)
  assert len(all_params) == 3
  assert all_params[0] == ("data", "list[int]", "")
  assert all_params[1] == ("p1", "int", "1")
  assert all_params[2] == ("other", "str", "'s'")


def test_scan_module(temp_source_file: Path):
  functions = scan_module(temp_source_file)
  # Should find both the function and the class
  assert len(functions) == 2

  # Check the function
  func_info = functions[0]
  assert func_info.name == "my_func"
  assert len(func_info.params) == 1
  assert func_info.params[0].name == "period"
  assert func_info.has_data_param is True

  # Check the class
  class_info = functions[1]
  assert class_info.name == "MyIndicator"
  assert len(class_info.params) == 1
  assert class_info.params[0].name == "window"
  assert class_info.params[0].type_annotation == "int"
  assert class_info.params[0].default_value == "10"
  assert class_info.has_data_param is False
  assert class_info.return_type == "MyIndicator"


def test_generate_config_class():
  params = [HyperParam("p1", "int", "1")]
  code = generate_config_class("my_func", params, "list[float]")
  assert "class MyFuncConfig(MakeableModel[list[float]]):" in code
  assert "p1: int" in code
  assert "def __init__(self, *, p1: int = 1) -> None: ..." in code
  assert "def __call__" not in code


def test_generate_configurable_class():
  params = [HyperParam("p1", "int", "1")]
  all_params = [("data", "list", ""), ("p1", "int", "1")]
  func_info = FunctionInfo(
    name="my_func",
    params=params,
    all_params=all_params,
    return_type="list[float]",
    has_data_param=True,
    is_function=True,
  )
  code = generate_configurable_class(func_info, "    class Config: ...")
  assert "class my_func:" in code
  assert "class Config: ..." in code
  assert "def __new__(cls, data: list, *, p1: int = 1) -> list[float]: ..." in code
  assert "def to_config(cls) -> Config: ..." in code
  # assert "my_func: TypeAlias = _MyFuncConfigurable" in code # This assertion might be wrong if I didn't change the return value of generate_configurable_class? No, it returns string.


def test_generate_stub_content(temp_source_file: Path):
  functions = scan_module(temp_source_file)
  content = generate_stub_content(functions, temp_source_file)
  assert "import pandas as pd" not in content  # No pandas in temp_source_file
  assert "class Config(MakeableModel" in content
  # nested config
  assert "def to_config(cls)" in content


def test_extract_constraints_from_annotation():
  """Test extracting constraint values from AST."""
  code = "def f(x: Hyper[int, Ge[10], Le[100]]): pass"
  tree = ast.parse(code)
  func = tree.body[0]
  assert isinstance(func, ast.FunctionDef)
  arg = func.args.args[0]
  assert isinstance(arg.annotation, ast.Subscript)

  constraints = extract_constraints_from_annotation(arg.annotation)
  assert constraints == {"ge": 10, "le": 100}


def test_extract_multiple_constraint_types():
  """Test extracting different types of constraints."""
  code = "def f(x: Hyper[str, MinLen[5], MaxLen[50], Pattern[r'^[A-Z]']]): pass"
  tree = ast.parse(code)
  func = tree.body[0]
  assert isinstance(func, ast.FunctionDef)
  arg = func.args.args[0]
  assert isinstance(arg.annotation, ast.Subscript)

  constraints = extract_constraints_from_annotation(arg.annotation)
  assert constraints["min_length"] == 5
  assert constraints["max_length"] == 50
  assert constraints["pattern"] == r"^[A-Z]"


def test_validate_constraint_conflicts_catches_contradictions():
  """Test that validation catches contradictory numeric bounds."""
  # Ge[100] and Le[50] should fail
  with pytest.raises(ValueError, match="contradictory constraints"):
    validate_constraint_conflicts({"ge": 100, "le": 50}, "x", "test_func")


def test_validate_constraint_conflicts_catches_strict_bounds():
  """Test that validation catches impossible strict/non-strict bounds."""
  # Ge[50] and Lt[50] should fail
  with pytest.raises(ValueError, match="strict and non-strict bounds"):
    validate_constraint_conflicts({"ge": 50, "lt": 50}, "x", "test_func")


def test_validate_constraint_conflicts_catches_length_issues():
  """Test that validation catches MinLen > MaxLen."""
  with pytest.raises(ValueError, match="contradictory constraints"):
    validate_constraint_conflicts(
      {"min_length": 10, "max_length": 5}, "text", "test_func"
    )


def test_validate_constraint_conflicts_allows_valid_constraints():
  """Test that validation allows valid constraints."""
  # These should not raise
  validate_constraint_conflicts({"ge": 10, "le": 100}, "x", "test_func")
  validate_constraint_conflicts({"gt": 0, "lt": 100}, "x", "test_func")
  validate_constraint_conflicts(
    {"min_length": 5, "max_length": 50}, "text", "test_func"
  )


def test_scan_module_rejects_contradictory_constraints(tmp_path: Path):
  """Test that scan_module raises error for contradictory constraints."""
  bad_file = tmp_path / "bad.py"
  bad_file.write_text(
    dedent("""
        from hipr import configurable, Hyper, Ge, Le

        @configurable
        def bad_func(x: Hyper[int, Ge[100], Le[50]] = 75) -> int:
            return x
    """)
  )

  with pytest.raises(ValueError, match="contradictory constraints"):
    scan_module(bad_file)


def test_scan_module_accepts_valid_constraints(tmp_path: Path):
  """Test that scan_module works with valid constraints."""
  good_file = tmp_path / "good.py"
  good_file.write_text(
    dedent("""
        from hipr import configurable, Hyper, Ge, Le

        @configurable
        def good_func(x: Hyper[int, Ge[10], Le[100]] = 50) -> int:
            return x
    """)
  )

  functions = scan_module(good_file)
  assert len(functions) == 1
  assert functions[0].name == "good_func"


def test_default_sentinel_in_stub_generation(tmp_path: Path):
  """Test that DEFAULT sentinel is properly handled in stub generation."""
  source_file = tmp_path / "with_default.py"
  source_file.write_text(
    dedent("""
        from hipr import configurable, Hyper, DEFAULT

        @configurable
        class InnerConfig:
            def __init__(self, param: Hyper[int] = 10):
                self.param = param

        @configurable
        def outer_func(
            data: list[float],
            inner: Hyper[InnerConfig.Config] = DEFAULT,
        ) -> float:
            return sum(data)
    """)
  )

  # Generate stub content
  stub_content = generate_stub_content(scan_module(source_file), source_file)

  # Verify DEFAULT is imported
  assert "from hipr import DEFAULT, MakeableModel" in stub_content

  # Verify the default value is preserved in the stub
  assert "inner: InnerConfig.Config = DEFAULT" in stub_content


def test_scan_module_with_dataclass(tmp_path: Path):
  """Test that scan_module properly handles @dataclass decorated classes."""
  dataclass_file = tmp_path / "dataclass_test.py"
  dataclass_file.write_text(
    dedent("""
        from dataclasses import dataclass
        from hipr import configurable, Hyper, Ge

        @configurable
        @dataclass
        class DataConfig:
            learning_rate: Hyper[float, Ge[0.0]] = 0.01
            batch_size: Hyper[int, Ge[1]] = 32
            epochs: Hyper[int, Ge[1]] = 10
    """)
  )

  functions = scan_module(dataclass_file)
  assert len(functions) == 1

  class_info = functions[0]
  assert class_info.name == "DataConfig"
  assert len(class_info.params) == 3

  # Check learning_rate param
  assert class_info.params[0].name == "learning_rate"
  assert class_info.params[0].type_annotation == "float"
  assert class_info.params[0].default_value == "0.01"

  # Check batch_size param
  assert class_info.params[1].name == "batch_size"
  assert class_info.params[1].type_annotation == "int"
  assert class_info.params[1].default_value == "32"

  # Check epochs param
  assert class_info.params[2].name == "epochs"
  assert class_info.params[2].type_annotation == "int"
  assert class_info.params[2].default_value == "10"

  assert class_info.has_data_param is False
  assert class_info.return_type == "DataConfig"


def test_scan_module_with_methods(tmp_path: Path):
  """Test that scan_module properly handles instance, class, and static methods."""
  methods_file = tmp_path / "methods_test.py"
  methods_file.write_text(
    dedent("""
        from hipr import configurable, Hyper, Ge

        class MyClass:
            @configurable
            def instance_method(
                self, data: list[float], window: Hyper[int, Ge[1]] = 10
            ) -> float:
                return sum(data) / window

            @classmethod
            @configurable
            def class_method(
                cls, data: list[float], alpha: Hyper[float, Ge[0.0]] = 0.5
            ) -> float:
                return sum(data) * alpha

            @staticmethod
            @configurable
            def static_method(
                data: list[float], beta: Hyper[float] = 1.0
            ) -> float:
                return sum(data) + beta
    """)
  )

  functions = scan_module(methods_file)
  assert len(functions) == 3

  # Check instance method
  instance_info = functions[0]
  assert instance_info.name == "instance_method"
  assert len(instance_info.params) == 1
  assert instance_info.params[0].name == "window"
  assert instance_info.params[0].type_annotation == "int"
  assert instance_info.params[0].default_value == "10"
  assert instance_info.has_data_param is True  # 'data' is detected
  assert instance_info.return_type == "float"

  # Check class method
  class_method_info = functions[1]
  assert class_method_info.name == "class_method"
  assert len(class_method_info.params) == 1
  assert class_method_info.params[0].name == "alpha"
  assert class_method_info.params[0].type_annotation == "float"
  assert class_method_info.params[0].default_value == "0.5"
  assert class_method_info.has_data_param is True
  assert class_method_info.return_type == "float"

  # Check static method
  static_method_info = functions[2]
  assert static_method_info.name == "static_method"
  assert len(static_method_info.params) == 1
  assert static_method_info.params[0].name == "beta"
  assert static_method_info.params[0].type_annotation == "float"
  assert static_method_info.params[0].default_value == "1.0"
  assert static_method_info.has_data_param is True
  assert static_method_info.return_type == "float"


def test_extract_constraint_value_edge_cases():
  """Test extract_constraint_value with various AST node types."""
  # Test with Constant node (numeric)
  code = "Ge[10]"
  tree = ast.parse(code, mode="eval")
  assert isinstance(tree.body, ast.Subscript)
  value = extract_constraint_value(tree.body)
  assert value == 10

  # Test with string constant
  code = "Pattern[r'^test$']"
  tree = ast.parse(code, mode="eval")
  assert isinstance(tree.body, ast.Subscript)
  value = extract_constraint_value(tree.body)
  assert value == r"^test$"

  # Test with non-extractable value
  code = "SomeConstraint[x + 5]"
  tree = ast.parse(code, mode="eval")
  assert isinstance(tree.body, ast.Subscript)
  value = extract_constraint_value(tree.body)
  assert value is None


def test_extract_constraints_from_annotation_edge_cases():
  """Test edge cases in constraint extraction."""
  # Test with non-tuple slice (single type, no constraints)
  code = "def f(x: Hyper[int]): pass"
  tree = ast.parse(code)
  func = tree.body[0]
  assert isinstance(func, ast.FunctionDef)
  arg = func.args.args[0]
  assert isinstance(arg.annotation, ast.Subscript)

  constraints = extract_constraints_from_annotation(arg.annotation)
  assert constraints == {}

  # Test with unknown constraint type
  code = "def f(x: Hyper[int, UnknownConstraint[42]]): pass"
  tree = ast.parse(code)
  func = tree.body[0]
  assert isinstance(func, ast.FunctionDef)
  arg = func.args.args[0]
  assert isinstance(arg.annotation, ast.Subscript)

  constraints = extract_constraints_from_annotation(arg.annotation)
  # UnknownConstraint should be ignored
  assert constraints == {}


def test_extract_default_value():
  """Test extract_default_value function."""
  # Test with None
  assert extract_default_value(None) == "..."

  # Test with a constant
  code = "10"
  tree = ast.parse(code, mode="eval")
  assert extract_default_value(tree.body) == "10"


def test_extract_all_params_no_annotations():
  """Test extracting parameters without annotations."""
  code = dedent("""
        def foo(self, x, y=5):
            pass
    """)
  tree = ast.parse(code)
  func_def = tree.body[0]
  assert isinstance(func_def, ast.FunctionDef)

  all_params = extract_all_params(func_def)
  # Should have 2 params (x, y) - self is skipped
  assert len(all_params) == 2
  assert all_params[0] == ("x", "Any", "")
  assert all_params[1] == ("y", "Any", "5")


def test_extract_hyperparams_from_init_no_init():
  """Test extracting from class with no __init__."""
  code = dedent("""
        class MyClass:
            def other_method(self):
                pass
    """)
  tree = ast.parse(code)
  class_def = tree.body[0]
  assert isinstance(class_def, ast.ClassDef)

  result = extract_hyperparams_from_init(class_def)
  assert result is None


def test_extract_hyperparams_from_dataclass_with_data_param():
  """Test dataclass with a 'data' field."""
  code = dedent("""
        @dataclass
        class Config:
            data: list[float]
            window: Hyper[int] = 10
    """)
  tree = ast.parse(code)
  class_def = tree.body[0]
  assert isinstance(class_def, ast.ClassDef)

  params, has_data = extract_hyperparams_from_dataclass(class_def)
  assert has_data is True
  assert len(params) == 1
  assert params[0].name == "window"


def test_extract_all_params_from_class_no_annotations():
  """Test extracting params from class fields without annotations."""
  code = dedent("""
        @dataclass
        class Config:
            x = 10
    """)
  tree = ast.parse(code)
  class_def = tree.body[0]
  assert isinstance(class_def, ast.ClassDef)

  # Should have no fields without annotations for the dataclass case
  all_params = extract_all_params_from_class(class_def)
  # Without annotation, AnnAssign won't match, so we get empty list
  assert len(all_params) == 0


def test_scan_module_file_size_limit(tmp_path: Path):
  """Test that scan_module skips files that are too large."""

  large_file = tmp_path / "large.py"
  large_file.write_text("# small file\n")

  # Mock the file size to be over the limit
  with patch.object(Path, "stat") as mock_stat:
    mock_stat.return_value.st_size = 11 * 1024 * 1024  # 11MB
    functions = scan_module(large_file)
    assert len(functions) == 0


def test_scan_module_class_without_init(tmp_path: Path):
  """Test scanning a @configurable class without __init__ or @dataclass."""
  no_init_file = tmp_path / "no_init.py"
  no_init_file.write_text(
    dedent("""
        from hipr import configurable

        @configurable
        class NoInitClass:
            def some_method(self):
                pass
    """)
  )

  functions = scan_module(no_init_file)
  # Class without __init__ should be skipped
  assert len(functions) == 0


def test_generate_config_class_no_params():
  """Test generating config class with no parameters."""
  params: list[HyperParam] = []
  code = generate_config_class("empty_func", params, "None")
  assert "class EmptyFuncConfig(MakeableModel[None]):" in code
  # assert "pass" in code # No longer true as we have methods now


def test_generate_configurable_class_long_signature():
  """Test generating configurable class with long signature that needs wrapping."""
  params = [
    HyperParam("param1", "int", "1"),
    HyperParam("param2", "int", "2"),
    HyperParam("param3", "int", "3"),
  ]
  all_params = [
    ("very_long_parameter_name_data", "list[float]", ""),
    ("very_long_param1", "int", "1"),
    ("very_long_param2", "int", "2"),
    ("very_long_param3", "int", "3"),
  ]
  func_info = FunctionInfo(
    name="very_long_function_name",
    params=params,
    all_params=all_params,
    return_type="list[float]",
    has_data_param=True,
    is_function=True,
  )
  code = generate_configurable_class(func_info, "    class Config: ...")
  # Should wrap the signature across multiple lines
  assert "def __new__(" in code


def test_generate_configurable_class_no_hyper_params():
  """Test generating configurable class without hyper params."""
  params: list[HyperParam] = []
  all_params = [("x", "int", ""), ("y", "int", "")]
  func_info = FunctionInfo(
    name="simple",
    params=params,
    all_params=all_params,
    return_type="int",
    has_data_param=False,
    is_function=True,
  )
  code = generate_configurable_class(func_info, "    class Config: ...")
  assert "def __new__(cls, x: int, y: int) -> int: ..." in code


def test_extract_imports():
  """Test extracting imports from source."""
  # Test: from pandas import DataFrame
  code = "from pandas import DataFrame\n"
  tree = ast.parse(code)
  future, other = extract_imports(tree)
  assert not future
  assert "from pandas import DataFrame" in other[0]

  # Test: import pandas
  code = "import pandas\n"
  tree = ast.parse(code)
  future, other = extract_imports(tree)
  assert not future
  assert "import pandas" in other[0]

  # Test: future import
  code = "from __future__ import annotations\nimport os"
  tree = ast.parse(code)
  future, other = extract_imports(tree)
  assert "from __future__ import annotations" in future[0]
  assert "import os" in other[0]


def test_has_default_usage():
  """Test detecting DEFAULT sentinel usage."""
  # Test with DEFAULT
  code_with_default = "x = DEFAULT"
  tree = ast.parse(code_with_default)
  assert has_default_usage(tree) is True

  # Test without DEFAULT
  code_without_default = "x = 10"
  tree = ast.parse(code_without_default)
  assert has_default_usage(tree) is False


def test_collect_used_names_from_stub():
  """Test collecting names used in stub content."""

  # Test with simple class using DataFrame
  stub_content = dedent("""
    class ProcessConfig(MakeableModel[DataFrame]):
      window: int
      def __init__(self, *, window: int = 10) -> None: ...
  """)
  used_names = collect_used_names_from_stub(stub_content)
  assert "MakeableModel" in used_names
  assert "DataFrame" in used_names
  assert "int" in used_names

  # Test with multiple types
  stub_content2 = dedent("""
    class MyConfig(MakeableModel[Series]):
      data: DataFrame
      result: Optional[Series]
  """)
  used_names2 = collect_used_names_from_stub(stub_content2)
  assert "MakeableModel" in used_names2
  assert "Series" in used_names2
  assert "DataFrame" in used_names2
  assert "Optional" in used_names2


def test_filter_imports_by_usage():
  """Test filtering imports based on usage."""

  imports = [
    "from pandas import DataFrame, Series",
    "import numpy as np",
    "from typing import Optional",
    "from __future__ import annotations",
  ]

  # Test filtering with only DataFrame used (not Series)
  used_names = {"DataFrame", "Optional", "MakeableModel"}
  filtered = filter_imports_by_usage(imports, used_names)

  # Should keep __future__ (always kept)
  assert "from __future__ import annotations" in filtered
  # Should keep ONLY DataFrame from pandas import (Series not used)
  pandas_import = next(imp for imp in filtered if "pandas" in imp)
  assert "DataFrame" in pandas_import
  assert "Series" not in pandas_import  # Series should be filtered out!
  # Should keep Optional import (Optional is used)
  assert "from typing import Optional" in filtered
  # Should exclude numpy (not used)
  assert "import numpy as np" not in filtered


def test_filter_imports_keeps_essentials():
  """Test that essential imports are always kept."""

  imports = [
    "from __future__ import annotations",
    "if TYPE_CHECKING:\\n  from pandas import DataFrame",
    "import unused_module",
  ]

  used_names: set[str] = set()  # Nothing is "used"

  filtered = filter_imports_by_usage(imports, used_names)

  # __future__ and TYPE_CHECKING should always be kept
  assert "from __future__ import annotations" in filtered
  assert any("TYPE_CHECKING" in imp for imp in filtered)
  # Unused module should be filtered out
  assert "import unused_module" not in filtered


def test_collect_constant_references():
  """Test collecting constant references from function default values."""
  from hipr.stubs.models import FunctionInfo, HyperParam

  # Create functions with constant references
  functions = [
    FunctionInfo(
      name="func1",
      params=[
        HyperParam("window", "int", "WINDOW_SIZE"),
        HyperParam("threshold", "float", "0.5"),
      ],
      all_params=[("window", "int", "WINDOW_SIZE")],
      return_type="int",
      has_data_param=False,
      is_function=True,
    ),
    FunctionInfo(
      name="func2",
      params=[HyperParam("mode", "str", "DEFAULT_MODE")],
      all_params=[],
      return_type="str",
      has_data_param=False,
      is_function=True,
    ),
  ]

  refs = collect_constant_references(functions)
  assert "WINDOW_SIZE" in refs
  assert "DEFAULT_MODE" in refs


def test_extract_module_constants():
  """Test extracting module constant definitions from source."""

  code = dedent("""
    WINDOW_SIZE = 10
    DEFAULT_MODE = "fast"
    UNUSED = 42
    threshold: float = 0.5
  """)
  tree = ast.parse(code)

  # Extract specific constants
  constant_names = {"WINDOW_SIZE", "DEFAULT_MODE", "threshold"}
  constants = extract_module_constants(tree, constant_names)

  assert len(constants) == 3
  assert "WINDOW_SIZE = 10" in constants
  assert 'DEFAULT_MODE = "fast"' in constants or "DEFAULT_MODE = 'fast'" in constants
  assert "threshold: float = 0.5" in constants
  # UNUSED should not be included
  assert not any("UNUSED" in c for c in constants)


def test_collect_type_references_from_ast():
  """Test collecting type references from AST function nodes."""

  code = dedent("""
    from collections.abc import Callable
    import pandas as pd
    
    def helper(data: pd.Series, func: Callable[[int], float]) -> list[float]:
      pass
  """)
  tree = ast.parse(code)

  # Extract function nodes
  func_nodes = [node for node in tree.body if isinstance(node, ast.FunctionDef)]

  type_refs = collect_type_references_from_ast(func_nodes)

  # Should collect pd and Callable
  assert "pd" in type_refs
  assert "Callable" in type_refs
  # Should filter out built-ins
  assert "int" not in type_refs
  assert "list" not in type_refs
  assert "float" not in type_refs  # float is also a built-in


def test_extract_local_type_definitions():
  """Test extracting local class and type alias definitions."""

  code = dedent("""
    class DataPoint:
      x: float
      y: float
    
    MyDict = dict[str, int]
    
    class UnusedClass:
      pass
    
    class ConfigurableClass:
      value: int
  """)
  tree = ast.parse(code)

  type_names = {"DataPoint", "MyDict", "ConfigurableClass"}
  configurable_names = {"ConfigurableClass"}  # This one is @configurable
  definitions, _ = extract_local_type_definitions(tree, type_names, configurable_names)

  assert len(definitions) == 2
  # Should include DataPoint
  assert any("class DataPoint:" in d for d in definitions)
  # Should include MyDict
  assert any("MyDict = dict[str, int]" in d for d in definitions)
  # Should not include UnusedClass (not in type_names)
  assert not any("UnusedClass" in d for d in definitions)
  # Should not include ConfigurableClass (excluded as @configurable)
  assert not any("ConfigurableClass" in d for d in definitions)


def test_extract_public_functions():
  """Test extracting public function signatures."""

  code = dedent("""
    def public_helper(x: int) -> float:
      return x * 2.0
    
    def _private_helper(x: int) -> int:
      return x * 2
    
    def configurable_func(x: int) -> int:
      return x
  """)
  tree = ast.parse(code)

  configurable_names = {"configurable_func"}
  sigs, nodes = extract_public_functions(tree, configurable_names)

  # Should include only public_helper (not private or configurable)
  assert len(sigs) == 1
  assert len(nodes) == 1
  assert "public_helper" in sigs[0]
  assert "def public_helper(x: int) -> float:" in sigs[0]
  assert "..." in sigs[0]
  # Should not include private
  assert not any("_private_helper" in s for s in sigs)
  # Should not include configurable (handled separately)
  assert not any("configurable_func" in s for s in sigs)


def test_generate_stub_with_public_functions(tmp_path: Path):
  """Test that stubs include public functions."""
  from hipr.stubs.extraction import scan_module
  from hipr.stubs.generation import generate_stub_content

  source_file = tmp_path / "test_public.py"
  source_file.write_text(
    dedent("""
      from hipr import configurable, Hyper
      
      def helper(x: int) -> float:
        return x * 2.0
      
      @configurable
      def process(x: Hyper[int] = 10) -> float:
        return helper(x)
    """)
  )

  functions = scan_module(source_file)
  content = generate_stub_content(functions, source_file)

  # Should include helper function
  assert "def helper(x: int) -> float:" in content
  # Should include configurable
  assert "def helper(x: int) -> float:" in content


def test_generate_stub_with_local_types(tmp_path: Path):
  """Test that stubs include local type definitions."""
  from hipr.stubs.extraction import scan_module
  from hipr.stubs.generation import generate_stub_content

  source_file = tmp_path / "test_types.py"
  source_file.write_text(
    dedent("""
      from hipr import configurable, Hyper
      
      class DataPoint:
        x: float
        y: float
      
      @configurable
      def process(data: list[DataPoint]) -> DataPoint:
        return data[0]
    """)
  )

  functions = scan_module(source_file)
  content = generate_stub_content(functions, source_file)

  # Should include DataPoint class definition
  assert "# Local type definitions" in content
  assert "class DataPoint:" in content
  assert "x: float" in content
  assert "y: float" in content


def test_public_function_imports_included(tmp_path: Path):
  """Test that imports used in public functions are included."""
  from hipr.stubs.extraction import scan_module
  from hipr.stubs.generation import generate_stub_content

  source_file = tmp_path / "test_imports.py"
  source_file.write_text(
    dedent("""
      from collections.abc import Callable
      from hipr import configurable, Hyper
      
      def helper(func: Callable[[int], float]) -> float:
        return func(10)
      
      @configurable
      def process(x: Hyper[int] = 10) -> float:
        return helper(lambda y: y * 2.0)
    """)
  )

  functions = scan_module(source_file)
  content = generate_stub_content(functions, source_file)

  # Should include Callable import (used in public function)
  assert "from collections.abc import Callable" in content
  # Should include helper function
  assert "def helper(func: Callable[[int], float]) -> float:" in content


def test_generate_stub_header():
  """Test stub header generation."""
  # Test with imports and DEFAULT

  header = generate_stub_header(
    future_imports=[], other_imports=["import pandas as pd"], has_default=True
  )
  assert "import pandas as pd" in header
  assert "from hipr import DEFAULT, MakeableModel" in header

  # Test without imports
  header = generate_stub_header(future_imports=[], other_imports=[], has_default=False)
  assert "import pandas" not in header
  assert "from hipr import MakeableModel" in header
  assert "DEFAULT" not in header


def test_generate_stub_content_empty_functions(tmp_path: Path):
  """Test generating stub content with no functions."""
  empty_file = tmp_path / "empty.py"
  empty_file.write_text("# No configurable functions\n")

  functions: list[FunctionInfo] = []
  content = generate_stub_content(functions, empty_file)
  assert not content


def test_process_file_with_configurable(tmp_path: Path):
  """Test process_file generates stub for file with @configurable."""
  source_file = tmp_path / "source.py"
  source_file.write_text(
    dedent("""
        from hipr import configurable, Hyper

        @configurable
        def my_func(x: Hyper[int] = 10) -> int:
            return x
    """)
  )

  result = process_file(source_file)
  assert result is True

  stub_file = tmp_path / "source.pyi"
  assert stub_file.exists()
  stub_content = stub_file.read_text()
  assert "class Config(MakeableModel" in stub_content


def test_process_file_without_configurable(tmp_path: Path):
  """Test process_file returns False for file without @configurable."""
  source_file = tmp_path / "plain.py"
  source_file.write_text(
    dedent("""
        def regular_func(x: int) -> int:
            return x
    """)
  )

  result = process_file(source_file)
  assert result is False

  stub_file = tmp_path / "plain.pyi"
  # Stub should not be created
  assert not stub_file.exists()


def test_is_dataclass_decorated():
  """Test detecting @dataclass decorator."""
  code = "@dataclass\nclass Foo: pass"
  tree = ast.parse(code)
  class_def = tree.body[0]
  assert isinstance(class_def, ast.ClassDef)
  assert is_dataclass_decorated(class_def)

  code = "class Bar: pass"
  tree = ast.parse(code)
  class_def = tree.body[0]
  assert isinstance(class_def, ast.ClassDef)
  assert not is_dataclass_decorated(class_def)


def test_main_default_directory(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
  """Test main() with default src directory."""
  # Create a test project structure
  src_dir = tmp_path / "src"
  src_dir.mkdir()

  # Use a name that doesn't start with "test_" to avoid being skipped
  module_file = src_dir / "my_module.py"
  module_file.write_text(
    dedent("""
        from hipr import configurable, Hyper

        @configurable
        def my_func(x: Hyper[int] = 10) -> int:
            return x
    """)
  )

  # Change to the temp directory
  monkeypatch.chdir(tmp_path)

  # Run main with default args
  exit_code = main([])
  assert exit_code == 0

  # Verify stub was created
  stub_file = src_dir / "my_module.pyi"
  assert stub_file.exists()


def test_main_custom_directory(tmp_path: Path):
  """Test main() with custom directory."""
  custom_dir = tmp_path / "custom"
  custom_dir.mkdir()

  test_file = custom_dir / "module.py"
  test_file.write_text(
    dedent("""
        from hipr import configurable, Hyper

        @configurable
        def func(x: Hyper[int] = 5) -> int:
            return x
    """)
  )

  # Run main with custom directory
  exit_code = main([str(custom_dir)])
  assert exit_code == 0

  stub_file = custom_dir / "module.pyi"
  assert stub_file.exists()


def test_main_nonexistent_directory(tmp_path: Path):
  """Test main() with nonexistent pattern."""
  nonexistent = tmp_path / "does_not_exist"

  exit_code = main([str(nonexistent)])
  assert exit_code == 1  # No files found


def test_main_single_file(tmp_path: Path):
  """Test main() with a single Python file."""
  test_file = tmp_path / "single_module.py"
  test_file.write_text(
    dedent("""
        from hipr import configurable, Hyper

        @configurable
        def single_func(x: Hyper[int] = 10) -> int:
            return x
    """)
  )

  exit_code = main([str(test_file)])
  assert exit_code == 0

  # Stub should be created
  stub_file = tmp_path / "single_module.pyi"
  assert stub_file.exists()


def test_main_glob_pattern(tmp_path: Path):
  """Test main() with glob pattern."""
  test_dir = tmp_path / "modules"
  test_dir.mkdir()

  # Create two files
  file1 = test_dir / "mod1.py"
  file1.write_text(
    dedent("""
        from hipr import configurable, Hyper

        @configurable
        def func1(x: Hyper[int] = 1) -> int:
            return x
    """)
  )

  file2 = test_dir / "mod2.py"
  file2.write_text(
    dedent("""
        from hipr import configurable, Hyper

        @configurable
        def func2(x: Hyper[int] = 2) -> int:
            return x
    """)
  )

  # Use glob pattern
  import os

  os.chdir(tmp_path)
  exit_code = main(["modules/*.py"])
  assert exit_code == 0

  # Both stubs should be created
  assert (test_dir / "mod1.pyi").exists()
  assert (test_dir / "mod2.pyi").exists()


def test_main_non_python_file(tmp_path: Path):
  """Test main() with a non-Python file returns error."""
  test_file = tmp_path / "not_python.txt"
  test_file.write_text("content")

  exit_code = main([str(test_file)])
  assert exit_code == 1  # No Python files found


def test_main_multiple_files(tmp_path: Path):
  """Test main() with multiple specific files."""
  # Create two files
  file1 = tmp_path / "mod1.py"
  file1.write_text(
    dedent("""
        from hipr import configurable, Hyper

        @configurable
        def func1(x: Hyper[int] = 1) -> int:
            return x
    """)
  )

  file2 = tmp_path / "mod2.py"
  file2.write_text(
    dedent("""
        from hipr import configurable, Hyper

        @configurable
        def func2(x: Hyper[int] = 2) -> int:
            return x
    """)
  )

  # Pass multiple files directly
  exit_code = main([str(file1), str(file2)])
  assert exit_code == 0

  # Both stubs should be created
  assert (tmp_path / "mod1.pyi").exists()
  assert (tmp_path / "mod2.pyi").exists()


def test_main_verbose_mode(tmp_path: Path):
  """Test main() with verbose flag."""
  test_dir = tmp_path / "src"
  test_dir.mkdir()

  test_file = test_dir / "verbose_test.py"
  test_file.write_text(
    dedent("""
        from hipr import configurable, Hyper

        @configurable
        def verbose_func(x: Hyper[int] = 1) -> int:
            return x
    """)
  )

  # Run with verbose flag
  exit_code = main([str(test_dir), "--verbose"])
  assert exit_code == 0

  stub_file = test_dir / "verbose_test.pyi"
  assert stub_file.exists()


def test_main_skips_pycache(tmp_path: Path):
  """Test main() skips __pycache__ directories."""
  test_dir = tmp_path / "src"
  test_dir.mkdir()

  pycache_dir = test_dir / "__pycache__"
  pycache_dir.mkdir()

  cache_file = pycache_dir / "cached.py"
  cache_file.write_text("# should be skipped")

  exit_code = main([str(test_dir)])
  assert exit_code == 0

  # No stub should be created for pycache files
  stub_file = pycache_dir / "cached.pyi"
  assert not stub_file.exists()


def test_main_skips_test_files(tmp_path: Path):
  """Test main() skips test_*.py files."""
  test_dir = tmp_path / "src"
  test_dir.mkdir()

  test_file = test_dir / "test_something.py"
  test_file.write_text(
    dedent("""
        from hipr import configurable, Hyper

        @configurable
        def test_func(x: Hyper[int] = 1) -> int:
            return x
    """)
  )

  exit_code = main([str(test_dir)])
  assert exit_code == 0

  # No stub should be created for test files
  stub_file = test_dir / "test_something.pyi"
  assert not stub_file.exists()


def test_main_handles_syntax_errors(tmp_path: Path):
  """Test main() handles files with syntax errors gracefully."""
  test_dir = tmp_path / "src"
  test_dir.mkdir()

  bad_file = test_dir / "bad_syntax.py"
  bad_file.write_text("def broken( # invalid syntax")

  # Should not crash, just skip the file
  exit_code = main([str(test_dir)])
  assert exit_code == 0


def test_main_counts_generated_stubs(tmp_path: Path):
  """Test main() correctly counts generated stubs."""
  test_dir = tmp_path / "src"
  test_dir.mkdir()

  # Create two files with @configurable
  file1 = test_dir / "module1.py"
  file1.write_text(
    dedent("""
        from hipr import configurable, Hyper

        @configurable
        def func1(x: Hyper[int] = 1) -> int:
            return x
    """)
  )

  file2 = test_dir / "module2.py"
  file2.write_text(
    dedent("""
        from hipr import configurable, Hyper

        @configurable
        def func2(x: Hyper[int] = 2) -> int:
            return x
    """)
  )

  exit_code = main([str(test_dir)])
  assert exit_code == 0

  # Both stubs should exist
  assert (test_dir / "module1.pyi").exists()
  assert (test_dir / "module2.pyi").exists()


def test_external_configurable_lowercase_name(tmp_path: Path):
  """Test that lowercase external configurables are correctly transformed to .Config."""
  source_file = tmp_path / "with_external.py"
  source_file.write_text(
    dedent("""
        from __future__ import annotations
        from dataclasses import dataclass
        from typing import TYPE_CHECKING

        if TYPE_CHECKING:
            from hipr import Hyper

        from hipr import DEFAULT, configurable

        # Mock external configurable (lowercase like 'rvol' from indikator)
        class rvol:
            pass

        @configurable
        @dataclass
        class Strategy:
            param: Hyper[float] = 0.5
            rvol_cfg: Hyper[rvol] = DEFAULT
    """)
  )

  # Generate stub content
  generate_stub_content(scan_module(source_file), source_file)


def test_scan_module_coverage_boost(tmp_path: Path):
  """Test scan_module with complex dataclass to boost coverage."""
  source_file = tmp_path / "complex_dataclass.py"
  source_file.write_text(
    dedent("""
        from dataclasses import dataclass, field
        from hipr import configurable, Hyper
        
        @configurable
        @dataclass
        class ComplexConfig:
            x: int = 1
            y: int = field(default=2, init=False)
            z: Hyper[int] = 3
            def method(self): pass

        @configurable
        def complex_func(a: int, b: Hyper[int] = 1):
            pass
    """)
  )
  functions = scan_module(source_file)
  assert len(functions) == 2
  class_info = functions[0]
  # y should be skipped
  assert "y" not in [p.name for p in class_info.params]
  assert "x" in [p.name for p in class_info.params]
  assert "z" in [p.name for p in class_info.params]

  func_info = functions[1]
  assert func_info.is_function is True
  assert func_info.has_data_param is False

  stub_content = generate_stub_content(functions, source_file)

  # Verify ComplexConfig fields in stub
  assert "x: int" in stub_content
  assert "z: int" in stub_content
  # y should not be in __new__ signature as init=False
  assert "y: int" not in stub_content
