#!/usr/bin/env python3
# vim:fileencoding=utf-8
#
# Configuration file for the Sphinx documentation builder.
#
# This file does only contain a selection of the most common options. For a
# full list see the documentation:
# https://www.sphinx-doc.org/en/master/config

import os
import re
import subprocess
import sys
import time
from functools import partial
from typing import (
    Any, Callable, Dict, Iterable, List, Match, Optional, Sequence, Tuple,
    Union
)

from docutils import nodes
from docutils.parsers.rst.roles import set_classes
from pygments.lexer import RegexLexer, bygroups  # type: ignore
from pygments.token import (  # type: ignore
    Comment, Keyword, Literal, Name, Number, String, Whitespace
)
from sphinx import addnodes, version_info  # type: ignore
from sphinx.environment.adapters.toctree import TocTree  # type: ignore
from sphinx.util.logging import getLogger  # type: ignore

kitty_src = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if kitty_src not in sys.path:
    sys.path.insert(0, kitty_src)

from kitty.conf.definition import Option, Shortcut  # noqa
from kitty.constants import str_version  # noqa


# config {{{
# -- Project information -----------------------------------------------------

project = 'kitty'
copyright = time.strftime('%Y, Kovid Goyal')
author = 'Kovid Goyal'
building_man_pages = 'man' in sys.argv

# The short X.Y version
version = str_version
# The full version, including alpha/beta/rc tags
release = str_version
logger = getLogger(__name__)


# -- General configuration ---------------------------------------------------

# If your documentation needs a minimal Sphinx version, state it here.
#
needs_sphinx = '1.7'

# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = [
    'sphinx.ext.ifconfig',
    'sphinx.ext.viewcode',
    'sphinx.ext.githubpages',
]

# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']

# The suffix(es) of source filenames.
# You can specify multiple suffix as a list of string:
#
# source_suffix = ['.rst', '.md']
source_suffix = '.rst'

# The master toctree document.
master_doc = 'index'

# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
#
# This is also used if you do content translation via gettext catalogs.
# Usually you set "language" from the command line for these cases.
language: Optional[str] = None

# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
# This pattern also affects html_static_path and html_extra_path .
exclude_patterns = [
    '_build', 'Thumbs.db', '.DS_Store',
    'generated/cli-*.rst', 'generated/conf-*.rst'
]

# The name of the Pygments (syntax highlighting) style to use.
pygments_style = 'sphinx'

rst_prolog = '''
.. |kitty| replace:: *kitty*
.. |version| replace:: VERSION
.. _tarball: https://github.com/kovidgoyal/kitty/releases/download/vVERSION/kitty-VERSION.tar.xz
.. role:: green
.. role:: italic
.. role:: bold
.. role:: cyan
.. role:: title
.. role:: env

'''.replace('VERSION', str_version)


# -- Options for HTML output -------------------------------------------------

# The theme to use for HTML and HTML Help pages.  See the documentation for
# a list of builtin themes.
#
html_theme = 'alabaster'

# Theme options are theme-specific and customize the look and feel of a theme
# further.  For a list of options available for each theme, see the
# documentation.
#
html_theme_options = {
    'logo': 'kitty.png',
    'show_powered_by': False,
    'fixed_sidebar': True,
    'sidebar_collapse': True,
    'github_button': False,
    'github_banner': True,
    'github_user': 'kovidgoyal',
    'github_repo': 'kitty',
    # increase contrast of link color with text color
    'link': '#00587d',
    'link_hover': 'green',
}


# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static', '../logo/kitty.png']
html_favicon = '../logo/kitty.png'

# Custom sidebar templates, must be a dictionary that maps document names
# to template names.
#
# The default sidebars (for documents that don't match any pattern) are
# defined by theme itself.  Builtin themes are using these templates by
# default: ``['localtoc.html', 'relations.html', 'sourcelink.html',
# 'searchbox.html']``.
#
html_sidebars = {
    '**': [
        'about.html',
        'support.html',
        'searchbox.html',
        'localtoc.html',
        'relations.html',
    ]
}
html_show_sourcelink = False


# -- Options for manual page output ------------------------------------------

# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [
    ('invocation', 'kitty', 'kitty Documentation',
     [author], 1)
]


# -- Options for Texinfo output ----------------------------------------------

# Grouping the document tree into Texinfo files. List of tuples
# (source start file, target name, title, author,
#  dir menu entry, description, category)
texinfo_documents = [
    (master_doc, 'kitty', 'kitty Documentation',
     author, 'kitty', 'Cross-platform, fast, feature-rich, GPU based terminal',
     'Miscellaneous'),
]
# }}}


# GitHub linking inline roles {{{

def num_role(which: str, name: str, rawtext: str, text: str, lineno: int, inliner: Any, options: Any = {}, content: Any = []) -> Tuple[List, List]:
    ' Link to a github issue '
    try:
        issue_num = int(text)
        if issue_num <= 0:
            raise ValueError
    except ValueError:
        msg = inliner.reporter.error(
            'GitHub issue number must be a number greater than or equal to 1; '
            '"%s" is invalid.' % text, line=lineno)
        prb = inliner.problematic(rawtext, rawtext, msg)
        return [prb], [msg]
    url = f'https://github.com/kovidgoyal/kitty/{which}/{issue_num}'
    set_classes(options)
    node = nodes.reference(rawtext, f'#{issue_num}', refuri=url, **options)
    return [node], []


def commit_role(name: str, rawtext: str, text: str, lineno: int, inliner: Any, options: Any = {}, content: Any = []) -> Tuple[List, List]:
    ' Link to a github commit '
    try:
        commit_id = subprocess.check_output(
            f'git rev-list --max-count=1 --skip=# {text}'.split()).decode('utf-8').strip()
    except Exception:
        msg = inliner.reporter.error(
            f'GitHub commit id "{text}" not recognized.', line=lineno)
        prb = inliner.problematic(rawtext, rawtext, msg)
        return [prb], [msg]
    url = f'https://github.com/kovidgoyal/kitty/commit/{commit_id}'
    set_classes(options)
    short_id = subprocess.check_output(
        f'git rev-list --max-count=1 --abbrev-commit --skip=# {commit_id}'.split()).decode('utf-8').strip()
    node = nodes.reference(rawtext, f'commit: {short_id}', refuri=url, **options)
    return [node], []
# }}}


# Sidebar ToC {{{
def create_toc(app: Any, pagename: str) -> Optional[Any]:
    tt = TocTree(app.env)
    toctree = tt.get_toc_for(pagename, app.builder)
    if toctree is not None:
        subtree = toctree[toctree.first_child_matching_class(nodes.list_item)]
        bl = subtree.first_child_matching_class(nodes.bullet_list)
        if bl is None:
            return None  # Empty ToC
        subtree = subtree[bl]
        # for li in subtree.traverse(nodes.list_item):
        #     modify_li(li)
        # subtree['ids'] = [ID]
        return app.builder.render_partial(subtree)['fragment']


def add_html_context(app: Any, pagename: str, templatename: str, context: Any, *args: Any) -> None:
    if 'toc' in context:
        context['toc'] = create_toc(app, pagename) or context['toc']
# }}}


# CLI docs {{{
def write_cli_docs(all_kitten_names: Iterable[str]) -> None:
    from kitty.launch import options_spec as launch_options_spec
    from kitty.cli import option_spec_as_rst
    with open('generated/launch.rst', 'w') as f:
        f.write(option_spec_as_rst(
            appname='launch', ospec=launch_options_spec, heading_char='_',
            message='''\
Launch an arbitrary program in a new kitty window/tab. Note that
if you specify a program-to-run you can use the special placeholder
:code:`@selection` which will be replaced by the current selection.
'''
        ))
    with open('generated/cli-kitty.rst', 'w') as f:
        f.write(option_spec_as_rst(appname='kitty').replace(
            'kitty --to', 'kitty @ --to'))
    as_rst = partial(option_spec_as_rst, heading_char='_')
    from kitty.rc.base import all_command_names, command_for_name
    from kitty.remote_control import global_options_spec, cli_msg
    with open('generated/cli-kitty-at.rst', 'w') as f:
        p = partial(print, file=f)
        p('kitty @\n' + '-' * 80)
        p('.. program::', 'kitty @')
        p('\n\n' + as_rst(
            global_options_spec, message=cli_msg, usage='command ...', appname='kitty @'))
        from kitty.rc.base import cli_params_for
        for cmd_name in sorted(all_command_names()):
            func = command_for_name(cmd_name)
            p(f'.. _at_{func.name}:\n')
            p('kitty @', func.name + '\n' + '-' * 120)
            p('.. program::', 'kitty @', func.name)
            p('\n\n' + as_rst(*cli_params_for(func)))
    from kittens.runner import get_kitten_cli_docs
    for kitten in all_kitten_names:
        data = get_kitten_cli_docs(kitten)
        if data:
            with open(f'generated/cli-kitten-{kitten}.rst', 'w') as f:
                p = partial(print, file=f)
                p('.. program::', f'kitty +kitten {kitten}')
                p('\n\n' + option_spec_as_rst(
                    data['options'], message=data['help_text'], usage=data['usage'], appname=f'kitty +kitten {kitten}',
                    heading_char='^'))

# }}}


def write_remote_control_protocol_docs() -> None:  # {{{
    from kitty.rc.base import all_command_names, command_for_name, RemoteCommand
    field_pat = re.compile(r'\s*([a-zA-Z0-9_+]+)\s*:\s*(.+)')

    def format_cmd(p: Callable, name: str, cmd: RemoteCommand) -> None:
        p(name)
        p('-' * 80)
        lines = (cmd.__doc__ or '').strip().splitlines()
        fields = []
        for line in lines:
            m = field_pat.match(line)
            if m is None:
                p(line)
            else:
                fields.append((m.group(1), m.group(2)))
        if fields:
            p('\nFields are:\n')
            for (name, desc) in fields:
                if '+' in name:
                    title = name.replace('+', ' (required)')
                else:
                    title = name
                    defval = cmd.get_default(name.replace('-', '_'), cmd)
                    if defval is not cmd:
                        title = f'{title} (default: {defval})'
                    else:
                        title = f'{title} (optional)'
                p(f':code:`{title}`')
                p(' ', desc), p()
        p(), p()

    with open('generated/rc.rst', 'w') as f:
        p = partial(print, file=f)
        for name in sorted(all_command_names()):
            cmd = command_for_name(name)
            if not cmd.__doc__:
                continue
            name = name.replace('_', '-')
            format_cmd(p, name, cmd)
# }}}


# config file docs {{{

class ConfLexer(RegexLexer):
    name = 'Conf'
    aliases = ['conf']
    filenames = ['*.conf']

    tokens = {
        'root': [
            (r'#.*?$', Comment.Single),
            (r'\s+$', Whitespace),
            (r'\s+', Whitespace),
            (r'(include)(\s+)(.+?)$', bygroups(Comment.Preproc, Whitespace, Name.Namespace)),
            (r'(map)(\s+)(\S+)(\s+)', bygroups(
                Keyword.Declaration, Whitespace, String, Whitespace), 'action'),
            (r'(symbol_map)(\s+)(\S+)(\s+)(.+?)$', bygroups(
                Keyword.Declaration, Whitespace, String, Whitespace, Literal)),
            (r'([a-zA-Z_0-9]+)(\s+)', bygroups(
                Name.Variable, Whitespace), 'args'),
        ],
        'action': [
            (r'[a-z_0-9]+$', Name.Function, 'root'),
            (r'[a-z_0-9]+', Name.Function, 'args'),
        ],
        'args': [
            (r'\s+', Whitespace, 'args'),
            (r'\b(yes|no)\b$', Number.Bin, 'root'),
            (r'\b(yes|no)\b', Number.Bin, 'args'),
            (r'[+-]?[0-9]+\s*$', Number.Integer, 'root'),
            (r'[+-]?[0-9.]+\s*$', Number.Float, 'root'),
            (r'[+-]?[0-9]+', Number.Integer, 'args'),
            (r'[+-]?[0-9.]+', Number.Float, 'args'),
            (r'#[a-fA-F0-9]{3,6}\s*$', String, 'root'),
            (r'#[a-fA-F0-9]{3,6}\s*', String, 'args'),
            (r'.+', String, 'root'),
        ],
    }


class SessionLexer(RegexLexer):
    name = 'Session'
    aliases = ['session']
    filenames = ['*.session']

    tokens = {
        'root': [
            (r'#.*?$', Comment.Single),
            (r'[a-z][a-z0-9_]+', Name.Function, 'args'),
        ],
        'args': [
            (r'.*?$', Literal, 'root'),
        ]
    }


def link_role(name: str, rawtext: str, text: str, lineno: int, inliner: Any, options: Any = {}, content: Any = []) -> Tuple[List, List]:
    m = re.match(r'(.+)\s+<(.+?)>', text)
    if m is None:
        msg = inliner.reporter.error(f'link "{text}" not recognized', line=lineno)
        prb = inliner.problematic(rawtext, rawtext, msg)
        return [prb], [msg]
    text, url = m.group(1, 2)
    set_classes(options)
    node = nodes.reference(rawtext, text, refuri=url, **options)
    return [node], []


def expand_opt_references(conf_name: str, text: str) -> str:
    conf_name += '.'

    def expand(m: Match) -> str:
        ref = m.group(1)
        if '<' not in ref and '.' not in ref:
            full_ref = conf_name + ref
            return ':opt:`{} <{}>`'.format(ref, full_ref)
        return str(m.group())

    return re.sub(r':opt:`(.+?)`', expand, text)


opt_aliases: Dict[str, str] = {}
shortcut_slugs: Dict[str, Tuple[str, str]] = {}


def parse_opt_node(env: Any, sig: str, signode: Any) -> str:
    """Transform an option description into RST nodes."""
    count = 0
    firstname = ''
    for potential_option in sig.split(', '):
        optname = potential_option.strip()
        if count:
            signode += addnodes.desc_addname(', ', ', ')
        text = optname.split('.', 1)[-1]
        signode += addnodes.desc_name(text, text)
        if not count:
            firstname = optname
            signode['allnames'] = [optname]
        else:
            signode['allnames'].append(optname)
            opt_aliases[optname] = firstname
        count += 1
    if not firstname:
        raise ValueError('{} is not a valid opt'.format(sig))
    return firstname


def parse_shortcut_node(env: Any, sig: str, signode: Any) -> str:
    """Transform a shortcut description into RST nodes."""
    conf_name, text = sig.split('.', 1)
    signode += addnodes.desc_name(text, text)
    return sig


def render_conf(conf_name: str, all_options: Iterable[Union['Option', Sequence['Shortcut']]]) -> str:
    from kitty.conf.definition import merged_opts, Option, Group
    ans = ['.. default-domain:: conf', '']
    a = ans.append
    current_group: Optional[Group] = None
    all_options_ = list(all_options)
    kitty_mod = 'kitty_mod'

    def render_group(group: Group) -> None:
        a('')
        a(f'.. _conf-{conf_name}-{group.name}:')
        a('')
        a(group.short_text)
        heading_level = '+' if '.' in group.name else '^'
        a(heading_level * (len(group.short_text) + 20))
        a('')
        if group.start_text:
            a(group.start_text)
            a('')

    def handle_group_end(group: Group) -> None:
        if group.end_text:
            assert current_group is not None
            a(''), a(current_group.end_text)

    def handle_group(new_group: Group, new_group_is_shortcut: bool = False) -> None:
        nonlocal current_group
        if new_group is not current_group:
            if current_group:
                handle_group_end(current_group)
            current_group = new_group
            render_group(current_group)

    def handle_option(i: int, opt: Option) -> None:
        nonlocal kitty_mod
        if not opt.long_text or not opt.add_to_docs:
            return
        handle_group(opt.group)
        if opt.name == 'kitty_mod':
            kitty_mod = opt.defval_as_string
        mopts = list(merged_opts(all_options_, opt, i))
        a('.. opt:: ' + ', '.join(conf_name + '.' + mo.name for mo in mopts))
        a('.. code-block:: conf')
        a('')
        sz = max(len(x.name) for x in mopts)
        for mo in mopts:
            a(('    {:%ds} {}' % sz).format(mo.name, mo.defval_as_string))
        a('')
        if opt.long_text:
            a(expand_opt_references(conf_name, opt.long_text))
            a('')

    def handle_shortcuts(shortcuts: Sequence[Shortcut]) -> None:
        sc = shortcuts[0]
        handle_group(sc.group, True)
        sc_text = f'{conf_name}.{sc.short_text}'
        a('.. shortcut:: ' + sc_text)
        shortcuts = [s for s in shortcuts if s.add_to_default]
        shortcut_slugs[f'{conf_name}.{sc.name}'] = (sc_text, sc.key.replace('kitty_mod', kitty_mod))
        if shortcuts:
            a('.. code-block:: conf')
            a('')
            for x in shortcuts:
                if x.add_to_default:
                    a('    map {} {}'.format(x.key.replace('kitty_mod', kitty_mod), x.action_def))
        a('')
        if sc.long_text:
            a(expand_opt_references(conf_name, sc.long_text))
            a('')

    for i, opt in enumerate(all_options_):
        if isinstance(opt, Option):
            handle_option(i, opt)
        else:
            handle_shortcuts(opt)

    if current_group:
        handle_group_end(current_group)
    return '\n'.join(ans)


def process_opt_link(env: Any, refnode: Any, has_explicit_title: bool, title: str, target: str) -> Tuple[str, str]:
    conf_name, opt = target.partition('.')[::2]
    if not opt:
        conf_name, opt = 'kitty', conf_name
    full_name = conf_name + '.' + opt
    return title, opt_aliases.get(full_name, full_name)


def process_shortcut_link(env: Any, refnode: Any, has_explicit_title: bool, title: str, target: str) -> Tuple[str, str]:
    conf_name, slug = target.partition('.')[::2]
    if not slug:
        conf_name, slug = 'kitty', conf_name
    full_name = conf_name + '.' + slug
    try:
        target, stitle = shortcut_slugs[full_name]
    except KeyError:
        logger.warning('Unknown shortcut: {}'.format(target), location=refnode)
    else:
        if not has_explicit_title:
            title = stitle
    return title, target


def write_conf_docs(app: Any, all_kitten_names: Iterable[str]) -> None:
    app.add_lexer('conf', ConfLexer() if version_info[0] < 3 else ConfLexer)
    app.add_object_type(
        'opt', 'opt',
        indextemplate="pair: %s; Config Setting",
        parse_node=parse_opt_node,
    )
    # Warn about opt references that could not be resolved
    opt_role = app.registry.domain_roles['std']['opt']
    opt_role.warn_dangling = True
    opt_role.process_link = process_opt_link

    app.add_object_type(
        'shortcut', 'sc',
        indextemplate="pair: %s; Keyboard Shortcut",
        parse_node=parse_shortcut_node,
    )
    sc_role = app.registry.domain_roles['std']['sc']
    sc_role.warn_dangling = True
    sc_role.process_link = process_shortcut_link

    def generate_default_config(all_options: Dict[str, Union[Option, Sequence[Shortcut]]], name: str) -> None:
        from kitty.conf.definition import as_conf_file
        with open(f'generated/conf-{name}.rst', 'w', encoding='utf-8') as f:
            print('.. highlight:: conf\n', file=f)
            f.write(render_conf(name, all_options.values()))

        conf_name = re.sub(r'^kitten-', '', name) + '.conf'
        with open(f'generated/conf/{conf_name}', 'w', encoding='utf-8') as f:
            text = '\n'.join(as_conf_file(all_options.values()))
            print(text, file=f)

    from kitty.config_data import all_options
    generate_default_config(all_options, 'kitty')

    from kittens.runner import get_kitten_conf_docs
    for kitten in all_kitten_names:
        all_options = get_kitten_conf_docs(kitten)
        if all_options:
            generate_default_config(all_options, f'kitten-{kitten}')
# }}}


def setup(app: Any) -> None:
    os.makedirs('generated/conf', exist_ok=True)
    from kittens.runner import all_kitten_names
    kn = all_kitten_names()
    write_cli_docs(kn)
    write_remote_control_protocol_docs()
    write_conf_docs(app, kn)
    app.add_css_file('custom.css')
    app.add_lexer('session', SessionLexer() if version_info[0] < 3 else SessionLexer)
    app.add_role('link', link_role)
    app.add_role('iss', partial(num_role, 'issues'))
    app.add_role('pull', partial(num_role, 'pull'))
    app.add_role('commit', commit_role)
    app.connect('html-page-context', add_html_context)
