"""
    sphinx.ext.apidoc
    ~~~~~~~~~~~~~~~~~

    Parses a directory tree looking for Python modules and packages and creates
    ReST files appropriately to create code documentation with Sphinx.  It also
    creates a modules index (named modules.<suffix>).

    This is derived from the "sphinx-autopackage" script, which is:
    Copyright 2008 Société des arts technologiques (SAT),
    https://sat.qc.ca/

    :copyright: Copyright 2007-2019 by the Sphinx team, see AUTHORS.
    :license: BSD, see LICENSE for details.
"""

import argparse
import glob
import locale
import os
import sys
import warnings
from fnmatch import fnmatch
from os import path
from typing import Any, List, Tuple

import sphinx.locale
from sphinx import __display_version__, package_dir
from sphinx.cmd.quickstart import EXTENSIONS
from sphinx.deprecation import RemovedInSphinx40Warning
from sphinx.locale import __
from sphinx.util import rst
from sphinx.util.osutil import FileAvoidWrite, ensuredir
from sphinx.util.template import ReSTRenderer

# automodule options
if 'SPHINX_APIDOC_OPTIONS' in os.environ:
    OPTIONS = os.environ['SPHINX_APIDOC_OPTIONS'].split(',')
else:
    OPTIONS = [
        'members',
        'undoc-members',
        # 'inherited-members', # disabled because there's a bug in sphinx
        'show-inheritance',
    ]

INITPY = '__init__.py'
PY_SUFFIXES = {'.py', '.pyx'}

template_dir = path.join(package_dir, 'templates', 'apidoc')


def makename(package: str, module: str) -> str:
    """Join package and module with a dot."""
    warnings.warn('makename() is deprecated.',
                  RemovedInSphinx40Warning)
    # Both package and module can be None/empty.
    if package:
        name = package
        if module:
            name += '.' + module
    else:
        name = module
    return name


def module_join(*modnames: str) -> str:
    """Join module names with dots."""
    return '.'.join(filter(None, modnames))


def write_file(name: str, text: str, opts: Any) -> None:
    """Write the output file for module/package <name>."""
    fname = path.join(opts.destdir, '%s.%s' % (name, opts.suffix))
    if opts.dryrun:
        print(__('Would create file %s.') % fname)
        return
    if not opts.force and path.isfile(fname):
        print(__('File %s already exists, skipping.') % fname)
    else:
        print(__('Creating file %s.') % fname)
        with FileAvoidWrite(fname) as f:
            f.write(text)


def format_heading(level: int, text: str, escape: bool = True) -> str:
    """Create a heading of <level> [1, 2 or 3 supported]."""
    warnings.warn('format_warning() is deprecated.',
                  RemovedInSphinx40Warning)
    if escape:
        text = rst.escape(text)
    underlining = ['=', '-', '~', ][level - 1] * len(text)
    return '%s\n%s\n\n' % (text, underlining)


def format_directive(module: str, package: str = None) -> str:
    """Create the automodule directive and add the options."""
    warnings.warn('format_directive() is deprecated.',
                  RemovedInSphinx40Warning)
    directive = '.. automodule:: %s\n' % module_join(package, module)
    for option in OPTIONS:
        directive += '    :%s:\n' % option
    return directive


def create_module_file(package: str, basename: str, opts: Any,
                       user_template_dir: str = None) -> None:
    """Build the text of the file and write the file."""
    qualname = module_join(package, basename)
    context = {
        'show_headings': not opts.noheadings,
        'basename': basename,
        'qualname': qualname,
        'automodule_options': OPTIONS,
    }
    text = ReSTRenderer([user_template_dir, template_dir]).render('module.rst_t', context)
    write_file(qualname, text, opts)


def create_package_file(root: str, master_package: str, subroot: str, py_files: List[str],
                        opts: Any, subs: List[str], is_namespace: bool,
                        excludes: List[str] = [], user_template_dir: str = None) -> None:
    """Build the text of the file and write the file."""
    # build a list of sub packages (directories containing an INITPY file)
    subpackages = [sub for sub in subs if not
                   shall_skip(path.join(root, sub, INITPY), opts, excludes)]
    subpackages = [module_join(master_package, subroot, pkgname)
                   for pkgname in subpackages]
    # build a list of sub modules
    submodules = [path.splitext(sub)[0] for sub in py_files
                  if not is_skipped_module(path.join(root, sub), opts, excludes) and
                  sub != INITPY]
    submodules = [module_join(master_package, subroot, modname)
                  for modname in submodules]

    pkgname = module_join(master_package, subroot)
    context = {
        'pkgname': pkgname,
        'subpackages': subpackages,
        'submodules': submodules,
        'is_namespace': is_namespace,
        'modulefirst': opts.modulefirst,
        'separatemodules': opts.separatemodules,
        'automodule_options': OPTIONS,
        'show_headings': not opts.noheadings,
    }
    text = ReSTRenderer([user_template_dir, template_dir]).render('package.rst_t', context)
    write_file(pkgname, text, opts)

    if submodules and opts.separatemodules:
        for submodule in submodules:
            create_module_file(None, submodule, opts, user_template_dir)


def create_modules_toc_file(modules: List[str], opts: Any, name: str = 'modules',
                            user_template_dir: str = None) -> None:
    """Create the module's index."""
    modules.sort()
    prev_module = ''
    for module in modules[:]:
        # look if the module is a subpackage and, if yes, ignore it
        if module.startswith(prev_module + '.'):
            modules.remove(module)
        else:
            prev_module = module

    context = {
        'header': opts.header,
        'maxdepth': opts.maxdepth,
        'docnames': modules,
    }
    text = ReSTRenderer([user_template_dir, template_dir]).render('toc.rst_t', context)
    write_file(name, text, opts)


def shall_skip(module: str, opts: Any, excludes: List[str] = []) -> bool:
    """Check if we want to skip this module."""
    # skip if the file doesn't exist and not using implicit namespaces
    if not opts.implicit_namespaces and not path.exists(module):
        return True

    # Are we a package (here defined as __init__.py, not the folder in itself)
    if os.path.basename(module) == INITPY:
        # Yes, check if we have any non-excluded modules at all here
        all_skipped = True
        basemodule = path.dirname(module)
        for submodule in glob.glob(path.join(basemodule, '*.py')):
            if not is_excluded(path.join(basemodule, submodule), excludes):
                # There's a non-excluded module here, we won't skip
                all_skipped = False
        if all_skipped:
            return True

    # skip if it has a "private" name and this is selected
    filename = path.basename(module)
    if filename != '__init__.py' and filename.startswith('_') and \
       not opts.includeprivate:
        return True
    return False


def is_skipped_module(filename: str, opts: Any, excludes: List[str]) -> bool:
    """Check if we want to skip this module."""
    if not path.exists(filename):
        # skip if the file doesn't exist
        return True
    elif path.basename(filename).startswith('_') and not opts.includeprivate:
        # skip if the module has a "private" name
        return True
    else:
        return False


def recurse_tree(rootpath: str, excludes: List[str], opts: Any,
                 user_template_dir: str = None) -> List[str]:
    """
    Look for every file in the directory tree and create the corresponding
    ReST files.
    """
    followlinks = getattr(opts, 'followlinks', False)
    includeprivate = getattr(opts, 'includeprivate', False)
    implicit_namespaces = getattr(opts, 'implicit_namespaces', False)

    # check if the base directory is a package and get its name
    if INITPY in os.listdir(rootpath) or implicit_namespaces:
        root_package = rootpath.split(path.sep)[-1]
    else:
        # otherwise, the base is a directory with packages
        root_package = None

    toplevels = []
    for root, subs, files in os.walk(rootpath, followlinks=followlinks):
        # document only Python module files (that aren't excluded)
        py_files = sorted(f for f in files
                          if path.splitext(f)[1] in PY_SUFFIXES and
                          not is_excluded(path.join(root, f), excludes))
        is_pkg = INITPY in py_files
        is_namespace = INITPY not in py_files and implicit_namespaces
        if is_pkg:
            py_files.remove(INITPY)
            py_files.insert(0, INITPY)
        elif root != rootpath:
            # only accept non-package at toplevel unless using implicit namespaces
            if not implicit_namespaces:
                del subs[:]
                continue
        # remove hidden ('.') and private ('_') directories, as well as
        # excluded dirs
        if includeprivate:
            exclude_prefixes = ('.',)  # type: Tuple[str, ...]
        else:
            exclude_prefixes = ('.', '_')
        subs[:] = sorted(sub for sub in subs if not sub.startswith(exclude_prefixes) and
                         not is_excluded(path.join(root, sub), excludes))

        if is_pkg or is_namespace:
            # we are in a package with something to document
            if subs or len(py_files) > 1 or not shall_skip(path.join(root, INITPY), opts):
                subpackage = root[len(rootpath):].lstrip(path.sep).\
                    replace(path.sep, '.')
                # if this is not a namespace or
                # a namespace and there is something there to document
                if not is_namespace or len(py_files) > 0:
                    create_package_file(root, root_package, subpackage,
                                        py_files, opts, subs, is_namespace, excludes,
                                        user_template_dir)
                    toplevels.append(module_join(root_package, subpackage))
        else:
            # if we are at the root level, we don't require it to be a package
            assert root == rootpath and root_package is None
            for py_file in py_files:
                if not is_skipped_module(path.join(rootpath, py_file), opts, excludes):
                    module = path.splitext(py_file)[0]
                    create_module_file(root_package, module, opts, user_template_dir)
                    toplevels.append(module)

    return toplevels


def is_excluded(root: str, excludes: List[str]) -> bool:
    """Check if the directory is in the exclude list.

    Note: by having trailing slashes, we avoid common prefix issues, like
          e.g. an exclude "foo" also accidentally excluding "foobar".
    """
    for exclude in excludes:
        if fnmatch(root, exclude):
            return True
    return False


def get_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        usage='%(prog)s [OPTIONS] -o <OUTPUT_PATH> <MODULE_PATH> '
              '[EXCLUDE_PATTERN, ...]',
        epilog=__('For more information, visit <http://sphinx-doc.org/>.'),
        description=__("""
Look recursively in <MODULE_PATH> for Python modules and packages and create
one reST file with automodule directives per package in the <OUTPUT_PATH>.

The <EXCLUDE_PATTERN>s can be file and/or directory patterns that will be
excluded from generation.

Note: By default this script will not overwrite already created files."""))

    parser.add_argument('--version', action='version', dest='show_version',
                        version='%%(prog)s %s' % __display_version__)

    parser.add_argument('module_path',
                        help=__('path to module to document'))
    parser.add_argument('exclude_pattern', nargs='*',
                        help=__('fnmatch-style file and/or directory patterns '
                                'to exclude from generation'))

    parser.add_argument('-o', '--output-dir', action='store', dest='destdir',
                        required=True,
                        help=__('directory to place all output'))
    parser.add_argument('-d', '--maxdepth', action='store', dest='maxdepth',
                        type=int, default=4,
                        help=__('maximum depth of submodules to show in the TOC '
                                '(default: 4)'))
    parser.add_argument('-f', '--force', action='store_true', dest='force',
                        help=__('overwrite existing files'))
    parser.add_argument('-l', '--follow-links', action='store_true',
                        dest='followlinks', default=False,
                        help=__('follow symbolic links. Powerful when combined '
                                'with collective.recipe.omelette.'))
    parser.add_argument('-n', '--dry-run', action='store_true', dest='dryrun',
                        help=__('run the script without creating files'))
    parser.add_argument('-e', '--separate', action='store_true',
                        dest='separatemodules',
                        help=__('put documentation for each module on its own page'))
    parser.add_argument('-P', '--private', action='store_true',
                        dest='includeprivate',
                        help=__('include "_private" modules'))
    parser.add_argument('--tocfile', action='store', dest='tocfile', default='modules',
                        help=__("filename of table of contents (default: modules)"))
    parser.add_argument('-T', '--no-toc', action='store_false', dest='tocfile',
                        help=__("don't create a table of contents file"))
    parser.add_argument('-E', '--no-headings', action='store_true',
                        dest='noheadings',
                        help=__("don't create headings for the module/package "
                                "packages (e.g. when the docstrings already "
                                "contain them)"))
    parser.add_argument('-M', '--module-first', action='store_true',
                        dest='modulefirst',
                        help=__('put module documentation before submodule '
                                'documentation'))
    parser.add_argument('--implicit-namespaces', action='store_true',
                        dest='implicit_namespaces',
                        help=__('interpret module paths according to PEP-0420 '
                                'implicit namespaces specification'))
    parser.add_argument('-s', '--suffix', action='store', dest='suffix',
                        default='rst',
                        help=__('file suffix (default: rst)'))
    parser.add_argument('-F', '--full', action='store_true', dest='full',
                        help=__('generate a full project with sphinx-quickstart'))
    parser.add_argument('-a', '--append-syspath', action='store_true',
                        dest='append_syspath',
                        help=__('append module_path to sys.path, used when --full is given'))
    parser.add_argument('-H', '--doc-project', action='store', dest='header',
                        help=__('project name (default: root module name)'))
    parser.add_argument('-A', '--doc-author', action='store', dest='author',
                        help=__('project author(s), used when --full is given'))
    parser.add_argument('-V', '--doc-version', action='store', dest='version',
                        help=__('project version, used when --full is given'))
    parser.add_argument('-R', '--doc-release', action='store', dest='release',
                        help=__('project release, used when --full is given, '
                                'defaults to --doc-version'))

    group = parser.add_argument_group(__('extension options'))
    group.add_argument('--extensions', metavar='EXTENSIONS', dest='extensions',
                       action='append', help=__('enable arbitrary extensions'))
    for ext in EXTENSIONS:
        group.add_argument('--ext-%s' % ext, action='append_const',
                           const='sphinx.ext.%s' % ext, dest='extensions',
                           help=__('enable %s extension') % ext)

    group = parser.add_argument_group(__('Project templating'))
    group.add_argument('-t', '--templatedir', metavar='TEMPLATEDIR',
                       dest='templatedir',
                       help=__('template directory for template files'))

    return parser


def main(argv: List[str] = sys.argv[1:]) -> int:
    """Parse and check the command line arguments."""
    sphinx.locale.setlocale(locale.LC_ALL, '')
    sphinx.locale.init_console(os.path.join(package_dir, 'locale'), 'sphinx')

    parser = get_parser()
    args = parser.parse_args(argv)

    rootpath = path.abspath(args.module_path)

    # normalize opts

    if args.header is None:
        args.header = rootpath.split(path.sep)[-1]
    if args.suffix.startswith('.'):
        args.suffix = args.suffix[1:]
    if not path.isdir(rootpath):
        print(__('%s is not a directory.') % rootpath, file=sys.stderr)
        sys.exit(1)
    if not args.dryrun:
        ensuredir(args.destdir)
    excludes = [path.abspath(exclude) for exclude in args.exclude_pattern]
    modules = recurse_tree(rootpath, excludes, args, args.templatedir)

    if args.full:
        from sphinx.cmd import quickstart as qs
        modules.sort()
        prev_module = ''
        text = ''
        for module in modules:
            if module.startswith(prev_module + '.'):
                continue
            prev_module = module
            text += '   %s\n' % module
        d = {
            'path': args.destdir,
            'sep': False,
            'dot': '_',
            'project': args.header,
            'author': args.author or 'Author',
            'version': args.version or '',
            'release': args.release or args.version or '',
            'suffix': '.' + args.suffix,
            'master': 'index',
            'epub': True,
            'extensions': ['sphinx.ext.autodoc', 'sphinx.ext.viewcode',
                           'sphinx.ext.todo'],
            'makefile': True,
            'batchfile': True,
            'make_mode': True,
            'mastertocmaxdepth': args.maxdepth,
            'mastertoctree': text,
            'language': 'en',
            'module_path': rootpath,
            'append_syspath': args.append_syspath,
        }
        if args.extensions:
            d['extensions'].extend(args.extensions)

        for ext in d['extensions'][:]:
            if ',' in ext:
                d['extensions'].remove(ext)
                d['extensions'].extend(ext.split(','))

        if not args.dryrun:
            qs.generate(d, silent=True, overwrite=args.force,
                        templatedir=args.templatedir)
    elif args.tocfile:
        create_modules_toc_file(modules, args, args.tocfile, args.templatedir)

    return 0


# So program can be started with "python -m sphinx.apidoc ..."
if __name__ == "__main__":
    main()
