from __future__ import annotations

import os
import json as _json
import math
import threading as _threading
import time as _time
from typing import Any, Callable


class BuiltinFunction:
    def __init__(self, name: str, func: Callable[..., Any]):
        self.name = name
        self.func = func

    def call(self, interpreter, args: list[Any], node=None):
        try:
            return self.func(interpreter, *args)
        except TypeError as e:
            # wrong arity, etc.
            raise BuiltinModuleError(str(e))


class BuiltinModuleError(Exception):
    pass


def _require_number(value: Any, name: str) -> float | int:
    if isinstance(value, bool) or not isinstance(value, (int, float)):
        raise BuiltinModuleError(f"{name} expects a number")
    return value


def _ganit_exports() -> dict[str, Any]:
    def sqrt(x):
        return math.sqrt(_require_number(x, "sqrt"))

    def pow_(a, b):
        a = _require_number(a, "pow")
        b = _require_number(b, "pow")
        return pow(a, b)

    def abs_(x):
        return abs(_require_number(x, "abs"))

    def round_(x, ndigits=None):
        x = _require_number(x, "round")
        if ndigits is None:
            return round(x)
        if isinstance(ndigits, bool) or not isinstance(ndigits, int):
            raise BuiltinModuleError("round expects ndigits to be an int")
        return round(x, ndigits)

    def floor(x):
        return math.floor(_require_number(x, "floor"))

    def ceil(x):
        return math.ceil(_require_number(x, "ceil"))

    def sin(x):
        return math.sin(_require_number(x, "sin"))

    def cos(x):
        return math.cos(_require_number(x, "cos"))

    def tan(x):
        return math.tan(_require_number(x, "tan"))

    def log(x, base=None):
        x = _require_number(x, "log")
        if base is None:
            return math.log(x)
        base = _require_number(base, "log")
        return math.log(x, base)

    def exp(x):
        return math.exp(_require_number(x, "exp"))

    return {
        "sqrt": sqrt,
        "pow": pow_,
        "abs": abs_,
        "round": round_,
        "floor": floor,
        "ceil": ceil,
        "sin": sin,
        "cos": cos,
        "tan": tan,
        "log": log,
        "exp": exp,
        "pi": math.pi,
        "e": math.e,
        "tau": math.tau,
        "inf": math.inf,
        "nan": math.nan,
    }


_BUILTINS: dict[str, Callable[[], dict[str, Any]]] = {
    "ganit": _ganit_exports,
    "math": _ganit_exports,
}


def _string_exports() -> dict[str, Any]:
    def _require_str(value: Any, name: str) -> str:
        if not isinstance(value, str):
            raise BuiltinModuleError(f"{name} expects a string as first argument")
        return value

    def len_(text):
        return len(_require_str(text, "len"))

    def lower(text):
        return _require_str(text, "lower").lower()

    def upper(text):
        return _require_str(text, "upper").upper()

    def trim(text):
        return _require_str(text, "trim").strip()

    def split(text, sep):
        text = _require_str(text, "split")
        if not isinstance(sep, str):
            raise BuiltinModuleError("split expects sep to be a string")
        return text.split(sep)

    def join(items, sep):
        if not isinstance(items, list):
            raise BuiltinModuleError("join expects a list as first argument")
        if not isinstance(sep, str):
            raise BuiltinModuleError("join expects sep to be a string")
        for it in items:
            if not isinstance(it, str):
                raise BuiltinModuleError("join expects list elements to be strings")
        return sep.join(items)

    def replace(text, old, new):
        text = _require_str(text, "replace")
        if not isinstance(old, str) or not isinstance(new, str):
            raise BuiltinModuleError("replace expects old/new to be strings")
        return text.replace(old, new)

    def contains(text, sub):
        text = _require_str(text, "contains")
        if not isinstance(sub, str):
            raise BuiltinModuleError("contains expects sub to be a string")
        return sub in text

    def starts_with(text, prefix):
        text = _require_str(text, "startsWith")
        if not isinstance(prefix, str):
            raise BuiltinModuleError("startsWith expects prefix to be a string")
        return text.startswith(prefix)

    def ends_with(text, suffix):
        text = _require_str(text, "endsWith")
        if not isinstance(suffix, str):
            raise BuiltinModuleError("endsWith expects suffix to be a string")
        return text.endswith(suffix)

    def slice_(text, start, end=None):
        text = _require_str(text, "slice")
        if isinstance(start, bool) or not isinstance(start, int):
            raise BuiltinModuleError("slice expects start to be an int")
        if end is None:
            return text[start:]
        if isinstance(end, bool) or not isinstance(end, int):
            raise BuiltinModuleError("slice expects end to be an int")
        return text[start:end]

    return {
        "len": len_,
        "lower": lower,
        "upper": upper,
        "trim": trim,
        "split": split,
        "join": join,
        "replace": replace,
        "contains": contains,
        "startsWith": starts_with,
        "endsWith": ends_with,
        "slice": slice_,
    }


_BUILTINS.update(
    {
        "string": _string_exports,
    }
)


def _suchi_exports() -> dict[str, Any]:
    def _require_list(value: Any, name: str) -> list:
        if not isinstance(value, list):
            raise BuiltinModuleError(f"{name} expects a list as first argument")
        return value

    def map_(itp, items, fn):
        items = _require_list(items, "map")
        out = []
        for x in items:
            out.append(itp._call_value(fn, [x], None, name_hint="map"))
        return out

    def filter_(itp, items, fn):
        items = _require_list(items, "filter")
        out = []
        for x in items:
            if itp.is_truthy(itp._call_value(fn, [x], None, name_hint="filter")):
                out.append(x)
        return out

    def reduce_(itp, items, fn, initial):
        items = _require_list(items, "reduce")
        acc = initial
        for x in items:
            acc = itp._call_value(fn, [acc, x], None, name_hint="reduce")
        return acc

    def sort_(itp, items):
        items = _require_list(items, "sort")
        try:
            return sorted(items)
        except TypeError:
            raise BuiltinModuleError("sort expects comparable list elements")

    def slice_(itp, items, start, end):
        items = _require_list(items, "slice")
        if isinstance(start, bool) or not isinstance(start, int):
            raise BuiltinModuleError("slice expects start to be an int")
        if end is None:
            return items[start:]
        if isinstance(end, bool) or not isinstance(end, int):
            raise BuiltinModuleError("slice expects end to be an int or kahi")
        return items[start:end]

    def contains_(itp, items, value):
        items = _require_list(items, "contains")
        return value in items

    return {
        "map": BuiltinFunction("suchi.map", map_),
        "filter": BuiltinFunction("suchi.filter", filter_),
        "reduce": BuiltinFunction("suchi.reduce", reduce_),
        "sort": BuiltinFunction("suchi.sort", sort_),
        "slice": BuiltinFunction("suchi.slice", slice_),
        "contains": BuiltinFunction("suchi.contains", contains_),
    }


_BUILTINS.update({"suchi": _suchi_exports})


def _vastu_exports() -> dict[str, Any]:
    def _require_obj(value: Any, name: str) -> dict:
        if not isinstance(value, dict):
            raise BuiltinModuleError(f"{name} expects an object as first argument")
        return value

    def keys(itp, obj):
        obj = _require_obj(obj, "keys")
        return list(obj.keys())

    def values(itp, obj):
        obj = _require_obj(obj, "values")
        return list(obj.values())

    def has(itp, obj, key):
        obj = _require_obj(obj, "has")
        if not isinstance(key, str):
            raise BuiltinModuleError("has expects key to be a string")
        return key in obj

    def delete(itp, obj, key):
        obj = _require_obj(obj, "delete")
        if not isinstance(key, str):
            raise BuiltinModuleError("delete expects key to be a string")
        existed = key in obj
        if existed:
            del obj[key]
        return existed

    def merge(itp, a, b):
        a = _require_obj(a, "merge")
        b = _require_obj(b, "merge")
        out = dict(a)
        out.update(b)
        return out

    return {
        "keys": BuiltinFunction("vastu.keys", keys),
        "values": BuiltinFunction("vastu.values", values),
        "has": BuiltinFunction("vastu.has", has),
        "delete": BuiltinFunction("vastu.delete", delete),
        "merge": BuiltinFunction("vastu.merge", merge),
    }


_BUILTINS.update({"vastu": _vastu_exports})


def _file_exports() -> dict[str, Any]:
    def _full_path(itp, path: str) -> str:
        return path if os.path.isabs(path) else os.path.join(itp.base_dir, path)

    def append(itp, path, text):
        if not isinstance(path, str):
            raise BuiltinModuleError("append expects path to be a string")
        if not isinstance(text, str):
            raise BuiltinModuleError("append expects text to be a string")

        full_path = _full_path(itp, path)
        try:
            with open(full_path, "a", encoding="utf-8") as f:
                f.write(text)
        except Exception as e:
            raise BuiltinModuleError(str(e))
        return None

    def mkdir(itp, path):
        if not isinstance(path, str):
            raise BuiltinModuleError("mkdir expects path to be a string")
        full_path = _full_path(itp, path)
        try:
            os.makedirs(full_path, exist_ok=True)
        except Exception as e:
            raise BuiltinModuleError(str(e))
        return None

    def listdir(itp, path):
        if not isinstance(path, str):
            raise BuiltinModuleError("listdir expects path to be a string")
        full_path = _full_path(itp, path)
        try:
            items = os.listdir(full_path)
        except Exception as e:
            raise BuiltinModuleError(str(e))
        # Make test output stable.
        return sorted(items)

    return {
        "append": BuiltinFunction("file.append", append),
        "mkdir": BuiltinFunction("file.mkdir", mkdir),
        "listdir": BuiltinFunction("file.listdir", listdir),
    }


_BUILTINS.update({"file": _file_exports})


def _json_exports() -> dict[str, Any]:
    def parse(itp, text):
        if not isinstance(text, str):
            raise BuiltinModuleError("parse expects text to be a string")
        try:
            return _json.loads(text)
        except Exception as e:
            raise BuiltinModuleError(str(e))

    def stringify(itp, obj):
        try:
            # Stable output for tests/docs: sorted keys, compact separators.
            return _json.dumps(obj, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
        except Exception as e:
            raise BuiltinModuleError(str(e))

    return {
        "parse": BuiltinFunction("json.parse", parse),
        "stringify": BuiltinFunction("json.stringify", stringify),
    }


_BUILTINS.update({"json": _json_exports})


def _time_exports() -> dict[str, Any]:
    def now(itp):
        # milliseconds since Unix epoch
        return int(_time.time() * 1000)

    def set_timeout(itp, fn, milliseconds):
        if isinstance(milliseconds, bool) or not isinstance(milliseconds, (int, float)):
            raise BuiltinModuleError("setTimeout expects milliseconds to be a number")
        if milliseconds < 0:
            raise BuiltinModuleError("setTimeout expects non-negative milliseconds")

        def _run():
            try:
                itp._call_value(fn, [], None, name_hint="time.setTimeout")
            except Exception as e:
                # Best-effort reporting for background execution.
                print(e)

        t = _threading.Timer(milliseconds / 1000.0, _run)
        t.daemon = True
        timer_id = itp._register_timer(("timeout", t))
        t.start()
        return timer_id

    def clear_timeout(itp, timer_id):
        if isinstance(timer_id, bool) or not isinstance(timer_id, int):
            raise BuiltinModuleError("clearTimeout expects an int timer id")
        handle = itp._pop_timer(timer_id)
        if handle is None:
            return False
        kind, obj = handle
        if kind != "timeout":
            # allow clearing an interval id too
            itp._timers[timer_id] = handle
            return False
        obj.cancel()
        return True

    def set_interval(itp, fn, milliseconds):
        if isinstance(milliseconds, bool) or not isinstance(milliseconds, (int, float)):
            raise BuiltinModuleError("setInterval expects milliseconds to be a number")
        if milliseconds < 0:
            raise BuiltinModuleError("setInterval expects non-negative milliseconds")

        stop = _threading.Event()

        def _loop():
            # first tick after delay, like JS
            delay = milliseconds / 1000.0
            while not stop.wait(delay):
                try:
                    itp._call_value(fn, [], None, name_hint="time.setInterval")
                except Exception as e:
                    print(e)

        th = _threading.Thread(target=_loop, daemon=True)
        timer_id = itp._register_timer(("interval", (stop, th)))
        th.start()
        return timer_id

    def clear_interval(itp, timer_id):
        if isinstance(timer_id, bool) or not isinstance(timer_id, int):
            raise BuiltinModuleError("clearInterval expects an int timer id")
        handle = itp._pop_timer(timer_id)
        if handle is None:
            return False
        kind, obj = handle
        if kind != "interval":
            itp._timers[timer_id] = handle
            return False
        stop, _th = obj
        stop.set()
        return True

    return {
        "now": BuiltinFunction("time.now", now),
        "setTimeout": BuiltinFunction("time.setTimeout", set_timeout),
        "clearTimeout": BuiltinFunction("time.clearTimeout", clear_timeout),
        "setInterval": BuiltinFunction("time.setInterval", set_interval),
        "clearInterval": BuiltinFunction("time.clearInterval", clear_interval),
    }


_BUILTINS.update({"time": _time_exports})


def is_builtin_module(name: str) -> bool:
    return name in _BUILTINS


def load_builtin_module(name: str) -> dict[str, Any]:
    factory = _BUILTINS.get(name)
    if factory is None:
        raise BuiltinModuleError(f"Unknown builtin module '{name}'")
    return factory()


def builtin_module_alias_groups() -> dict[str, list[str]]:
    """
    Return groups of builtin module names that share the same loader factory.

    Example:
      {"ganit": ["ganit", "math"]}
    """
    by_factory: dict[Callable[[], dict[str, Any]], list[str]] = {}
    for name, factory in _BUILTINS.items():
        by_factory.setdefault(factory, []).append(name)

    groups: dict[str, list[str]] = {}
    for factory, names in by_factory.items():
        names_sorted = sorted(names)
        canonical = names_sorted[0]
        groups[canonical] = names_sorted
    return dict(sorted(groups.items(), key=lambda kv: kv[0]))
