import dataclasses
import importlib
import inspect
import pkgutil
import re
import warnings
from pathlib import Path
from types import ModuleType
from typing import Any, Callable, List, Optional, cast

import ipywidgets
import reacton
import reacton.core

import solara
from solara.alias import rv
from solara.util import cwd

autoroute_level_context = solara.create_context(0)
DEBUG = False


DefaultLayout = solara.AppLayout


def source_to_module(path: Path) -> ModuleType:
    fullname = path.stem
    mod = ModuleType(fullname)
    mod.__file__ = str(path)
    if path.suffix == ".py":
        with open(path) as f:
            ast = compile(f.read(), path, "exec")
            exec(ast, mod.__dict__)
    elif path.suffix == ".ipynb":
        import nbformat

        nb: nbformat.NotebookNode = nbformat.read(path, 4)
        with cwd(Path(path).parent):
            for cell_index, cell in enumerate(nb.cells):
                cell_index += 1  # used 1 based
                if cell.cell_type == "code":
                    source = cell.source
                    cell_path = f"{path} input cell {cell_index}"
                    ast = compile(source, cell_path, "exec")
                    exec(ast, mod.__dict__)
    else:
        raise TypeError(f"Only .py and .ipynb supported (not {path})")
    return mod


def count_arguments(f: Callable):
    if isinstance(f, reacton.core.ComponentFunction):
        f = f.f
    sig = inspect.signature(f)
    return len([k for name, k in sig.parameters.items() if (k.default == k.empty) and (k.kind != k.VAR_POSITIONAL) and (k.kind != k.VAR_KEYWORD)])


def arg_cast(args: List[str], f: Callable):
    if isinstance(f, reacton.core.ComponentFunction):
        f = f.f
    sig = inspect.signature(f)
    results = []
    for i, (name, param) in enumerate(sig.parameters.items()):
        if i >= len(args):
            break
            # TODO: can we give a better error message?
            # raise TypeError(f"{f} expected positional argument {param}, but not enought argument supplied")
        annotation = param.annotation
        value = args[i]
        check_optional_types = [str, int, float]
        for check_type in check_optional_types:
            if annotation == Optional[check_type]:
                annotation = check_type
        if annotation == param.empty:
            results.append(value)
        else:
            try:
                results.append(annotation(value))
            except Exception as e:
                raise ValueError(f"Cannot cast argument {name} of {f} to type {annotation} with value {value}") from e
    return results


@solara.component
def RoutingProvider(children: List[reacton.core.Element] = [], routes: List[solara.Route] = [], pathname: str = ""):
    """Wraps the app, adds extra context, like navigation/routing."""
    path, set_path = solara.use_state(pathname, key="solara-context-path")
    nav = solara.Navigator(location=path, on_location=set_path)
    solara.routing._location_context.provide(solara.routing._Location(path, set_path))
    solara.routing.router_context.provide(solara.routing.Router(path, routes=routes, set_path=set_path))

    main = solara.VBox(
        children=[
            nav,
            *children,
        ]
    )

    return main


@solara.component
def RenderPage():
    """Renders the page that matches the route."""
    level_start = solara.use_route_level()
    router = solara.use_context(solara.routing.router_context)
    # we use these to cache script runs that use regular ipywidgets
    modules = solara.use_memo(dict, dependencies=[])
    modules_modified_times = solara.use_memo(dict, dependencies=[])

    if len(router.path_routes) <= level_start:
        with solara.VBox() as main:
            solara.Error(f"Page not found: {router.path}, len(router.path_routes)={len(router.path_routes)} <= level_start={level_start}")
            parent = "/" + "/".join(router.parts[:-1])
            with solara.Link(parent):
                solara.Button(f"Go to parent: {parent}", text=True)
            if DEBUG:
                from reacton.core import pp

                from solara.components.captureoutput import CaptureOutput

                with CaptureOutput():
                    pp(router.routes)
        return main

    level_max = level_start
    layouts = []  # nested layouts
    # find the 'RenderPage' sentinel value to find the deepest level we should render
    for level in range(level_start, len(router.path_routes)):
        # we always level_start the 'package'/'root' layout
        roots = [k for k in router.path_routes_siblings[level] if k.path == "/"]
        if len(roots) == 1:
            root = roots[0]
            if root.layout:
                layouts.append(root.layout)
        route = router.path_routes[level]
        # and, if not the root layout, include the layout for the current route
        if route.path != "/" and route.layout:
            layouts.append(route.layout)
        if route.component is RenderPage:
            level_max = level
    route_current = router.path_routes[level_max]
    routes_siblings = router.path_routes_siblings[level_max]
    routes_siblings_index = routes_siblings.index(route_current)
    # if no layouts are found, we use the default layout
    if layouts == []:
        # except when the root routes has no siblings (maybe we should include no children?)
        if len(router.path_routes_siblings[level_start]) == 1:
            layouts = []
        else:
            layouts = [DefaultLayout]
    if route_current.data is None and route_current.module is None:
        return solara.Error(f"Page not found: {router.path}, route does not link to a path or module")

    def wrap_in_layouts(element: reacton.core.Element, layouts):
        for Layout in reversed(layouts):
            element = Layout(children=[element])
        return element

    def get_args(f):
        if len(router.path_routes) < len(router.parts):
            arg_strings = router.parts[len(router.path_routes) :]
            args = arg_cast(arg_strings, f)
            return args
        else:
            return []

    if isinstance(route_current.data, Path):
        path = cast(Path, route_current.data)
        if path.suffix == ".md":
            with solara.HBox() as navigation:
                if routes_siblings_index > 0:
                    prev = routes_siblings[routes_siblings_index - 1]
                    with solara.Link(prev):
                        solara.Button(f"{prev.label}", text=True, icon_name="mdi-arrow-left")
                rv.Spacer()
                if routes_siblings_index < len(routes_siblings) - 1:
                    next = routes_siblings[routes_siblings_index + 1]
                    with solara.Link(next):
                        solara.Button(f"{next.label}", text=True, icon_name="mdi-arrow-right")
            main = solara.Div(
                children=[
                    solara.Title(route_current.label or "No title"),
                    solara.Markdown(path.read_text(), unsafe_solara_execute=True),
                    navigation,
                ]
            )
            main = wrap_in_layouts(main, layouts)
        else:
            main = solara.Error(f"Suffix {path.suffix} not supported")
    else:
        assert route_current.module is not None
        title = route_current.label or "No title"
        title_element = solara.Title(title)
        module = route_current.module
        namespace = module.__dict__
        Page = namespace.get("Page", None)
        # app is for backwards compatibility
        page = namespace.get("page", namespace.get("app"))
        if page is not None:
            if isinstance(page, reacton.core.Element):
                pass  # we are good
            elif isinstance(page, ipywidgets.Widget):
                # If we have a widget, we need to execute this again for each
                # connection, since we cannot share widgets between connections/users.
                # We also cannot tear them down, so we cache the widget based pages.
                # To support hot reload, we manualy need to check the mtimes
                # because the reload support for modules in reloader.py only works
                # for modules.
                if route_current.file is None:
                    page = solara.Error(f"{route_current.path} is not associated with a file")
                else:
                    assert route_current.file is not None
                    if route_current.path not in modules:
                        modules[route_current.path] = source_to_module(route_current.file)
                        modules_modified_times[route_current.path] = route_current.file.stat().st_mtime
                    else:
                        if modules_modified_times[route_current.path] != route_current.file.stat().st_mtime:
                            # out of date, 'reload'
                            modules[route_current.path] = source_to_module(route_current.file)
                            modules_modified_times[route_current.path] = route_current.file.stat().st_mtime
                    page = getattr(modules[route_current.path], "app", None)
                    page = getattr(modules[route_current.path], "page", page)
                    if page is None:
                        page = solara.Error(f"{module} does not have a `Page` component of a `page` element or widget")
            else:
                page = solara.Error(f"{module} page variable not a Solara element or ipywidget")
            main = solara.Div(
                children=[
                    title_element,
                    page,
                ]
            )
            main = wrap_in_layouts(main, layouts)
        elif Page is not None:
            args = get_args(Page)
            main = solara.Div(
                children=[
                    title_element,
                    Page(*args),
                ]
            )
            main = wrap_in_layouts(main, layouts)
        else:
            with DefaultLayout() as main:
                solara.Error(f"{module} does not have a Page component or an app element")
    return main


def get_page(module: ModuleType, required=True):
    page = getattr(module, "Page", None)
    if required and page is None:
        raise NameError(f"No component names 'Page' in module {module}")
    return page


def get_renderable(module: ModuleType, required=False):
    var_names = "app Page page".split()
    for var_name in var_names:
        if not hasattr(module, var_name):
            continue
        entry = getattr(module, var_name)
        return entry
    if required:
        raise NameError(f"No component, element or widget found in module {module} with names: {', '.join(var_names[:-1])} or {var_names[-1]}")


def get_title(module: ModuleType, required=True):
    assert module.__file__ is not None
    name = Path(module.__file__).stem
    if module.__file__.endswith("__init__.py"):
        name = Path(module.__file__).parent.stem
    # if title is a submodule it may shadown the variable in __init__
    # in this case, _title is an escape hatch
    if hasattr(module, "_title") and isinstance(module._title, str):
        title = module._title
    elif hasattr(module, "title") and isinstance(module.title, str):
        title = module.title
    else:
        title_parts = re.split("[\\-_ ]+", name)
        title = " ".join(k.title() for k in title_parts)
    return title


def fix_route(route: solara.Route, new_file: Path) -> solara.Route:
    file = route.file or new_file
    children = fix_routes(route.children, new_file) if route.children else []

    return dataclasses.replace(route, file=file, children=children)


def fix_routes(routes: List[solara.Route], new_file: Path):
    new_routes = []
    for route in routes:
        new_routes.append(fix_route(route, new_file))
    return new_routes


def generate_routes(module: ModuleType) -> List[solara.Route]:
    """Generate routes from a module.

    This is a recursive function that will generate routes for all submodules.

    Note this only support .py files, since Markdown files and Jupyter notebook
    files are not Python modules.


    See [our multipage guide](/docs/howto/multipage#as-a-package) for more details.


    """
    from .server import reload

    assert module.__file__ is not None
    routes = []
    file = Path(module.__file__)

    if module.__file__.endswith("__init__.py"):
        if hasattr(module, "routes"):
            # if routes if provided, use them instead of us generating them
            children = getattr(module, "routes")
            children = fix_routes(children, file)
            return children
        route_order = getattr(module, "route_order", None)
        layout = getattr(module, "Layout", None)
        title = get_title(module)
        children = getattr(module, "routes", [])
        if hasattr(module, "Page"):
            routes.append(solara.Route(path="/", component=RenderPage, data=module, module=module, layout=layout, children=children, label=title, file=file))

        assert module.__file__ is not None
        reload.reloader.watcher.add_file(module.__file__)
        for info in pkgutil.iter_modules([str(Path(module.__file__).parent)]):
            submod = importlib.import_module(module.__name__ + f".{info.name}")
            subfile = Path(submod.__file__) if submod.__file__ is not None else None
            title = get_title(submod)

            if info.ispkg:
                route = solara.Route(info.name, component=RenderPage, children=generate_routes(submod), module=submod, layout=None, label=title)
                # skip empty subpackages
                if len(route.children) == 0:
                    continue
            else:
                # skip empty modules
                if get_renderable(submod) is None:
                    continue
                children = getattr(submod, "routes", [])
                if subfile:
                    children = fix_routes(children, subfile)
                module_layout = getattr(submod, "Layout", None)
                route = solara.Route(info.name, component=RenderPage, module=submod, layout=module_layout, children=children, label=title, file=subfile)
            routes.append(route)
        if route_order:
            lookup = {k.path: k for k in routes}
            for k in route_order:
                if k not in lookup:
                    raise KeyError(f"Route {k!r} listen in route_order not found in {module}")
            routes = [lookup[k] for k in route_order]
            if set(lookup) - set(route_order):
                warnings.warn(f"Some routes are not in route_order: {set(lookup) - set(route_order)}")

    else:
        children = getattr(module, "routes", [])
        children = fix_routes(children, file)
        # children = []
        # single module, single route
        return [solara.Route(path="/", component=RenderPage, data=None, module=module, label=get_title(module), children=children, file=file)]

    return routes


def generate_routes_directory(path: Path) -> List[solara.Route]:
    """Generate routes for a directory.

    This is a recursive function that will generate routes for all
    subdirectories and files in the directory. It will skip any
    files or directories that start with an underscore or a dot.

    Markdown files ending in .md will be rendered as markdown.

    Python files ending in .py, or Jupyter notebooks ending in .ipynb
    will be executed and its `Page` component will be rendered.

    Automatic titles will be [generated as explained in the multipage guide](/docs/howto/multipage).

    """
    from .server import reload

    subpaths = list(sorted(path.iterdir()))
    routes = []
    first = True
    has_index = len([k for k in subpaths if k.name == "__init__"]) > 0
    suffixes = [".py", ".ipynb", ".md"]
    init = path / "__init__.py"
    layout = None
    if init.exists():
        init_module = source_to_module(init)
        layout = getattr(init_module, "Layout", None)

    for subpath in subpaths:
        # only handle directories and recognized file types
        if not (subpath.is_dir() or subpath.suffix in suffixes):
            continue
        if subpath.stem.startswith("_") or subpath.stem.startswith("."):
            continue
        name = subpath.stem
        match = re.match("([0-9\\-_ ]*)(.*)", name)
        if match:
            _prefix, name = match.groups()
        title_parts = re.split("[\\-_ ]+", name)
        title = " ".join(k.title() for k in title_parts)
        if not has_index and first:
            route_path = "/"
        else:
            route_path = "-".join([k.lower() for k in title_parts])
        # used as a 'sentinel' to find the deepest level of the route tree we need to render in 'RenderPage'
        component = RenderPage
        children = []
        module: Optional[ModuleType] = None
        data: Any = None
        module_layout = layout if first else None
        if subpath.suffix == ".md":
            data = subpath
            reload.reloader.watcher.add_file(subpath)
        elif subpath.is_dir():
            children = generate_routes_directory(subpath)
        else:
            reload.reloader.watcher.add_file(subpath)
            module = source_to_module(subpath)
            children = getattr(module, "routes", children)
            children = fix_routes(children, subpath)
            module_layout = getattr(module, "Layout", module_layout)
        first = False
        route = solara.Route(route_path, component=component, module=module, label=title, children=children, data=data, layout=module_layout, file=subpath)
        routes.append(route)
    return routes
