from codecs import getwriter
from collections import defaultdict
from errno import ENOENT
from functools import wraps
from json import load, dump
import os
from os.path import abspath, relpath, splitext, sep
import subprocess
from tempfile import TemporaryFile, NamedTemporaryFile

from six import string_types
from sphinx.errors import SphinxError

from .parsers import path_and_formal_params, PathVisitor
from .suffix_tree import PathTaken, SuffixTree
from .typedoc import parse_typedoc


def gather_doclets(app):
    """Run JSDoc or another analysis tool across a whole codebase, and squirrel
    away its results in jsdoc doclet format."""
    source_paths = [app.config.js_source_path] if isinstance(app.config.js_source_path, string_types) else app.config.js_source_path
    # Uses cwd, which Sphinx seems to set to the dir containing conf.py:
    abs_source_paths = [abspath(path) for path in source_paths]

    root_for_relative_paths = root_or_fallback(app.config.root_for_relative_js_paths,
                                               abs_source_paths)

    analyze = analyzer_for(app.config.js_language)
    doclets = analyze(abs_source_paths, app)
    # 2 doclets are made for classes, and they are largely redundant: one for
    # the class itself and another for the constructor. However, the
    # constructor one gets merged into the class one and is intentionally
    # marked as undocumented, even if it isn't. See
    # https://github.com/jsdoc3/jsdoc/issues/1129.
    doclets = [d for d in doclets if d.get('comment')
                                     and not d.get('undocumented')]

    # Build table for lookup by name, which most directives use:
    app._sphinxjs_doclets_by_path = SuffixTree()
    conflicts = []
    for d in doclets:
        try:
            app._sphinxjs_doclets_by_path.add(
                doclet_full_path(d, root_for_relative_paths),
                d)
        except PathTaken as conflict:
            conflicts.append(conflict.segments)
    if conflicts:
        raise PathsTaken(conflicts)

    # Build lookup table for autoclass's :members: option. This will also
    # pick up members of functions (inner variables), but it will instantly
    # filter almost all of them back out again because they're undocumented.
    # We index these by unambiguous full path. Then, when looking them up by
    # arbitrary name segment, we disambiguate that first by running it through
    # the suffix tree above. Expect trouble due to jsdoc's habit of calling
    # things (like ES6 class methods) "<anonymous>" in the memberof field, even
    # though they have names. This will lead to multiple methods having each
    # other's members. But if you don't have same-named inner functions or
    # inner variables that are documented, you shouldn't have trouble.
    app._sphinxjs_doclets_by_class = defaultdict(lambda: [])
    for d in doclets:
        of = d.get('memberof')
        if of:  # speed optimization
            segments = doclet_full_path(d, root_for_relative_paths, longname_field='memberof')
            app._sphinxjs_doclets_by_class[tuple(segments)].append(d)


def program_name_on_this_platform(program):
    """Return the name of the executable file on the current platform, given a
    command name with no extension."""
    return program + '.cmd' if os.name == 'nt' else program


class Command(object):
    def __init__(self, program):
        self.program = program_name_on_this_platform(program)
        self.args = []

    def add(self, *args):
        self.args.extend(args)

    def make(self):
        return [self.program] + self.args


def cache_to_file(get_filename):
    """Return a decorator that will cache the result of ``get_filename()`` to a
    file

    :arg get_filename: A function which receives the original arguments of the
        decorated function
    """
    def decorator(fn):
        @wraps(fn)
        def decorated(*args, **kwargs):
            filename = get_filename(*args, **kwargs)
            if filename and os.path.isfile(filename):
                with open(filename) as f:
                    return load(f)
            res = fn(*args, **kwargs)
            if filename:
                with open(filename, 'w') as f:
                    dump(res, f, indent=2)
            return res
        return decorated
    return decorator


@cache_to_file(lambda abs_source_paths, app: getattr(app.config, 'jsdoc_cache', None))
def analyze_jsdoc(abs_source_paths, app):
    command = Command('jsdoc')
    command.add('-X', *abs_source_paths)
    if app.config.jsdoc_config_path:
        command.add('-c', app.config.jsdoc_config_path)

    # Use a temporary file to handle large output volume. JSDoc defaults to
    # utf8-encoded output.
    with getwriter('utf-8')(TemporaryFile(mode='w+b')) as temp:
        try:
            p = subprocess.Popen(command.make(), cwd=app.confdir, stdout=temp)
        except OSError as exc:
            if exc.errno == ENOENT:
                raise SphinxError('%s was not found. Install it using "npm install -g jsdoc".' % command.program)
            else:
                raise
        p.wait()
        # Once output is finished, move back to beginning of file and load it:
        temp.seek(0)
        try:
            return load(temp)
        except ValueError:
            raise SphinxError('jsdoc found no JS files in the directories %s. Make sure js_source_path is set correctly in conf.py. It is also possible (though unlikely) that jsdoc emitted invalid JSON.' % abs_source_paths)


def analyze_typescript(abs_source_paths, app):
    command = Command('typedoc')
    if app.config.jsdoc_config_path:
        command.add('--tsconfig', app.config.jsdoc_config_path)

    with getwriter('utf-8')(NamedTemporaryFile(mode='w+b')) as temp:
        command.add('--json', temp.name, *abs_source_paths)
        try:
            subprocess.call(command.make())
        except OSError as exc:
            if exc.errno == ENOENT:
                raise SphinxError('%s was not found. Install it using "npm install -g typedoc".' % command.program)
            else:
                raise
        # typedoc emits a valid JSON file even if it finds no TS files in the dir:
        return parse_typedoc(temp)


ANALYZERS = {'javascript': analyze_jsdoc,
             'typescript': analyze_typescript}


def analyzer_for(language):
    """Return a callable that spits out JSDoc-style doclets from some language:
    JS, TypeScript, or other."""
    try:
        return ANALYZERS[language]
    except KeyError:
        raise SphinxError('Unsupported value of js_language in config: %s' % language)


def root_or_fallback(root_for_relative_paths, abs_source_paths):
    """Return the path that relative JS entity paths in the docs are relative to.

    Fall back to the sole JS source path if the setting is unspecified.

    :arg root_for_relative_paths: The raw root_for_relative_js_paths setting.
        None if the user hasn't specified it.
    :arg abs_source_paths: Absolute paths of dirs to scan for JS code

    """
    if root_for_relative_paths:
        return abspath(root_for_relative_paths)
    else:
        if len(abs_source_paths) > 1:
            raise SphinxError('Since more than one js_source_path is specified in conf.py, root_for_relative_js_paths must also be specified. This allows paths beginning with ./ or ../ to be unambiguous.')
        else:
            return abs_source_paths[0]


def doclet_full_path(d, base_dir, longname_field='longname'):
    """Return the full, unambiguous list of path segments that points to an
    entity described by a doclet.

    Example: ``['./', 'dir/', 'dir/', 'file/', 'object.', 'object#', 'object']``

    :arg d: The doclet
    :arg base_dir: Absolutized value of the jsdoc_source_path option
    :arg longname_field: The field to look in at the top level of the doclet
        for the long name of the object to emit a path to
    """
    meta = d['meta']
    rel = relpath(meta['path'], base_dir)
    rel = '/'.join(rel.split(sep))
    if not rel.startswith(('../', './')) and rel not in ('..', '.'):
        # It just starts right out with the name of a folder in the cwd.
        rooted_rel = './%s' % rel
    else:
        rooted_rel = rel

    # Building up a string and then parsing it back down again is probably
    # not the fastest approach, but it means knowledge of path format is in
    # one place: the parser.
    path = '%s/%s.%s' % (rooted_rel,
                         splitext(meta['filename'])[0],
                         d[longname_field])
    return PathVisitor().visit(
        path_and_formal_params['path'].parse(path))


class PathsTaken(Exception):
    """One or more JS objects had the same paths.

    Rolls up multiple PathTaken exceptions for mass reporting.

    """
    def __init__(self, conflicts):
        # List of paths, each given as a list of segments:
        self.conflicts = conflicts

    def __str__(self):
        return ('Your JS code contains multiple documented objects at each of '
                "these paths:\n\n  %s\n\nWe won't know which one you're "
                'talking about. Using JSDoc tags like @class might help you '
                'differentiate them.' %
                '\n  '.join(''.join(c) for c in self.conflicts))