#!/usr/bin/python
# -*- coding: utf-8 -*-
# vim:fileencoding=utf8
#
# Copyright (C) 2014, Pádraig Brady <P@draigBrady.com>
#
# This program is free software; you can redistribute it and/or modify it
# under the terms of the GPLv2, the GNU General Public License version 2, as
# published by the Free Software Foundation. http://gnu.org/licenses/gpl.html

import os
import sys
import errno
import contextlib
import ConfigParser
import getopt
import iniparse
import pipes
import shutil
import string
import tempfile
from cStringIO import StringIO
import time

# The following exits cleanly on Ctrl-C,
# while treating other exceptions as before.
def cli_exception(type, value, tb):
    if not issubclass(type, KeyboardInterrupt):
        sys.__excepthook__(type, value, tb)
if sys.stdin.isatty():
    sys.excepthook=cli_exception

try:
    iniparse.DEFAULTSECT
except AttributeError:
    iniparse.DEFAULTSECT = 'DEFAULT'

def usage(exitval=0):
    cmd = os.path.basename(sys.argv[0])
    output = sys.stderr if exitval else sys.stdout
    output.write(
      "A utility for manipulating ini files\n"
      "\n" +
      "Usage: " + cmd + " --set [OPTION]...   config_file section   [param] [value]\n" +
      "  or:  " + cmd + " --get [OPTION]...   config_file [section] [param]\n" +
      "  or:  " + cmd + " --del [OPTION]...   config_file section   [param] [value]\n" +
      "  or:  " + cmd + " --merge [OPTION]... config_file [section]\n" +
      "\n"
      "Options:\n"
      "\n"
      "  --existing       For --set, --del and --merge fail if the\n"
      "                     section or param is not present\n"
      "  --format=FMT     For --get, select the output FMT.\n"
      "                     Formats are sh,ini,lines\n"
      "  --inplace        Lock and write files in place.\n"
      "                     This is not atomic but has less restrictions\n"
      "                     than the default replacement method.\n"
      "  --list           For --set and --del, update a list (set) of values\n"
      "  --list-sep=STR   Delimit list values with \"STR\" instead of \" ,\"\n"
      "  --output=FILE    Write output to FILE instead. '-' means stdout\n"
    )
    sys.exit(exitval)

def error(message=None):
    if message:
        sys.stderr.write(message+'\n')

_sh_safe_id_chars = frozenset(string.ascii_letters + string.digits + '_')
def valid_sh_identifier(i):
    if i[0] in string.digits:
        return False
    for c in i:
        if c not in _sh_safe_id_chars:
            return False
    return True

def print_section_header(section):
    if fmt == 'ini':
        print "[%s]" % section
    else:
        print section

def print_name_value(name, value, section=None):
    if fmt == 'lines':
        # Both unambiguous and easily parseable by shell. Caveat is
        # that sections and values with spaces are awkward to split in shell
        if section:
            line = '[ %s ]' % section
            if name:
                line += ' '
        if name:
            line += '%s' % name
        if value:
            line += ' = %s' % value.replace('\n','\\n')
        print line
    elif fmt == 'sh':
        # Note we provide validation of the output indentifiers
        # as it's dangerous to leave validation to shell.
        # consider for example doing eval on this in shell:
        #   rm -Rf /;oops=val
        if not valid_sh_identifier(name):
            error('Inavlid sh identifier: %s' % name)
            sys.exit(1)
        sys.stdout.write("%s=%s\n" % (name, pipes.quote(value)))
    elif fmt == 'ini':
        print name, '=', value.replace('\n','\n ')
    else:
        print name or value

mode = fmt = update = inplace = cfgfile = output = section = param = \
value = vlist = listsep = None

def parse_options():
    try:
        long_options = ['set', 'del', 'get', 'list', 'list-sep=', 'merge',
                        'existing', 'format=', 'output=', 'inplace', 'help', 'version']
        opts, args = getopt.getopt(sys.argv[1:], '', long_options)
    except getopt.GetoptError, e:
        error(str(e))
        usage(1)

    global mode, fmt, update, inplace, cfgfile, output, section, param, \
           value, vlist, listsep

    for o, a in opts:
        if o in ('--help',):
            usage(0)
        elif o in ('--version',):
            print 'crudini 0.4'
            sys.exit(0)
        elif o in ('--set', '--del', '--get', '--merge'):
            if mode:
                error('Only one of --set|--del|--get|--merge can be specified')
                usage(1)
            mode = o
        elif o in ('--format',):
            fmt = a
            if fmt not in ('sh','ini','lines'):
                error('--format not recognized: %s' % fmt)
                usage(1)
        elif o in ('--existing',):
            update = True
        elif o in ('--inplace',):
            inplace = True
        elif o in ('--list',):
            vlist = "set" #TODO support combos of list, sorted, ...
        elif o in ('--list-sep',):
            listsep = a
        elif o in ('--output',):
            output = a

    if not mode:
        error('One of --set|--del|--get|--merge must be specified')
        usage(1)

    try:
        cfgfile = args[0]
        section = args[1]
        param = args[2]
        value = args[3]
    except IndexError:
        pass

    if not output:
        output = cfgfile

    if cfgfile is None:
        usage(1)
    if section is None and mode in ('--del', '--set'):
        usage(1)
    if param is not None and mode in ('--merge',):
        usage(1)
    if value is not None and mode not in ('--set',):
        if not (mode == '--del' and vlist):
            error('A value should not be specified with %s' % mode)
            usage(1)

    if mode == '--merge' and fmt == 'sh':
        # I'm not sure how useful is is to support this.
        # printenv will already generate a mostly compat ini format.
        # If you want to also include non exported vars (from `set`),
        # then there is a format change.
        error('sh format input is not supported at present')
        sys.exit(1)

parse_options()

section_explicit_default = False
if section == '':
    section = iniparse.DEFAULTSECT
elif section == iniparse.DEFAULTSECT:
    section_explicit_default = True

# XXX: should be done in iniparse.  Used to
# add support for ini files without a section
class add_default_section():
    def __init__(self, fp):
        self.fp = fp
        self.first = True

    def readline(self):
        if self.first:
            self.first = False
            return '[%s]' % iniparse.DEFAULTSECT
        else:
            return self.fp.readline()

    def seek(self, *args):
        self.fp.seek(*args)

stdin = ""

def has_default_section(locked_file):
    try:
        if locked_file is None:
            fp = StringIO(stdin)
        else:
            fp = locked_file.fp

        for line in fp:
            if line.startswith('[%s]' % iniparse.DEFAULTSECT):
                return True

        return False

    except IOError as e:
        error(str(e))
        sys.exit(1)
    finally:
        fp.seek(0)

def delete_if_exists(path):
    """Delete a file, but ignore file not found error.
    """
    try:
        os.unlink(path)
    except EnvironmentError as e:
        if e.errno != errno.ENOENT:
            print str(e)
            raise

class FileLock(object):
    """Advisory file based locking.  This should be reasonably cross platform
       and also work over distributed file systems."""
    def __init__(self, exclusive=False, separated=False):
        # In inplace mode, the process must be careful to not close this fp
        # until finished, nor open and close another fp associated with the file.
        self.fp = None
        self.locked = False
        self.die = delete_if_exists # reference so available at teardown

        # Note we can't combine these methods to provide separated locks
        # which are immune to stale file deadlock, as once the separated
        # file is unlinked or renamed, you introduce a race with 3 or more users
        # if there is an associated fcntl lock.

        if separated:
            import signal
            def cleanup(signum, frame):
                sys.exit(1)
            if hasattr(signal, "SIGTERM"):
                signal.signal(signal.SIGTERM, cleanup)

            def lock(self):
                while True:
                    try:
                        os.open(self.lockpath, os.O_EXCL | os.O_CREAT, 0)
                    except EnvironmentError as e:
                        if e.errno == errno.EEXIST:
                            time.sleep(1)
                        else:
                            raise
                    else:
                        self.locked = True
                        break

            def unlock(self):
                if self.locked: # Don't clobber other locks on ctrl-c etc.
                    self.die (self.lockpath)
                self.locked = False

        elif os.name == 'nt':
            import msvcrt
            def lock(self):
                msvcrt.locking(self.fp, msvcrt.LK_LOCK, 1)
                self.locked = True

            def unlock(self):
                if self.locked:
                    msvcrt.locking(self.fp, msvcrt.LK_UNLCK, 1)
                self.locked = False

        else:
            import fcntl
            def lock(self):
                fcntl.lockf(self.fp, fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH)
                self.locked = True

            def unlock(self):
                if self.locked:
                    fcntl.lockf(self.fp, fcntl.LOCK_UN)
                self.locked = False

        FileLock.lock = lock
        FileLock.unlock = unlock


class LockedFile(FileLock):
    """Open a file with advisory locking.  This provides the Isolation
       property of ACID, to avoid missing writes.  In addition this provides AC
       properties of ACID if crudini is the only logic accessing the ini file.
       This should work on most platforms and distributed file systems.

       Caveats in --inplace mode:
        - File must be writeable
        - File should be generally non readable to avoid read lock DoS.
       Caveats in replace mode:
        - Possibility of stale lock files left on crash leading to deadlock.
        - Less responsive when there is contention."""

    def __init__(self, filename, operation, inplace):

        self.filename = filename
        self.operation = operation

        FileLock.__init__(self, operation != "--get", not inplace)

        if inplace:
            self.lockpath = filename
        else:
            self.lockpath = os.path.join(os.path.dirname(filename),
                                         '.' + os.path.basename(filename) + '.crudini.lck')

        if inplace:
            open_mode = 'r'
            if operation != "--get":
                open_mode += '+'

        try:
            if inplace:
                self.fp = open(self.lockpath, open_mode)
                # In general readers are protected by file_replace()
                # but using read lock here gives AC of the ACID propserties
                # when only accessing the file through crudini even with file_rewrite().
                self.lock()
            else:
                self.lock()
                self.fp = open(self.filename)
        except EnvironmentError as e:
            error(str(e))
            sys.exit(1)

    def __del__(self):
        # explicit close so closed in correct order
        # if taking lock multiple times
        self.unlock()
        if self.fp:
            self.fp.close()


locked_file = None

# Note we use RawConfigParser rather than SafeConfigParser
# to avoid unwanted variable interpolation.
# Note iniparse doesn't currently support allow_no_value=True.
class CrudiniConfigParser(iniparse.RawConfigParser):
    def __init__(self, preserve_case=False):
        iniparse.RawConfigParser.__init__(self)
        if preserve_case:
            self.optionxform = str

def _parse_file(filename, add_default=False, preserve_case=False):
    try:
        if filename == '-':
            fp = StringIO(stdin)
        else:
            global locked_file
            fp = locked_file.fp
        if add_default:
            fp = add_default_section(fp)
        conf = CrudiniConfigParser(preserve_case=preserve_case)
        conf.readfp(fp)
        return conf
    except EnvironmentError as e:
        error(str(e))
        sys.exit(1)
    finally:
        fp.seek(0) # in case we need to reparse


def parse_file(filename, preserve_case=False):
    global added_default_section
    added_default_section = False

    global locked_file
    if filename != '-':
        locked_file = LockedFile (filename, mode, inplace)

    try:
        conf = _parse_file(filename, preserve_case=preserve_case)

        if not conf.items(iniparse.DEFAULTSECT):
            # Check if there is just [DEFAULT] in a file with no
            # name=values to avoid adding a duplicate section.
            if not has_default_section(locked_file):
                # reparse with inserted [DEFAULT] to be able to add global opts etc.
                conf = _parse_file(filename, add_default=True,
                                   preserve_case=preserve_case)
                added_default_section = True

    except ConfigParser.MissingSectionHeaderError:
        conf = _parse_file(filename, add_default=True, preserve_case=preserve_case)
        added_default_section = True
    except ConfigParser.ParsingError as e:
        error(str(e))
        sys.exit(1)

    return conf

added_default_section = False

if mode == '--merge':
    stdin = sys.stdin.read() # read all upfront so that we can reparse if needed
    mconf = parse_file('-', preserve_case=True)

madded_default_section = added_default_section
conf = parse_file(cfgfile)
# Take the [DEFAULT] header from the input if present
if mode == '--merge' and not update \
   and not madded_default_section and mconf.items(iniparse.DEFAULTSECT):
    added_default_section = madded_default_section

# TODO item should be items and split also
# especially in merge mode
def update_list(curr_val, item, mode, sep):
    curr_items = []
    use_space = True
    if curr_val:
        if sep is None:
            use_space = ' ' in curr_val or ',' not in curr_val
            curr_items = [v.strip() for v in curr_val.split(",")]
        else:
            curr_items = curr_val.split(sep)

    if mode == "--set":
        if item not in curr_items:
            curr_items.append(item)
    elif mode == "--del":
        try:
            curr_items.remove(item)
        except ValueError:
            pass

    if sep is None:
        sep = ","
        if use_space:
            sep += " "

    return sep.join(curr_items)

def set_name_value(section, param, value):
    curr_val = None

    if update:
        if param is None:
            _sec = section == iniparse.DEFAULTSECT or conf.has_section(section)
            if not _sec:
                raise ConfigParser.NoSectionError(section)
        else:
            curr_val = conf.get(section, param)
    elif section != iniparse.DEFAULTSECT and not conf.has_section(section):
        if mode == "--del":
            raise ConfigParser.NoSectionError(section)
        else:
            conf.add_section(section)

    if param is not None:
        if not update:
            try:
                curr_val = conf.get(section, param)
            except ConfigParser.NoOptionError:
                if mode == "--del":
                    return
        if value is None:
            value = ''
        if vlist:
            value = update_list(curr_val, value, mode, listsep)
        conf.set(section, param, value)

try:
    if mode == '--set':
        set_name_value(section, param, value)
    elif mode == '--merge':
        for msection in [iniparse.DEFAULTSECT] + mconf.sections():
            if msection == iniparse.DEFAULTSECT:
                defaults_to_strip = {}
            else:
                defaults_to_strip = mconf.defaults()
            items = mconf.items(msection)
            set_param = False
            for item in items:
                # XXX: Note this doesn't update an item in section
                # if matching value also in default (global) section.
                if defaults_to_strip.get(item[0]) != item[1]:
                    ignore_errs = (ConfigParser.NoOptionError,)
                    if section is not None:
                        msection = section
                    elif not update:
                        ignore_errs += (ConfigParser.NoSectionError,)
                    try:
                        set_param = True
                        set_name_value(msection, item[0], item[1])
                    except ignore_errs:
                        pass
            # For empty sections ensure the section header is added
            if not set_param and section is None:
                set_name_value(msection, None, None)
    elif mode == '--del':
        if param is None:
            if section == iniparse.DEFAULTSECT:
                for name in conf.defaults():
                    conf.remove_option(iniparse.DEFAULTSECT, name)
            else:
                if not conf.remove_section(section) and update:
                    raise ConfigParser.NoSectionError(section)
        elif value is None:
            if not conf.remove_option(section, param) and update:
                raise ConfigParser.NoOptionError(section, param)
        else: # remove item from list
            set_name_value(section, param, value)
    elif mode == '--get' and fmt != 'lines':
        if section is None:
            if conf.defaults():
                print_section_header(iniparse.DEFAULTSECT)
            for item in conf.sections():
                print_section_header(item)
        elif param is None:
            if fmt == 'ini':
                print_section_header(section)
            if section == iniparse.DEFAULTSECT:
                defaults_to_strip = {}
            else:
                defaults_to_strip = conf.defaults()
            for item in conf.items(section):
                # XXX: Note this strips an item from section
                # if matching value also in default (global) section.
                if defaults_to_strip.get(item[0]) != item[1]:
                    if fmt:
                        val = item[1]
                    else:
                        val = None
                    print_name_value(item[0], val)
        else:
            val = conf.get(section, param)
            if fmt:
                name = param
            else:
                name = None
            print_name_value(name, val)
    elif mode == '--get' and fmt == 'lines':
        if section is None:
            sections = conf.sections()
            if conf.defaults():
                sections.insert(0, iniparse.DEFAULTSECT)
        else:
            sections = (section,)
        if param is not None:
            val = conf.get(section, param)
            print_name_value(param, val, section)
        else:
            for section in sections:
                if section == iniparse.DEFAULTSECT:
                    defaults_to_strip = {}
                else:
                    defaults_to_strip = conf.defaults()
                items = False
                for item in conf.items(section):
                    # XXX: Note this strips an item from section
                    # if matching value also in default (global) section.
                    if defaults_to_strip.get(item[0]) != item[1]:
                        val = item[1]
                        print_name_value(item[0], val, section)
                        items = True
                if not items:
                    print_name_value(None, None, section)

except ConfigParser.NoSectionError as e:
    error('Section not found: %s' % e.section)
    sys.exit(1)
except ConfigParser.NoOptionError:
    error('Parameter not found: %s' % param)
    sys.exit(1)

@contextlib.contextmanager
def remove_file_on_error(path):
    """Protect code that wants to operate on PATH atomically.
    Any exception will cause PATH to be removed.
    """
    try:
        yield
    except Exception:
        t, v, tb = sys.exc_info()
        delete_if_exists(path)
        raise t, v, tb

def file_replace(name, data):
    """Replace file as atomically as possible,
    fulfilling and AC properties of ACID.
    This is essentially using method 9 from:
    http://www.pixelbeat.org/docs/unix_file_replacement.html

    Caveats:
     - Changes ownership of the file being edited
       by non root users (due to POSIX interface limitations).
     - Loses any extended attributes of the original file
       (due to the simplicity of this implementation).
     - Existing hardlinks will be separated from the
       newly replaced file.
     - Ignores the write permissions of the original file.
     - Requires write permission on the directory as well as the file.
     - With python2 on windows we don't fulfill the A ACID property.

    To avoid the above caveats see the --inplace option.
    """
    (f, tmp) = tempfile.mkstemp(".tmp", prefix=name+".", dir=".")

    with remove_file_on_error(tmp):
        shutil.copystat(name, tmp)

        if hasattr(os, 'fchown') and os.geteuid() == 0:
            st = os.stat(name)
            os.fchown(f, st.st_uid, st.st_gid)

        os.write(f, data)
        os.close(f)

        if hasattr(os,'replace'): # >= python 3.3
            os.replace(tmp, name) # atomic even on windos
        elif os.name == 'posix':
            os.rename(tmp, name) # atomic on POSIX
        else:
            backup = tmp+'.backup'
            os.rename(name, backup)
            os.rename(tmp, name)
            delete_if_exists(backup)

def file_rewrite(name, data):
    """Rewrite file inplace avoiding the caveats
    noted in file_replace().

    Caveats:
     - Not Atomic as readers may see incomplete data for a while.
     - Not Consistent as multiple writers may overlap.
     - Less Durable as exisiting data truncated before I/O completes.
     - Requires write access to file rather than write access to dir.
    """
    with open(name, 'w') as f:
        f.write(data)

if mode != '--get':
    # XXX: Ideally we should just do conf.write(f) here,
    # but to avoid iniparse issues, we massage the data a little here
    str_data = str(conf.data)
    if len(str_data) and str_data[-1] != '\n':
        str_data += '\n'

    if (
        (added_default_section and not (section_explicit_default and mode in ('--set', '--merge')))
        or (mode == '--del' and section == iniparse.DEFAULTSECT and param is None)
        ):
        str_data = str_data.replace('[%s]\n' % iniparse.DEFAULTSECT, '', 1)

    try:
        if output == '-':
            sys.stdout.write(str_data)
        else:
            file_edit = file_rewrite if inplace else file_replace
            file_edit(output, str_data)
    except EnvironmentError as e:
        error(str(e))
        sys.exit(1)
