#-----------------------------------------------------------------------------
# Copyright (c) Anaconda, Inc., and Bokeh Contributors.
# All rights reserved.
#
# The full license is in the file LICENSE.txt, distributed with this software.
#-----------------------------------------------------------------------------
'''

'''

#-----------------------------------------------------------------------------
# Boilerplate
#-----------------------------------------------------------------------------
from __future__ import annotations

import logging # isort:skip
log = logging.getLogger(__name__)

#-----------------------------------------------------------------------------
# Imports
#-----------------------------------------------------------------------------

# Standard library imports
import hashlib
import json
import os
from dataclasses import dataclass
from os.path import normpath
from pathlib import Path
from typing import (
    TYPE_CHECKING,
    Callable,
    Iterator,
    Sequence,
    TypedDict,
)
from urllib.parse import urljoin

# Bokeh imports
from ..core.has_props import HasProps
from ..core.templates import CSS_RESOURCES, JS_RESOURCES
from ..document.document import Document
from ..resources import Resources
from ..settings import settings
from ..util.compiler import bundle_models
from .util import contains_tex_string

if TYPE_CHECKING:
    from typing_extensions import NotRequired

    from ..resources import Hashes

#-----------------------------------------------------------------------------
# Globals and constants
#-----------------------------------------------------------------------------

__all__ = (
    'Bundle',
    'bundle_for_objs_and_resources',
)

#-----------------------------------------------------------------------------
# General API
#-----------------------------------------------------------------------------

#-----------------------------------------------------------------------------
# Dev API
#-----------------------------------------------------------------------------

class Artifact:
    pass

class ScriptRef(Artifact):
    def __init__(self, url: str, type: str = "text/javascript") -> None:
        self.url = URL(url)
        self.type = type


class Script(Artifact):
    def __init__(self, content: str, type: str = "text/javascript") -> None:
        self.content = content
        self.type = type


class StyleRef(Artifact):
    def __init__(self, url: str) -> None:
        self.url = URL(url)


class Style(Artifact):
    def __init__(self, content: str) -> None:
        self.content = content


class Bundle:

    js_files: list[URL]
    js_raw: list[str]
    css_files: list[URL]
    css_raw: list[str]
    hashes: Hashes

    def __init__(self, js_files: list[URL] = [], js_raw: list[str] = [],
            css_files: list[URL] = [], css_raw: list[str] = [], hashes: Hashes = {}):
        self.js_files = js_files[:]
        self.js_raw = js_raw[:]
        self.css_files = css_files[:]
        self.css_raw = css_raw[:]
        self.hashes = {**hashes}

    def __iter__(self) -> Iterator[str]:
        yield self._render_js()
        yield self._render_css()

    def _render_js(self) -> str:
        return JS_RESOURCES.render(js_files=self.js_files, js_raw=self.js_raw, hashes=self.hashes)

    def _render_css(self) -> str:
        return CSS_RESOURCES.render(css_files=self.css_files, css_raw=self.css_raw)

    def scripts(self, tag: bool = True) -> str:
        if tag:
            return JS_RESOURCES.render(js_raw=self.js_raw, js_files=[])
        else:
            return "\n".join(self.js_raw)

    @property
    def js_urls(self) -> list[URL]:
        return self.js_files

    @property
    def css_urls(self) -> list[URL]:
        return self.css_files

    def add(self, artifact: Artifact) -> None:
        if isinstance(artifact, ScriptRef):
            self.js_files.append(artifact.url)
        elif isinstance(artifact, Script):
            self.js_raw.append(artifact.content)
        elif isinstance(artifact, StyleRef):
            self.css_files.append(artifact.url)
        elif isinstance(artifact, Style):
            self.css_raw.append(artifact.content)

def bundle_for_objs_and_resources(objs: Sequence[HasProps | Document] | None, resources: Resources | None) -> Bundle:
    ''' Generate rendered CSS and JS resources suitable for the given
    collection of Bokeh objects

    Args:
        objs (seq[HasProps or Document]) :

        resources (Resources)

    Returns:
        Bundle

    '''
    if objs is not None:
        all_objs    = _all_objs(objs)
        use_widgets = _use_widgets(all_objs)
        use_tables  = _use_tables(all_objs)
        use_gl      = _use_gl(all_objs)
        use_mathjax = _use_mathjax(all_objs)
    else:
        # XXX: force all components on server and in notebook, because we don't know in advance what will be used
        all_objs    = None
        use_widgets = True
        use_tables  = True
        use_gl      = True
        use_mathjax = True

    js_files: list[URL] = []
    js_raw: list[str] = []
    css_files: list[URL] = []
    css_raw: list[str] = []

    if resources is not None:
        components = list(resources.components)
        if not use_widgets: components.remove("bokeh-widgets")
        if not use_tables:  components.remove("bokeh-tables")
        if not use_gl:      components.remove("bokeh-gl")
        if not use_mathjax: components.remove("bokeh-mathjax")

        resources = resources.clone(components=components)

        js_files.extend(map(URL, resources.js_files))
        js_raw.extend(resources.js_raw)

        css_files.extend(map(URL, resources.css_files))
        css_raw.extend(resources.css_raw)

        extensions = _bundle_extensions(all_objs if objs else None, resources)
        mode = resources.mode
        if mode == "inline":
            js_raw.extend([ Resources._inline(bundle.artifact_path) for bundle in extensions ])
        elif mode == "server":
            js_files.extend([ bundle.server_url for bundle in extensions ])
        elif mode == "cdn":
            for bundle in extensions:
                if bundle.cdn_url is not None:
                    js_files.append(bundle.cdn_url)
                else:
                    js_raw.append(Resources._inline(bundle.artifact_path))
        else:
            js_files.extend([ URL(str(bundle.artifact_path)) for bundle in extensions ])

    models = [ obj.__class__ for obj in all_objs ] if all_objs else None
    ext = bundle_models(models)
    if ext is not None:
        js_raw.append(ext)

    return Bundle(js_files, js_raw, css_files, css_raw, resources.hashes if resources else {})

#-----------------------------------------------------------------------------
# Private API
#-----------------------------------------------------------------------------

def _query_extensions(all_objs: set[HasProps], query: Callable[[type[HasProps]], bool]) -> bool:
    names: set[str] = set()

    for obj in all_objs:
        if hasattr(obj, "__implementation__"):
            continue
        name = obj.__view_module__.split(".")[0]
        if name == "bokeh":
            continue
        if name in names:
            continue
        names.add(name)

        for model in HasProps.model_class_reverse_map.values():
            if model.__module__.startswith(name):
                if query(model):
                    return True

    return False

@dataclass(frozen=True)
class URL:
    """ Opaque type for representing URLs. """

    url: str

    def __truediv__(self, path: str) -> URL:
        url = self.url if self.url.endswith("/") else f"{self.url}/"
        return URL(urljoin(url, path.replace(os.sep, "/")))

    def __str__(self) -> str:
        return self.url

@dataclass(frozen=True)
class ExtensionEmbed:
    artifact_path: Path
    server_url: URL
    cdn_url: URL | None = None

class Pkg(TypedDict):
    name: NotRequired[str]
    version: NotRequired[str]
    module: NotRequired[str]
    main: NotRequired[str]

_default_cdn_host = URL("https://unpkg.com")

extension_dirs: dict[str, Path] = {}

def _bundle_extensions(objs: set[HasProps] | None, resources: Resources) -> list[ExtensionEmbed]:
    names: set[str] = set()
    bundles: list[ExtensionEmbed] = []

    extensions = [".min.js", ".js"] if resources.minified else [".js"]

    all_objs = objs if objs is not None else HasProps.model_class_reverse_map.values()

    for obj in all_objs:
        if hasattr(obj, "__implementation__"):
            continue
        name = obj.__view_module__.split(".")[0]
        if name == "bokeh":
            continue
        if name in names:
            continue
        names.add(name)
        module = __import__(name)
        this_file = Path(module.__file__).absolute()
        base_dir = this_file.parent
        dist_dir = base_dir / "dist"

        ext_path = base_dir / "bokeh.ext.json"
        if not ext_path.exists():
            continue

        server_prefix = URL(resources.root_url) / "static" / "extensions"
        package_path = base_dir / "package.json"

        pkg: Pkg | None = None
        if package_path.exists():
            with open(package_path) as io:
                try:
                    pkg = json.load(io)
                except json.decoder.JSONDecodeError:
                    pass

        artifact_path: Path
        server_url: URL
        cdn_url: URL | None = None

        if pkg is not None:
            pkg_name: str | None = pkg.get("name", None)
            if pkg_name is None:
                raise ValueError("invalid package.json; missing package name")
            pkg_version = pkg.get("version", "latest")
            pkg_main = pkg.get("module", pkg.get("main", None))
            if pkg_main is not None:
                pkg_main = Path(normpath(pkg_main))
                cdn_url = _default_cdn_host / f"{pkg_name}@{pkg_version}" / f"{pkg_main}"
            else:
                pkg_main = dist_dir / f"{name}.js"
            artifact_path = base_dir / pkg_main
            artifacts_dir = artifact_path.parent
            artifact_name = artifact_path.name
            server_path = f"{name}/{artifact_name}"
            if not settings.dev:
                sha = hashlib.sha256()
                sha.update(pkg_version.encode())
                vstring = sha.hexdigest()
                server_path = f"{server_path}?v={vstring}"
        else:
            for ext in extensions:
                artifact_path = dist_dir / f"{name}{ext}"
                artifacts_dir = dist_dir
                server_path = f"{name}/{name}{ext}"
                if artifact_path.exists():
                    break
            else:
                raise ValueError(f"can't resolve artifact path for '{name}' extension")

        extension_dirs[name] = Path(artifacts_dir)
        server_url = server_prefix / server_path
        embed = ExtensionEmbed(artifact_path, server_url, cdn_url)
        bundles.append(embed)

    return bundles

def _all_objs(objs: Sequence[HasProps | Document]) -> set[HasProps]:
    all_objs: set[HasProps] = set()

    for obj in objs:
        if isinstance(obj, Document):
            for root in obj.roots:
                all_objs |= root.references()
        else:
            all_objs |= obj.references()

    return all_objs

def _any(objs: set[HasProps], query: Callable[[HasProps], bool]) -> bool:
    ''' Whether any of a collection of objects satisfies a given query predicate

    Args:
        objs (set[HasProps]) :

        query (callable)

    Returns:
        True, if ``query(obj)`` is True for some object in ``objs``, else False

    '''
    return any(query(x) for x in objs)

def _use_tables(all_objs: set[HasProps]) -> bool:
    ''' Whether a collection of Bokeh objects contains a TableWidget

    Args:
        objs (seq[HasProps or Document]) :

    Returns:
        bool

    '''
    from ..models.widgets import TableWidget
    return _any(all_objs, lambda obj: isinstance(obj, TableWidget)) or _ext_use_tables(all_objs)

def _use_widgets(all_objs: set[HasProps]) -> bool:
    ''' Whether a collection of Bokeh objects contains a any Widget

    Args:
        objs (seq[HasProps or Document]) :

    Returns:
        bool

    '''
    from ..models.widgets import Widget
    return _any(all_objs, lambda obj: isinstance(obj, Widget)) or _ext_use_widgets(all_objs)

def _model_requires_mathjax(model: HasProps) -> bool:
    """Whether a model requires MathJax to be loaded
    Args:
        model (HasProps): HasProps to check
    Returns:
        bool: True if MathJax required, False if not
    """
    # TODO query model's properties that include TextLike or better
    # yet load mathjax bundle dynamically on bokehjs' side.

    from ..models.annotations import TextAnnotation
    from ..models.axes import Axis
    from ..models.widgets.markups import Div, Paragraph
    from ..models.widgets.sliders import AbstractSlider

    if isinstance(model, TextAnnotation):
        if isinstance(model.text, str) and contains_tex_string(model.text):
            return True

    if isinstance(model, AbstractSlider):
        if isinstance(model.title, str) and contains_tex_string(model.title):
            return True

    if isinstance(model, Axis):
        if isinstance(model.axis_label, str) and contains_tex_string(model.axis_label):
            return True

        for val in model.major_label_overrides.values():
            if isinstance(val, str) and contains_tex_string(val):
                return True

    if isinstance(model, Div) and not model.disable_math and not model.render_as_text:
        if contains_tex_string(model.text):
            return True

    if isinstance(model, Paragraph) and not model.disable_math:
        if contains_tex_string(model.text):
            return True

    return False

def _use_mathjax(all_objs: set[HasProps]) -> bool:
    ''' Whether a collection of Bokeh objects contains a model requesting MathJax
    Args:
        objs (seq[HasProps or Document]) :
    Returns:
        bool
    '''
    from ..models.glyphs import MathTextGlyph
    from ..models.text import MathText

    return _any(all_objs, lambda obj: isinstance(obj, MathTextGlyph | MathText) or _model_requires_mathjax(obj)) or _ext_use_mathjax(all_objs)

def _use_gl(all_objs: set[HasProps]) -> bool:
    ''' Whether a collection of Bokeh objects contains a plot requesting WebGL

    Args:
        objs (seq[HasProps or Document]) :

    Returns:
        bool

    '''
    from ..models.plots import Plot
    return _any(all_objs, lambda obj: isinstance(obj, Plot) and obj.output_backend == "webgl")

def _ext_use_tables(all_objs: set[HasProps]) -> bool:
    from ..models.widgets import TableWidget
    return _query_extensions(all_objs, lambda cls: issubclass(cls, TableWidget))

def _ext_use_widgets(all_objs: set[HasProps]) -> bool:
    from ..models.widgets import Widget
    return _query_extensions(all_objs, lambda cls: issubclass(cls, Widget))

def _ext_use_mathjax(all_objs: set[HasProps]) -> bool:
    from ..models.text import MathText
    return _query_extensions(all_objs, lambda cls: issubclass(cls, MathText))
#-----------------------------------------------------------------------------
# Code
#-----------------------------------------------------------------------------
