#!/usr/bin/python3
# "PlacehoLder Un- and Generator"

import sys
import re
from enum import Enum, auto

C = {} # ANSI colors

def has_color():
    import os
    return "color" in os.environ.get('TERM')

def def_colors():
    global C
    C = {
        'b': '\033[34m',
        'y': '\033[33m',
        'g': '\033[32m',
        'r': '\033[31m',
        'm': '\033[35m',
        'B': '\033[1m',
        'R': '\033[7m',
        'n': '\033[0m',
    }

def undef_colors():
    global C
    C = {
        'b': '',
        'y': '',
        'g': '',
        'r': '',
        'B': '',
        'n': '',
        'R': '',
        'm': '',
    }
undef_colors()

class Error(Enum):
    PARAM_MISS = auto()
    UNK_FLAG   = auto()
    IO         = auto()
    UNK_OPT    = auto()
    UNTERM_DEF = auto()
    NO_FILES   = auto()
    MULTI_OP   = auto()
    NO_OP      = auto()

def usage():
    print(
'''{C[g]}{C[B]}{argv0}{C[n]} {C[B]}{C[y]}({C[b]}-g{C[y]}|{C[b]}-u{C[y]}) {C[B]}{C[y]}({C[b]}<option>{C[y]}|{C[b]}<file>{C[y]})+{C[n]}
  {C[y]}Options:{C[n]}
    {C[g]}{C[B]}-h{C[n]} {C[b]}{C[B]}{C[n]}                       : print help and exit
    {C[g]}{C[B]}--color{C[n]} {C[B]}{C[b]}never{C[y]}|{C[b]}auto{C[y]}|{C[b]}always{C[n]} : set output coloring option; default: auto
    {C[g]}{C[B]}-d{C[n]} {C[b]}{C[B]}<name> <value>{C[n]}         : define placeholder on the cli
    {C[g]}{C[B]}-e{C[n]} {C[b]}{C[B]}<name> <file>{C[n]}          : define placeholder as the contents of <file>
    {C[g]}{C[B]}-a{C[n]}                        : define all undefined placeholders as empty strings
    {C[g]}{C[B]}-u{C[n]}                        : ungenerate placeholders (ie. collapse)
    {C[g]}{C[B]}-g{C[n]}                        : generate placeholders (ie. expand)
  Every argument not starting with '-' is considered a file.
  If multiple files are specified, actions apply to all of them.
  Undefine placeholders are left intact.

  {C[y]}{C[B]}Placeholder syntax:{C[n]}
    {C[y]}#placeholder<{C[n]}<name>{C[y]}> COLLAPSED{C[n]}
    {C[m]}{C[R]}NOTE:{C[n]} text located on the same line and before the placeholder is preserved.
     This allows you to comment it out while embedding.
    {C[y]}Builtins:{C[n]}
      Builtin placeholder names start with '@', these names are reserved.
      This Plug version defines the following builtins:
        {C[b]}@gnu-tofile-*{C[n]}

  {C[y]}{C[B]}Example:{C[n]}
    {C[g]}$ cat example/ex1.txt{C[n]}
    original text
    #placeholder<hw> COLLAPSED
    some more original text
    {C[g]}$ plug -g -d hw 'hello world' example/ex1.txt{C[n]}
    {C[g]}$ cat example/ex1.txt{C[n]}
    original text
    #placeholder<hw> BEGIN
    hello world
    #placeholder<hw> END
    some more original text
'''.format(**globals(), argv0 = sys.argv[0]), end='')

destination_files = []
operation = ""
is_all    = False

placeholders = {}

placeholder = '#placeholder<{0}>'
placeholder_collapsed  = placeholder + ' COLLAPSED'
placeholder_expanded_beginning = placeholder + ' BEGIN'
placeholder_expanded_ending = placeholder + ' END'
del placeholder

re_placeholder_collapsed          = re.compile('''^(.*){0}.*'''.format(placeholder_collapsed.format('''([a-zA-Z0-9_@-]+)''')), re.M)
re_placeholder_expanded_beginning = re.compile('''^(.*){0}.*'''.format(placeholder_expanded_beginning.format('''([a-zA-Z0-9_@-]+)''')), re.M)
re_placeholder_expanded_ending    = re.compile('''^.*{0}.*'''.format(placeholder_expanded_ending.format('''([a-zA-Z0-9_@-]+)''')), re.M)


builtins = [
('''@gnu-tofile-(.*)''',
'''payload_data=$(sed -n '/#placeholder<payload> START$/,/#placeholder<payload> END$/p' "$(realpath $0)")
payload_data=$(echo "$payload_data" | grep -vE '#placeholder<payload> (START|END)')
[ -z "$PAYLOADOUT" ] && PAYLOADOUT="out"
echo "$payload_data" > "$PAYLOADOUT"'''
),
]

def builtin_lookup(phl : str) -> str:
    for i in builtins:
        regex, value = i
        m = re.compile(regex).match(phl)
        if m:
            value = value.format(m.groups()[1:])
            return value
    return ''

def gen(s : str, phls : [str]) -> str:
    global is_all
    s = ungen(s, phls)
    for phl in phls:
        buf = ''
        l = 0
        for m in re_placeholder_collapsed.finditer(s):
            if (m.group(2) != phl): continue
            buf += s[l : m.start(0)]
            buf += m.group(1) + placeholder_expanded_beginning.format(phl) + '\n'
            buf += builtin_lookup(phl) if phl[0] == '@' else placeholders[phl]
            buf += '\n' + m.group(1) + placeholder_expanded_ending.format(phl)
            l = m.end(0)
        buf += s[l:]
        s = buf
    if is_all:
        buf = ''
        l = 0
        for m in re_placeholder_collapsed.finditer(s):
            buf += s[l : m.start(0)]
            buf += m.group(1) + placeholder_expanded_beginning.format(m.group(2)) + '\n'
            buf += m.group(1) + placeholder_expanded_ending.format(m.group(2))
            l = m.end(0)
        buf += s[l:]
        s = buf
    return s

def ungen(s : str, phls : [str]) -> str:
    global is_all
    for phl in phls:
        buf = ''
        l = 0
        for m in re_placeholder_expanded_beginning.finditer(s):
            if(m.group(2) != phl): continue
            buf += s[l : m.start(0)]
            buf += m.group(1) + placeholder_collapsed.format(phl)
            l = m.end(0)
            for me in re_placeholder_expanded_ending.finditer(s[m.end(0):]):
                if(me.group(1) != phl): continue
                l = m.end(0) + me.end(0)
                break
        buf += s[l:]
        s = buf
    if is_all:
        buf = ''
        l = 0
        for m in re_placeholder_expanded_beginning.finditer(s):
            buf += s[l : m.start(0)]
            buf += m.group(1) + placeholder_collapsed.format(m.group(2))
            l = m.end(0)
            for me in re_placeholder_expanded_ending.finditer(s[m.end(0):]):
                if(me.group(1) != m.group(2)): continue
                l = m.end(0) + me.end(0)
                break
        buf += s[l:]
        s = buf
    return s

def error_and_quit(e : int, argv : [str]) -> None:
    message = {
        Error.PARAM_MISS : "Missing parameter to flag '{0}'.",
        Error.UNK_FLAG   : "Unrecognized flag '{0}'.",
        Error.UNK_OPT    : "Unknown option passed to {0}: '{1}'.",
        Error.IO         : "I/O error encountered while interacting with '{0}'.",
        Error.UNTERM_DEF : "Unterminated definition ({0}).",
        Error.NO_FILES   : "Flags were specified, but no files.",
        Error.MULTI_OP   : "Multiple operations specified, '-g'/'-u' are mutually exclive.",
        Error.NO_OP      : "No operation operation specified. Either '-g' or '-u' is required.",
    }
    formatted_message = message[e].format(*argv, **globals())
    print("{C[r]}{msg}{C[n]}".format(**globals(), msg=formatted_message))
    exit(e.value)

# We need this function because getopt does not support a single flag taking 2 arguments
def parse_args(argv : [str]) -> None:
    global destination_files, operation, is_all
    def get_param(argv : [str], i : int) -> str:
        try: param = argv[i]
        except: error_and_quit(Error.PARAM_MISS, [argv[i-1]])
        return param

    for arg in argv:
        if arg == '-h' or arg == '--help':
            usage()
            exit(0)

    try:
        i = 0
        while i < len(argv):
            # 0 parama opt
            if argv[i] == '-u' or argv[i] == '-g':
                if operation != '': error_and_quit(Error.MULTI_OP, [])
                operation = argv[i][1]
                i = i + 1
                continue

            if argv[i] == '-a':
                is_all = True
                i = i + 1
                continue

            # 1 param opt
            if argv[i-1] == '--color':
                p = get_param(argv, i)
                if p == 'always': def_colors()
                elif p == 'auto' and has_color: def_colors()
                elif p == 'never': undef_colors()
                else: error_and_quit(Error.UNK_OPT, ['--color', p])
                i = i + 2
                continue

            # 2 param opt
            if argv[i] == '-d':
                placeholders[argv[i+1]] = argv[i+2]
                i = i + 3
                continue

            if argv[i] == '-e':
                with open(argv[i+2], "r") as f: placeholders[argv[i+1]] = f.read()
                i = i + 3
                continue

            # catch all
            if argv[i][0] != '-': # file
                destination_files.append(argv[i])
                i = i + 1
                continue

            error_and_quit(Error.UNK_FLAG, [argv[i]])
    except IndexError: error_and_quit(Error.PARAM_MISS, [argv[i]])
    except FileNotFoundError as e: error_and_quit(Error.IO, [e.filename])

def plug(argv : [str]) -> int:
    global destination_files, operation
    if has_color: def_colors()

    if len(argv) < 2:
        usage()
        exit(1)

    parse_args(argv)

    if destination_files == []:
        error_and_quit(Error.NO_FILES, [])

    if operation == '': error_and_quit(Error.NO_OP, [argv[i-1]])
    elif operation == 'g': gen_callback = gen
    elif operation == 'u': gen_callback = ungen

    for df in destination_files:
        try:
            with open(df, 'r') as f: s = gen_callback(f.read(), placeholders)
            with open(df, 'w') as f: f.write(s)
        except FileNotFoundError: error_and_quit(Error.IO, df)
    return 0

if __name__ == '__main__':
    raise SystemExit(plug(sys.argv[1:]))