#!/usr/bin/env python3

'''
This script renders Jinja comments found at the beginning
of `.sls` files as state documentation.

The code is horrible, but I'm too lazy to rewrite it properly
because it works for my use cases. There will likely be a lot of bugs.

Full configuration in ``pyproject.toml`` with defaults:

[tool.slsdoc]
  # Choose which renderer to use. `single` or `depth`
renderer = "single"
  # Also list states without documentation
include_empty = false
  # Path prefixes to ignore, relative to formula root
ignore = ["_mapdata"]
  # Given a directory with `package.init`, `package.install`
  # and `package.clean`, only dump init and clean.
avoid_init_dup = True
  # By default, states are ordered alphabetically, but init
  # always comes first and all clean states are separated.
  # This overrides the purely alphabetical order for the roots.
sort_list = ["init", "clean", "package", "config", "service"]

[tool.slsdoc.single_renderer]
  # Include init.sls files in state list
init = true
  # Include .init suffix in when init is true
init_suffix = false
  # Include clean.sls files in state list
clean = true
  # When rendering with prefix `settings.something`,
  # write `else` instead of `settings.something.else`
cut_prefix = false
  # Write `salt.package` instead of `package` when formula root == `salt`
include_formula_root = true
  # Default .rst main header, when not defined in root init.sls
default_header = "Available States"
  # Header, overrides everything
header = ""
  # Description, overrides everything
description = ""
  # Default description, when init.sls is empty
default_description = """
.. contents::
   :local:
"""
  # Always append default description, regardless of
  # presence in init.sls
always_include_default_description = false
  # Character to use to underline main headings
main_heading_char = "="
  # Character to use to underline sub headings
sub_heading_char = "^"
  # Make state IDs literal – ``state.id`` instead of state.id
literal_sub_heading = false
  # File to write to
target = "docs/states.rst"

[tool.slsdoc.depth_renderer]
  # maxdepth for generated index.rst files (toctree)
index_maxdepth = 4
  # Directory to write to
target = "docs/states"

[tool.slsdoc.depth_renderer.single_opts]
  # Include init.sls files in state list
init = false
  # Include .init suffix in when init is true
init_suffix = false
  # Include clean.sls files in state list
clean = false
  # When rendering with prefix `settings.something`,
  # write `else` instead of `settings.something.else`
cut_prefix = true
  # Write `salt.package` instead of `package` when formula root == `salt`
include_formula_root = false
'''

from pathlib import Path

import toml
from jinja2 import Environment
from jinja2.exceptions import TemplateSyntaxError

# import sphinx.util.docstrings as d

base_dir = Path(__file__).parent.parent.absolute()


class State:
    """
    Object representing a documented state.
    """

    def __init__(self, path, comment, formula_root):
        if not isinstance(path, Path):
            path = Path(path)
        self.path = path
        self.comment = str(comment)
        self.formula_root = formula_root

    def to_state_id(
        self, include_init=True, relative_to="", split=False, formula_root=False
    ):
        """
        Return this state's state ID.

        include_init
            If False, remove ``.init`` suffix. Defaults to True.

        relative_to
            Render the state ID relative to another state or file.

        split
            Return a tuple of parts instead of a string.

        formula_root
            If not using relative_to, include the formula's root
            directory in the state ID (the actually correct one).
            Defaults to False (for compatibility reasons).
        """
        ret = self.path.with_suffix("")

        if not include_init and ret.parts[-1] == "init":
            ret = ret.parent

        if relative_to:
            relative_to = Path(relative_to.replace(".", "/"))
            ret = ret.relative_to(relative_to)
        elif formula_root:
            ret = Path(self.formula_root) / ret

        ret = str(ret).replace("/", ".")
        if not split:
            return ret
        return tuple(ret.split("."))

    def is_init(self):
        """
        Checks whether this is an ``.init`` state.
        """
        return self.path.parts[-1] == "init.sls"

    def is_clean(self):
        """
        Checks whether this is a ``.clean`` state.
        """
        return self.path.parts[-1] == "clean.sls"

    def __str__(self):
        return self.to_state_id()


class StateRegistry:
    """
    Registers State objects. It generates several indices:

    1. nested dictionary containing the states
    2. flattened list of states
    3. flattened list of prefixes ~ directories containing .sls files

    All of them are guaranteed to be sorted, which is required
    by some of the downstream classes.
    """

    def __init__(
        self,
        data=None,
        states=None,
        prefixes=None,
        default_sort_fn=None,
        avoid_init_dup=True,
        sort_list=None,
    ):
        # nested dict
        self.reg = data or {}
        # flat dict with ptr to reg
        self.states = states or {}
        # flat dict with ptr to reg
        self.prefixes = prefixes or {}
        self.default_sort_fn = default_sort_fn or self._default_sort
        # ensure no prefix lists the root
        self.prefixes[()] = self.reg
        self.avoid_init_dup = avoid_init_dup
        self.sort_list = tuple(
            sort_list or ("init", "clean", "package", "config", "service")
        )

    def _default_sort(self, a):
        """
        Sort prefixes package > config > service, rest alphabetically.
        Init always comes first. Clean states are separated.
        """
        sort_key = ()
        if a[0] in self.sort_list:
            sort_key += (str(self.sort_list.index(a[0])),)
        else:
            sort_key += (str(len(self.sort_list)),)
        if a[-1] == "init":
            sort_key += a[:-1]
        elif a[-1] == "clean":
            sort_key = ("zzzzzzzzz",) + sort_key + a[:-1]
        else:
            sort_key += a
        return sort_key

    def lookup(self, state, tree=False):
        """
        Looks up State objects by ID ('some.example' or ['some', 'example']).
        If tree, also returns pointers to dicts inside the nested registry.
        """
        ptr = self.reg
        if isinstance(state, str):
            state = state.split(".")
        state = list(state)

        try:
            while state:
                cur = state.pop(0)
                ptr = ptr[cur]
        except KeyError:
            return False

        if isinstance(ptr, State) or tree:
            return ptr
        # map init.sls same way as salt does
        if "init" in ptr:
            return ptr["init"]

        raise ValueError("State could not be found. Most likely, this is a prefix.")

    def register(self, state):
        """
        Register a state object.
        """
        try:
            state_id = state.to_state_id(split=True)
        except AttributeError as err:
            raise ValueError("`state` argument must be an instance of `State`") from err

        ptr = self.reg
        prefix = []
        parts = list(state_id)

        while len(parts):
            cur = parts.pop(0)
            prefix = prefix + [cur]
            if not parts:
                ptr[cur] = state
                break
            # make sure subdicts are initialized
            if cur not in ptr:
                ptr[cur] = {}
                self.prefixes[tuple(prefix)] = ptr[cur]
            ptr = ptr[cur]
        self.states[state_id] = ptr[cur]

    def unregister(self, state):
        """
        Unregister a state object.
        """
        try:
            state_id = state.to_state_id(split=True)
        except AttributeError as err:
            raise ValueError("`state` argument must be an instance of `State`") from err
        try:
            self.states.pop(state_id)
        except KeyError as err:
            raise RuntimeError(f"Specified state {state} was not registered") from err

        parts = list(state_id)
        first_run = True

        while parts:
            key = parts.pop()
            parent = self.lookup(parts, tree=True)
            if first_run:
                del parent[key]
            elif not len(parent[key]):
                self.prefixes.pop(tuple(parts + [key]))
                del parent[key]
            first_run = False

    def depth(self, prefix=""):
        """
        Returns depth of nested dict. Optionally from a specified prefix.
        """
        d = self.lookup(prefix, tree=True) if prefix else self.reg
        return self._get_depth(d)

    def _get_depth(self, d):
        """
        Recursively look up depth.
        """
        if isinstance(d, dict) and set(d.keys()) - {"init", "clean"}:
            return 1 + (max(map(self._get_depth, d.values())) if d else 0)
        return 0

    def count(self):
        """
        Return the number of registered states.
        """
        return len(self.states)

    def dump(self, prefix=None, sort_fn=None):
        """
        Returns all states sorted, optionally beginning from a prefix only.
        """
        prefix = prefix or ()
        if isinstance(prefix, str):
            prefix = tuple(prefix.split("."))
        for state_id in sorted(self.states, key=sort_fn or self.default_sort_fn):
            if state_id[: len(prefix)] == prefix:
                if self.avoid_init_dup and state_id[-1] not in ["init", "clean"]:
                    # given package.init, package.install and package.clean,
                    # only dump init and clean
                    parent = self.lookup(state_id[:-1], tree=True)
                    if len(list(x for x, y in parent.items() if not isinstance(y, dict))) == 3 and "init" in parent and "clean" in parent:
                        continue
                yield self.states[state_id]


class Renderer:
    """
    Base class for Renderer objects.
    """

    _default_opts = {
        "default_description": """\
The following states are found in {where}:

.. contents::
   :local:
""",
        "always_include_default_description": False,
        "default_header": "Available States",
        "main_heading_char": "=",
        "sub_heading_char": "-",
        "literal_sub_heading": False,
    }

    def __init__(self, target="docs", prefix=None, basedir=base_dir, opts=None):
        self.opts = self._default_opts | (opts or {})
        self.target = basedir / target
        if isinstance(prefix, str):
            prefix = tuple(prefix.split("."))
        self.prefix = prefix or ()
        self.basedir = basedir
        self._ensurepath(self.target)

    def render(self, states):
        raise NotImplementedError()

    def _write(self, text, ncount=1):
        """
        Convenience method to mimic writeln
        This is bad form though, implicitly requires
        self.f to be set before being called
        """
        if not hasattr(self, "f"):
            raise ValueError(
                "Sorry, you need to set self.f to a file pointer "
                "before calling self._write methods."
            )
        self.f.write(text + "\n" * ncount)

    def _write_heading(self, text, uline=None):
        """
        Convenience method to write a subheading
        """
        if self.opts["literal_sub_heading"]:
            text = f"``{text}``"
        self._write(text)
        self._write((uline or self.opts["sub_heading_char"]) * len(text))

    def _write_main_heading(self, text, uline=None):
        """
        Convenience method to write the main heading
        """
        self.f.write(text + "\n")
        self.f.write((uline or self.opts["main_heading_char"]) * len(text) + "\n\n")

    def _write_main_description(self, text):
        """
        Convenience method to write the main description
        """
        self.f.write(text + "\n")

    def _get_main_heading(self, states):
        """
        Returns a string to be used as the main heading of an
        rst file. Looks up in the following order:
        1. If there is a documented root init.sls, return its first line.
        2. If a header was specified in opts, return that.
        3. If this renderer was instantiated with a prefix, return the
           capitalized last part of it.
        4. "Available States"
        """
        if header := self.opts.get("header"):
            return header
        if (main := states.lookup(self.prefix + ("init",))) and main.comment:
            lines = main.comment.splitlines()
            header = lines.pop(0)
            # make sure the header does not get printed twice
            main.comment = "\n".join(lines).strip("\n")
            return header
        if self.prefix:
            return self.prefix[-1].title()
        if header := self.opts.get("default_header"):
            return header
        return self.opts["default_header"]

    def _get_main_description(self, states):
        """
        Returns a string to be used as the main description (after the main header).
        Looks up in the following order:
        1. If there is a documented root init.sls, return everything but its first line.
           If it was found, unregister it to not render it twice
           (bad form again, maybe consider was_rendered on State)
        2. If a description was specified in opts, return that.
        3. general description
        """

        def get_default_desc():
            return self.opts["default_description"].format(
                where=".".join(self.prefix) if self.prefix else "this formula"
            )

        if desc := self.opts.get("description"):
            return desc
        if main := states.lookup(self.prefix + ("init",)):
            desc = main.comment
            if self.opts.get("always_include_default_description"):
                desc += "\n" + get_default_desc()
            return desc
        return get_default_desc()

    def _write_header(self, states):
        """
        Convenience method to write the main heading and description.
        """
        self._write_main_heading(self._get_main_heading(states))
        self._write_main_description(self._get_main_description(states))
        self.f.write("\n")

    def _ensurepath(self, path):
        """
        Convenience method to mimic mkdir -p on the parent dir of a path
        """
        if not path.parent.is_dir():
            path.parent.mkdir(parents=True)


class SingleRenderer(Renderer):
    """
    Renders documented Salt states in a single file. When a prefix
    is specified, it only considers states nested inside that prefix.

    If there is a documented top-layer init.sls, the first line of
    its comment will become the main heading of the file, the stripped
    rest will be rendered directly below before any nested states as
    the main description.

    Should have been a Jinja template @TODO
    """

    default_opts = {
        # include init.sls files
        "init": True,
        # include .init suffix
        "init_suffix": False,
        # include clean.sls files
        "clean": True,
        # when rendering with prefix `settings.something`,
        # write `else` instead of `settings.something.else`
        "cut_prefix": False,
        "include_formula_root": True,
    }

    def __init__(
        self, target="docs/states.rst", prefix=None, basedir=base_dir, **kwargs
    ):
        if isinstance(target, str):
            target = Path(target)
        target = target.with_suffix(".rst")
        super().__init__(
            target=target,
            prefix=prefix,
            basedir=basedir,
            opts=self.default_opts | kwargs,
        )

    def render(self, states):
        relative_to = "/".join(self.prefix) if self.opts["cut_prefix"] else ""

        with open(self.target, "w") as f:
            self.f = f

            self._write_header(states)

            for state in states.dump(self.prefix):
                if not self.opts["init"] and state.is_init():
                    continue
                if not self.opts["clean"] and state.is_clean():
                    continue
                state_id = state.to_state_id(
                    self.opts["init_suffix"],
                    relative_to,
                    formula_root=self.opts["include_formula_root"],
                )
                if state_id == ".":
                    continue
                self._write_heading(
                    state.to_state_id(
                        self.opts["init_suffix"],
                        relative_to,
                        formula_root=self.opts["include_formula_root"],
                    )
                )
                self._write(state.comment, 3)


class DepthRenderer(Renderer):
    """
    Renders documented Salt states in separate files. They are combined
    according to the maximum nesting depth specified during instantiation.

    Consider an example with max_depth=1.

    This directory structure:
        settings
        ├── animations
        │   ├── cursor_blinking
        │   └── window_resize_time
        └── apps
            └── messages
                └── read_receipts

    will result in these files:
        * docs/settings/index.rst
            - animations
            - apps/index
        * docs/settings/animations.rst
            - cursor_blinking doc
            - window_resize_time doc
        * docs/settings/apps/index.rst
            - messages
        * docs/settings/apps/messages.rst
            - read_receipts doc
    """

    default_opts = {
        # maxdepth for generated index.rst files
        "index_maxdepth": 4,
        "single_opts": {
            "init": True,
            "init_suffix": False,
            "clean": False,
            "cut_prefix": True,
            "include_formula_root": False,
        },
    }

    def __init__(
        self,
        max_depth=1,
        target="docs/states",
        delegate_cls=None,
        basedir=base_dir,
        **kwargs,
    ):
        self.max_depth = max_depth
        self.delegate = delegate_cls or SingleRenderer
        super().__init__(
            target=target, basedir=basedir, opts=self.default_opts | (kwargs or {})
        )

    def render(self, states):
        found = []
        indices = {}
        cur_top = ()

        # Find at which prefix length the maxdepth is fulfilled
        for prefix in states.prefixes:
            # Filtering out more specific = shallower dicts
            # This requires the list to be sorted!
            # This also goes not too well with rendering clean states
            # by default since they will be separate.
            if cur_top:
                if prefix[: len(cur_top)] == cur_top:
                    continue
                cur_top = ()
            if states.depth(prefix) <= self.max_depth:
                found.append(prefix)
                cur_top = prefix

        # fill indices with relative links to
        #   a) other indices if there are nested prefixes
        #   b) the rendered files at that depth
        # example: settings.one settings.two.[subone, subtwo] =>
        # { settings: {'_index': ['one', 'two/index'],
        #              'two': {'_index': [subone, subtwo]}},
        #  '_index': 'settings/index'}

        for prefix in found:
            ptr = indices
            prefix = list(prefix)

            while prefix:
                cur = prefix.pop(0)
                if prefix and cur not in ptr:
                    ptr[cur] = {}
                if "_index" not in ptr:
                    ptr["_index"] = []
                if prefix and cur + "/index" not in ptr["_index"]:
                    ptr["_index"].append(cur + "/index")
                elif not prefix and cur not in ptr["_index"]:
                    ptr["_index"].append(cur)
                if prefix:
                    ptr = ptr[cur]

        if "_index" in indices:
            self._render_index(states, indices)

        for prefix in found:
            self._render_single(states, prefix)

    def _render_single(self, states, prefix):
        target = self._get_target(prefix)
        rendr = self.delegate(
            target=target, prefix=prefix, **self.opts["single_opts"]
        )
        rendr.render(states)

    def _render_index(self, states, indices, prefix=None):
        prefix = prefix or ()
        target = self._get_target(prefix) / Path("index.rst")
        with open(target, "w") as f:
            self.f = f
            self.prefix = prefix
            self._write_header(states)
            self.prefix = ()
            f.write(
                f"""
.. toctree::
   :maxdepth: {self.opts['index_maxdepth']}
   :caption: Contents:

"""
            )
            for s in sorted(indices["_index"]):
                f.write("   " + s + "\n")
        for sub in sorted(indices):
            if sub == "_index":
                continue
            if "_index" in indices[sub]:
                new_prefix = prefix + (sub,)
                self._render_index(states, indices[sub], new_prefix)

    def _get_target(self, prefix):
        target_prefix = Path("/".join(prefix))
        target = self.target / target_prefix
        self._ensurepath(target)
        return target


class SaltAutodoc:
    """
    Looks for salt state files (*.sls) relative to base,
    reads them and looks for the first token being a Jinja
    comment. If the comment does not start with the string
    '@internal', it will be saved in the registry.

    The registry is then passed to the renderer.
    """

    SKIP_FILE = "@internal"

    def __init__(
        self,
        base=None,
        rendr=None,
        registry=None,
        env=None,
        include_empty=False,
        ignore=None,
    ):
        """
        base: base path containing Salt states to autodocument
        renderer: renderer to use for rendering
        """
        if base is None:
            base = list(base_dir.glob("*/init.sls"))[0].parent
        self.base = base
        self.renderer = rendr or SingleRenderer()
        self.env = env or Environment()
        self.registry = registry or StateRegistry()
        self.include_empty = include_empty
        self.ignore = ignore or []

    def run(self, src=""):
        """
        1. look for .sls files
        2. lex them with Jinja and see if the first token is a comment
        3. in that case, save it to the registry
        4. pass the registry to the renderer
        """
        for file in self.glob(src):
            if str(file.relative_to(self.base)).startswith(tuple(self.ignore)):
                continue
            with open(file, "r") as f:
                text = "".join(f.readlines())
            try:
                comment = self.find_comment(text)
            except TemplateSyntaxError as err:
                raise RuntimeError(
                    f"There was an error parsing the Jinja syntax in file '{file}':\n\n{err}"
                ) from err

            if (comment or self.include_empty) and not comment.startswith(
                self.SKIP_FILE
            ):
                path = file.relative_to(self.base)
                self.registry.register(State(path, comment, self.base.name))

        self.renderer.render(self.registry)

    def glob(self, src):
        """
        Returns Path objects pointing to Salt states provided by this formula.
        """
        for path in (self.base / Path(src)).rglob("*.sls"):
            yield path

    def find_comment(self, text):
        """
        Lexes files with Jinja. If the first token is a comment, return it.
        """
        lexed = list(self.env.lex(text))
        try:
            i = 0
            t = ""
            while i < 5 and t != "comment":
                _, t, content = lexed[i]
                i += 1
        except IndexError:
            t = content = ""

        if t == "comment":
            return self.parse(content)

        return ""

    def parse(self, comment):
        """
        Strips indentation from [multiline] strings. Could have used
        docutils.prepare_docstring.
        """
        # return d.prepare_docstring(comment)
        ws = min(len(x) - len(x.lstrip()) for x in comment.splitlines() if len(x) > 0)
        return "\n".join([x[ws:] for x in comment.splitlines()]).strip("\n")


if __name__ == "__main__":
    options = (
        toml.loads((base_dir / "pyproject.toml").read_text())
        .get("tool", {})
        .get("slsdoc", {})
    )
    renderer = options.get("renderer", "single")

    if renderer == "single":
        rend = SingleRenderer(**options.get("single_renderer", {}))
    elif renderer == "depth":
        rend = DepthRenderer(**options.get("depth_renderer", {}))
    else:
        raise ValueError(f"Invalid value for renderer: {renderer}")

    reg = StateRegistry(
        avoid_init_dup=options.get("avoid_init_dup", True),
        sort_list=options.get("sort_list"),
    )
    doc = SaltAutodoc(
        rendr=rend,
        registry=reg,
        include_empty=options.get("include_empty", False),
        ignore=options.get("ignore", ["_mapdata"]),
    )
    doc.run()
