#!/usr/bin/python
"""
SYNOPSIS
        ./yarn2spec

DESCRIPTION
        Produces a complete RPM spec file that will build binary packages for
        any nodejs modules that are new or updated since the last checkpointed
        build.

EXAMPLE
        # Update nodejs packages
        yarn upgrade --flat  # or yarn add --flat newdep
        git add -A
        git commit -m 'new stuff'
        exagrid/bin/snyk test

        # Build RPM packages and update checkpoint files
        vim exagrid/template.spec  # bump release number
        git add exagrid/template.spec
        git commit -m 'Release 123'
        eg-buildpkg  # runs yarn2spec in pre hook, updates exagrid/previous.* in post hook

        # Publish RPM packages
        eg-uploadpkg

        # Commit updated checkpoint
        git diff  # should show new state in exagrid/previous.*
        git add -A
        git commit -m 'update published package state'
        git push
"""
import glob
import json
import logging
import operator
import os
import os.path
import re
import string
import sys

# Temp constants
bin_pkg_prefix = 'libnodejs-'
spec_input = 'exagrid/template.spec'
spec_output = 'exagrid/spec'
yarn_state = 'yarn.lock'
prev_yarn_state = 'exagrid/previous.yarn.lock'
prev_pkg_list = 'exagrid/previous.package-list'

# Constants
install_header = """\
mkdir -p "%{buildroot}/%{_prefix}/lib/node"
mkdir -p "%{buildroot}/%{_prefix}/lib/node_modules"
mkdir -p "%{buildroot}/%{_prefix}/share/doc/%{name}"
install -m 644 bundle-disclaimer.txt "%{buildroot}/%{_prefix}/share/doc/%{name}/"
"""
install_extra_dir_template = string.Template("""\
mkdir -p "%{buildroot}/%{_prefix}/lib/node/${dir_name}"
mkdir -p "%{buildroot}/%{_prefix}/lib/node_modules/${dir_name}"
""")
install_template = string.Template("""\
cp -al "node_modules/${name}" "%{buildroot}/%{_prefix}/lib/node/${name}"
ln -s "%{_prefix}/lib/node/${name}" "%{buildroot}/%{_prefix}/lib/node_modules/${name}"
""")
install_bin_template = string.Template("""\
mkdir -p "%{buildroot}%{_prefix}/bin"
ln -s "%{_prefix}/lib/node/${lib_path}" "%{buildroot}%{_prefix}/bin/${bin_name}"
""")
binary_pkg_template = string.Template("""
#-----------------------------------------------------
%package -n ${name_prefix}${rpm_name}
Summary: ${description}
Version: ${version}
Release: %{release}
${dependency_list}

%description -n ${name_prefix}${rpm_name}
${description}

%files -n ${name_prefix}${rpm_name}
%{_prefix}/lib/node/${name}
%{_prefix}/lib/node_modules/${name}

""")
dependency_template = string.Template("Requires: ${name_prefix}${rpm_name}")
binary_pkg_bin_template = string.Template("""
#-----------------------------------------------------
%package -n ${name_prefix}${rpm_name}-bin
Summary: ${name_prefix}${rpm_name} executable commands
Version: ${version}
Release: %{release}
Requires: nodejs
Requires: ${name_prefix}${rpm_name}

%description -n ${name_prefix}${rpm_name}-bin
${description}

This package contains the user-executable commands associated with this module:
${files_list}

%files -n ${name_prefix}${rpm_name}-bin
${files_list}

""")
bin_file_template = string.Template("%{_prefix}/bin/${bin_name}")
doc_pkg_section = """
#-----------------------------------------------------
%package doc
Summary: bundle docs (including license bundle)

%description doc
This package contains the license bundle for all JS components that are part of
this source package.

%files doc
%{_prefix}/share/doc/%{name}/bundle-disclaimer.txt

"""


def to_rpm_name(name):
    """
    Turn things like "@allenfang/react-toastr" into a valid RPM package name.
    """
    return  re.sub('^-', '', re.sub(r'--+', '-', re.sub(r'[^a-zA-Z0-9_.-]', '-', name)))


def read_yarn_db(path):
    """
    Parse the yarn.lock file and return a dict of packages and some minimal metadata.

    Example input:

    "@allenfang/react-toastr@2.8.2":
    version "2.8.2"
    resolved "https://registry.yarnpkg.com/@allenfang/react-toastr/-/react-toastr-2.8.2.tgz#0bef6585189e0571dd6bdfc4ef98bc9f9c47da0c"
    dependencies:
        classnames "^2.2.5"
        element-class "^0.2.2"
        lodash "^4.16.1"
        react "^0.14.0 || <15.4.0"
        react-addons-update "^0.14.0 || <15.4.0"
        react-dom "^0.14.0 || <15.4.0"

    abbrev@1, abbrev@^1.0.7:
    version "1.1.0"
    resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.0.tgz#d0554c2256636e2f56e7c2e5ad183f859428d81f"

    acorn-dynamic-import@^2.0.0:
    version "2.0.2"
    resolved "https://registry.yarnpkg.com/acorn-dynamic-import/-/acorn-dynamic-import-2.0.2.tgz#c752bd210bef679501b6c6cb7fc84f8f47158cc4"
    dependencies:
        acorn "^4.0.3"

    acorn@^4.0.3, acorn@^5.0.0:
    version "5.0.3"
    resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.0.3.tgz#c460df08491463f028ccb82eab3730bf01087b3d"

    "mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.0:
    version "0.5.1"
    resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903"
    dependencies:
        minimist "0.0.8"
    """
    heading_re = re.compile(r'"?(\S+?)@.*:')
    version_re = re.compile(r'  version "(.*)"')
    dep_sec_re = re.compile(r'  dependencies:')
    dep_info_re = re.compile(r'    "?(\S+?)"?(\s|$)')
    db = {}
    cur_pkg = {}
    in_deps_section = False
    with open(path) as yarnfile:
        for line in yarnfile:
            match = heading_re.match(line)
            if match:
                # new pkg
                if len(cur_pkg.keys()) > 0:
                    db[cur_pkg['name']] = cur_pkg
                    cur_pkg = {}
                cur_pkg['name'] = match.group(1)
                cur_pkg['rpm_name'] = to_rpm_name(match.group(1))
                continue
            match = version_re.match(line)
            if match:
                cur_pkg['version'] = match.group(1)
                continue
            if dep_sec_re.match(line):
                in_deps_section = True
                continue
            if in_deps_section:
                match = dep_info_re.match(line)
                if match:
                    if 'dependencies' not in cur_pkg:
                        cur_pkg['dependencies'] = []
                    cur_pkg['dependencies'].append(to_rpm_name(match.group(1)))
                else:
                    in_deps_section = False
    db[cur_pkg['name']] = cur_pkg
    return db


def add_pkg_description_to(pkg):
    metapath = 'node_modules/{0}/package.json'.format(pkg['name'])
    with open(metapath) as metafh:
        meta = json.load(metafh)
        try:
            pkg['description'] = meta['description'].encode('ascii', 'ignore')
            if pkg['description'] == "":
                raise Exception()
        except:
            pkg['description'] = 'a nodejs package with no description'


def read_bin_pkgs(bindir):
    """
    Analyze the contents of node_modules/.bin to determine what executable
    packages are indicated. The convention is that all such exes are symlinks
    and the first component of the symlink is the name of the package that
    contains the exe.

    node_modules/.bin/sha.js -> ../sha.js/bin.js
    """
    db = {}
    for exe in glob.glob(bindir + '/*'):
        rec = {}
        tgt = os.readlink(exe)
        logging.debug("Found exe %s -> %s", exe, tgt)
        rec['bin_name'] = exe.split('/')[-1]
        assert tgt[0:3] == '../'
        rec['lib_path'] = tgt[3:]
        module = rec['lib_path'].split('/')[0]
        if module not in db:
            db[module] = []
        db[module].append(rec)
    return db

def main():
    logging.basicConfig(level=logging.DEBUG)

    spec_output_tmp = spec_output + '.new'
    with open(spec_output_tmp, 'w') as spec:

        spec.write("# THIS FILE WAS GENERATED FROM THE FOLLOWING INPUTS:\n")
        spec.write("#\n")
        for f in (spec_input, yarn_state, prev_yarn_state):
            if os.path.exists(f):
                spec.write("#   ")
                spec.write(f)
                spec.write("\n")
        spec.write("#\n")
        spec.write("# DO NOT EDIT.\n")
        spec.write("#\n")

        with open(spec_input) as hdr:
            for line in hdr:
                spec.write(line)

        ignored = []
        db = read_yarn_db(yarn_state)
        for p in db.values():
            try:
                add_pkg_description_to(p)
            except IOError as e:
                logging.warning("Ignoring %s because of error: %s", p['name'], e)
                ignored.append(p['name'])
        for n in ignored:
            del db[n]

        if os.path.exists(prev_yarn_state):
            prev_db = read_yarn_db(prev_yarn_state)
        else:
            logging.info("No prior yarn state found at '%s'", prev_yarn_state)
            prev_db = {}

        bin_pkgs = read_bin_pkgs('node_modules/.bin')

        # Get sorted list of packages that are new or changed in current yarn.lock
        sorted_items = sorted([x for x in db.items()
                               if x[0] not in prev_db or x[1]['version'] != prev_db[x[0]]['version']
                              ], key=operator.itemgetter(0))

        # Write install section (assume template has the %install line)
        spec.write(install_header)
        for (n, p) in sorted_items:
            if '/' in n:
                spec.write(install_extra_dir_template.substitute(dir_name=n.split('/')[0]))
            spec.write(install_template.substitute(p))
            logging.debug("Wrote install line for %s", n)
            if n in bin_pkgs:
                for exe in bin_pkgs[n]:
                    spec.write(install_bin_template.substitute(exe))
                    logging.debug("Wrote install line for %s (bin)", n)

        # Write each binary package section
        for (n, p) in sorted_items:
            try:
                if 'dependencies' in p:
                    p['dependency_list'] = '\n'.join(map(lambda d:
                        dependency_template.substitute(rpm_name=d, name_prefix=bin_pkg_prefix), p['dependencies']))
                else:
                    p['dependency_list'] = '# No dependencies'
                spec.write(binary_pkg_template.substitute(p, name_prefix=bin_pkg_prefix))
                logging.debug("Wrote package section for %s", n)

                if n in bin_pkgs:
                    p['files_list'] = "\n".join([bin_file_template.substitute(exe) for exe in bin_pkgs[n]])
                    spec.write(binary_pkg_bin_template.substitute(p, name_prefix=bin_pkg_prefix))
                    logging.debug("Wrote package section for %s (bin)", n)

            except Exception as e:
                logging.error("Problem with %s: %s", n, e)
                raise

        # Write section for -doc binary package
        spec.write(doc_pkg_section)

    os.rename(spec_output_tmp, spec_output)
    logging.info("Wrote %s", spec_output)


if __name__ == "__main__":
    main()
