#! /usr/bin/env python3

"""Extract configuration items into various configuration headers.

This uses the configitems file, a database consisting of text lines with the
following single-tab-separated fields:
 - Name of the configuration item, e.g. PQXX_HAVE_PTRDIFF_T.
 - Publication marker: public or internal.
 - A single environmental factor determining the item, e.g. libpq or compiler.
"""

from __future__ import (
    absolute_import,
    print_function,
    unicode_literals,
    )

from argparse import ArgumentParser
import codecs
from errno import ENOENT
import os.path
from os import getcwd
import re
from sys import (
    getdefaultencoding,
    getfilesystemencoding,
    stdout,
    )

__metaclass__ = type


def guess_fs_encoding():
    """Try to establish the filesystem encoding.

    It's a sad thing: some guesswork is involved.  The encoding often seems to
    be conservatively, and incorrectly, set to ascii.
    """
    candidates = [
        getfilesystemencoding(),
        getdefaultencoding(),
        'utf-8',
        ]
    for encoding in candidates:
        lower = encoding.lower()
        if lower != 'ascii' and lower != 'ansi_x3.4-1968':
            return encoding
    raise AssertionError("unreachable code reached.")


def guess_output_encoding():
    """Return the encoding of standard output."""
    # Apparently builds in Docker containers may have None as an encoding.
    # Fall back to ASCII.  If this ever happens in a non-ASCII path, well,
    # there may be a more difficult decision to be made.  We'll burn that
    # bridge when we get to it, as they almost say.
    return stdout.encoding or 'ascii'


def decode_path(path):
    """Decode a path element from bytes to unicode string."""
    return path.decode(guess_fs_encoding())


def encode_path(path):
    """Encode a path element from unicode string to bytes."""
    # Nasty detail: unicode strings are stored as UTF-16.  Which can contain
    # surrogate pairs.  And those break in encoding, unless you use this
    # special error handler.
    return path.encode(guess_fs_encoding(), 'surrogateescape')


def read_text_file(path, encoding='utf-8'):
    """Read text file, return as string, or `None` if file is not there."""
    assert isinstance(path, type(''))
    try:
        with codecs.open(encode_path(path), encoding=encoding) as stream:
            return stream.read()
    except IOError as error:
        if error.errno == ENOENT:
            return None
        else:
            raise


def read_lines(path, encoding='utf-8'):
    """Read text file, return as list of lines."""
    assert isinstance(path, type(''))
    with codecs.open(encode_path(path), encoding=encoding) as stream:
        return list(stream)


def read_configitems(filename):
    """Read the configuration-items database.

    :param filename: Path to the configitems file.
    :return: Sequence of text lines from configitems file.
    """
    return [line.split() for line in read_lines(filename)]


def map_configitems(items):
    """Map each config item to publication/factor.

    :param items: Sequence of config items: (name, publication, factor).
    :return: Dict mapping each item name to a tuple (publication, factor).
    """
    return {
        item: (publication, factor)
        for item, publication, factor in items
        }


def read_header(source_tree, filename):
    """Read the original config.h generated by autoconf.

    :param source_tree: Path to libpqxx source tree.
    :param filename: Path to the config.h file.
    :return: Sequence of text lines from config.h.
    """
    assert isinstance(source_tree, type(''))
    assert isinstance(filename, type(''))
    return read_lines(os.path.join(source_tree, filename))


def extract_macro_name(config_line):
    """Extract a cpp macro name from a configuration line.

    :param config_line: Text line from config.h which may define a macro.
    :return: Name of macro defined in `config_line` if it is a `#define`
        statement, or None.
    """
    config_line = config_line.strip()
    match = re.match(r'\s*#\s*define\s+([^\s]+)', config_line)
    if match is None:
        return None
    else:
        return match.group(1)


def extract_section(header_lines, items, publication, factor):
    """Extract config items for given publication/factor from header lines.

    :param header_lines: Sequence of header lines from config.h.
    :param items: Dict mapping macro names to (publication, factor).
    :param publication: Extract only macros for this publication tag.
    :param factor: Extract only macros for this environmental factor.
    :return: Sequence of `#define` lines from `header_lines` insofar they
        fall within the requested section.
    """
    return sorted(
        line.strip()
        for line in header_lines
        if items.get(extract_macro_name(line)) == (publication, factor)
        )


def compose_header(lines, publication, factor):
    """Generate header text containing given lines."""
    intro = (
        "/* Automatically generated from config.h: %s/%s config. */"
        % (publication, factor)
        )
    return '\n'.join([intro, ''] + lines + [''])


def generate_config(source_tree, header_lines, items, publication, factor):
    """Generate config file for a given section, if appropriate.

    Writes nothing if the configuration file ends up identical to one that's
    already there.

    :param source_tree: Location of the libpqxx source tree.
    :param header_lines: Sequence of header lines from config.h.
    :param items: Dict mapping macro names to (publication, factor).
    :param publication: Extract only macros for this publication tag.
    :param factor: Extract only macros for this environmental factor.
    """
    assert isinstance(source_tree, type(''))
    config_file = os.path.join(
        source_tree, 'include', 'pqxx',
        'config-%s-%s.h' % (publication, factor))
    unicode_path = config_file.encode(guess_output_encoding(), 'replace')
    section = extract_section(header_lines, items, publication, factor)
    contents = compose_header(section, publication, factor)
    if read_text_file(config_file) == contents:
        print("Generating %s: no changes--skipping." % unicode_path)
        return

    print("Generating %s: %d item(s)." % (unicode_path, len(section)))
    path = encode_path(config_file)
    with codecs.open(path, 'wb', encoding='ascii') as header:
        header.write(contents)


def parse_args():
    """Parse command-line arguments."""
    default_source_tree = os.path.dirname(
        os.path.dirname(os.path.normpath(os.path.abspath(__file__))))
    parser = ArgumentParser(description=__doc__)
    parser.add_argument(
        'sourcetree', metavar='PATH', default=default_source_tree,
        help="Location of libpqxx source tree.  Defaults to '%(default)s'.")
    return parser.parse_args()


def check_args(args):
    """Validate command-line arguments."""
    if not os.path.isdir(args.sourcetree):
        raise Exception("Not a directory: '%s'." % args.sourcetree)


def get_current_dir():
    cwd = getcwd()
    if isinstance(cwd, bytes):
        return decode_path(cwd)
    else:
        return cwd


def main():
    """Main program entry point."""
    args = parse_args()
    check_args(args)
    # The configitems file is under revision control; it's in sourcetree.
    items = read_configitems(os.path.join(args.sourcetree, 'configitems'))
    publications = sorted(set(item[1] for item in items))
    factors = sorted(set(item[2] for item in items))
    # The config.h header is generated; it's in the build tree, which should
    # be where we are.
    directory = get_current_dir()
    original_header = read_header(
        directory,
        os.path.join('include', 'pqxx', 'config.h'))
    items_map = map_configitems(items)

    for publication in publications:
        for factor in factors:
            generate_config(
                directory, original_header, items_map, publication, factor)


if __name__ == '__main__':
    main()
