# /// script
# requires-python = ">=3.8"
# dependencies = []
# ///
"""
Analyze a Python module using AST to extract all testable units.

Usage:
  python scripts/analyze_module.py <path/to/module.py> [--format json|text]
  python scripts/analyze_module.py --help

Output:
  JSON with classes, methods, functions, type hints, raises, async flags,
  and concrete testing hints.
"""

import ast
import json
import sys
import argparse
from pathlib import Path


def _unparse(node) -> str:
    try:
        return ast.unparse(node)
    except AttributeError:
        return ast.dump(node)


def _collect_raises(func_node) -> list:
    seen, raises = set(), []
    for child in ast.walk(func_node):
        if isinstance(child, ast.Raise) and child.exc is not None:
            s = _unparse(child.exc)
            if s not in seen:
                seen.add(s)
                raises.append(s)
    return raises


def _analyze_function(node) -> dict:
    args = []
    for a in node.args.args:
        entry = {"name": a.arg}
        if a.annotation:
            entry["type"] = _unparse(a.annotation)
        args.append(entry)

    decorators  = [_unparse(d) for d in node.decorator_list]
    is_async    = isinstance(node, ast.AsyncFunctionDef)
    is_property = any("property" in d for d in decorators)
    is_cls      = any("classmethod" in d for d in decorators)
    is_static   = any("staticmethod" in d for d in decorators)
    is_private  = node.name.startswith("_") and not node.name.startswith("__")
    is_dunder   = node.name.startswith("__") and node.name.endswith("__")
    branch_count = sum(
        1 for n in ast.walk(node)
        if isinstance(n, (ast.If, ast.For, ast.While, ast.Try,
                          ast.ExceptHandler, ast.With))
    )
    return {
        "name": node.name, "lineno": node.lineno, "args": args,
        "returns": _unparse(node.returns) if node.returns else None,
        "decorators": decorators, "raises": _collect_raises(node),
        "is_async": is_async, "is_property": is_property,
        "is_classmethod": is_cls, "is_staticmethod": is_static,
        "is_private": is_private, "is_dunder": is_dunder,
        "branch_count": branch_count, "docstring": ast.get_docstring(node),
    }


def _analyze_class(node: ast.ClassDef) -> dict:
    bases      = [_unparse(b) for b in node.bases]
    decorators = [_unparse(d) for d in node.decorator_list]
    methods, attrs = [], []
    for item in node.body:
        if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
            methods.append(_analyze_function(item))
        elif isinstance(item, ast.Assign):
            for t in item.targets:
                if isinstance(t, ast.Name):
                    attrs.append(t.id)
        elif isinstance(item, ast.AnnAssign) and isinstance(item.target, ast.Name):
            attrs.append(item.target.id)
    return {
        "name": node.name, "lineno": node.lineno, "bases": bases,
        "decorators": decorators, "class_attrs": attrs, "methods": methods,
        "docstring": ast.get_docstring(node),
        "is_dataclass": any("dataclass" in d for d in decorators),
        "is_abstract":  any("ABC" in b or "ABCMeta" in b for b in bases),
        "is_exception": any(("Error" in b or "Exception" in b) for b in bases),
    }


def _hints(classes, functions) -> list:
    hints = []
    for cls in classes:
        if cls["is_dataclass"]:
            hints.append(f"'{cls['name']}' is a dataclass — test field defaults, equality, __repr__.")
        if cls["is_exception"]:
            hints.append(f"'{cls['name']}' is an exception — test message and isinstance checks.")
        for m in cls["methods"]:
            if m["raises"]:
                hints.append(f"'{cls['name']}.{m['name']}' raises {m['raises']} — add pytest.raises.")
            if m["is_async"]:
                hints.append(f"'{cls['name']}.{m['name']}' is async — use @pytest.mark.asyncio.")
            if m["branch_count"] >= 3:
                hints.append(f"'{cls['name']}.{m['name']}' has {m['branch_count']} branches — use parametrize.")
    for fn in functions:
        if fn["raises"]:
            hints.append(f"Function '{fn['name']}' raises {fn['raises']} — add pytest.raises.")
        if fn["is_async"]:
            hints.append(f"Function '{fn['name']}' is async — use @pytest.mark.asyncio.")
        if fn["branch_count"] >= 3:
            hints.append(f"Function '{fn['name']}' has {fn['branch_count']} branches — use parametrize.")
    return hints


def analyze(filepath: str) -> dict:
    path = Path(filepath)
    if not path.exists():
        print(f"Error: File not found: {filepath}", file=sys.stderr); sys.exit(1)
    if path.suffix != ".py":
        print(f"Error: Expected a .py file, got: {path.suffix}", file=sys.stderr); sys.exit(1)
    source = path.read_text(encoding="utf-8")
    try:
        tree = ast.parse(source, filename=str(path))
    except SyntaxError as e:
        print(f"Error: Syntax error in {filepath}: {e}", file=sys.stderr); sys.exit(1)

    classes, functions, constants = [], [], []
    for node in tree.body:
        if isinstance(node, ast.ClassDef):
            classes.append(_analyze_class(node))
        elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
            functions.append(_analyze_function(node))
        elif isinstance(node, ast.Assign):
            for t in node.targets:
                if isinstance(t, ast.Name) and t.id.isupper():
                    try:    val = ast.literal_eval(node.value)
                    except: val = _unparse(node.value)
                    constants.append({"name": t.id, "value": val})

    pub_fn = [f for f in functions if not f["name"].startswith("_")]
    pub_m  = [m for c in classes for m in c["methods"] if not m["name"].startswith("_")]
    has_async = (
        any(f["is_async"] for f in functions) or
        any(m["is_async"] for c in classes for m in c["methods"])
    )
    return {
        "file": str(path), "module_name": path.stem,
        "docstring": ast.get_docstring(tree),
        "classes": classes, "functions": functions, "constants": constants,
        "summary": {
            "total_classes": len(classes), "total_functions": len(functions),
            "public_functions": len(pub_fn),
            "total_methods": sum(len(c["methods"]) for c in classes),
            "public_methods": len(pub_m), "has_async": has_async,
            "suggested_test_file": f"tests/test_{path.stem}.py",
        },
        "testing_hints": _hints(classes, functions),
    }


def main():
    p = argparse.ArgumentParser(
        description="Analyze a Python module — extract all testable units.",
        epilog="Examples:\n  python scripts/analyze_module.py src/calculator.py\n  python scripts/analyze_module.py src/calculator.py --format text",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    p.add_argument("module", help="Path to the Python file to analyze")
    p.add_argument("--format", choices=["json", "text"], default="json")
    args = p.parse_args()
    result = analyze(args.module)
    if args.format == "json":
        print(json.dumps(result, indent=2))
    else:
        s = result["summary"]
        print(f"Module : {result['module_name']}\nFile   : {result['file']}")
        print(f"Tests  : {s['suggested_test_file']}\nAsync  : {'yes' if s['has_async'] else 'no'}\n")
        print(f"Classes ({s['total_classes']}):")
        for cls in result["classes"]:
            print(f"  {cls['name']} (line {cls['lineno']})")
            for m in cls["methods"]:
                exc = f"  raises={m['raises']}" if m["raises"] else ""
                print(f"    def {m['name']}{exc}")
        print(f"\nFunctions ({s['total_functions']}):")
        for fn in result["functions"]:
            exc = f"  raises={fn['raises']}" if fn["raises"] else ""
            print(f"  def {fn['name']} (line {fn['lineno']}){exc}")
        if result["testing_hints"]:
            print("\nTesting hints:")
            for h in result["testing_hints"]: print(f"  • {h}")

if __name__ == "__main__":
    main()
