#!/usr/bin/env python3

#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements.  See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership.  The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License.  You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

# Automatically convert remapping configuration for use of header_rewrite.so, regex_remap.so and gzip.so
# plugins from pre-ATS9 to ATS9 and later.  (See docs.trafficserver.apache.org, Command Line Utilities
# Appendix.)

import argparse

BACKSLASH = "\\"

parser = argparse.ArgumentParser(
    prog="cvt7to9",
    description= "Convert remap configuration from ATS7 to ATS9"
)
parser.add_argument("--filepath", default="remap.config", help="path specifier of remap config file")
parser.add_argument("--prefix", default="1st-", help="prefix for new header_rewrite config files")
parser.add_argument(
    "--plugin", default=None, help="path specifier (relative to FILEPATH) of (global) plugins config file"
)
args = parser.parse_args()

import sys

def eprint(*args, **kwargs):
    print(*args, file=sys.stderr, **kwargs)

# Prefix to add to file relative pathspecs before opening file.
#
pathspec_prefix = None

import os
import copy

# Remap or header rewrite config file.
#
class CfgFile:
    def __init__(self, param):
        if isinstance(param, CfgFile):
            # Making copy of header rewrite config file to use when header rewrite is first plugin for remap
            # rule.
            #
            self.changed = True
            d = os.path.dirname(param.pathspec)
            if (d != "") and (d != "/"):
                d += "/"
            self.pathspec = d + args.prefix + os.path.basename(param.pathspec)
            self.lines = copy.copy(param.lines)

        else:
            # Opening existing config file, param is pathspec.
            #
            self.changed = False
            self.pathspec = param
            ps = param
            if ps[:1] != "/":
                ps = pathspec_prefix + ps
            try:
                fd = open(ps, "r")
            except:
                eprint(f"fatal error: failure opening {ps} for reading")
                sys.exit(1)

            try:
                self.lines = fd.readlines()
            except:
                eprint(f"fatal error: failure reading {ps}")
                sys.exit(1)

            try:
                fd.close()
            except:
                eprint(f"fatal error: failure closing {ps}")
                sys.exit(1)

    # Write out new version of file if it's contents are new.  ".new" is appended for the pathspec to get the
    # pathspec of the new file.
    #
    def close(self):
        if self.changed:
            ps = self.pathspec
            if ps[:1] != "/":
                ps = pathspec_prefix + ps
            print(ps)
            ps += ".new"
            try:
                fd = open(ps, "w")
            except:
                eprint(f"fatal error: failure opening {ps} for writing")
                sys.exit(1)

            for ln in self.lines:
                if ln != None:
                    try:
                        fd.write(ln)
                    except:
                        eprint(f"fatal error: failure writing {ps}")
                        sys.exit(1)

            try:
                fd.close()
            except:
                eprint(f"fatal error: failure closing {ps}")
                sys.exit(1)

# A generic object.
#
class Obj:
    def __init__(self):
        pass

# A dictionary that is a mapping from pathspecs for header rewrite config files to generic objects.  If the
# object has a "first" attribute, it is a list of 2-tuples.  The first tuple entry is a reference to the
# the CfgFile object for a remap config file.  The second tuple entry is the index into the list of lines,
# with the index of the line where header_rewrite.so is the first remap plugin, and the header rewrite
# config file is a parameter to it.  If "other" is an attribute of the generic object, the config file is
# used as a parameter to calls to a global instance of header_rewrite.so, or instances of header_rewrite.so on
# a remap rule as the second or later remap plugin.  (The value of the "other" attribute is always None because
# the value doesn't matter.)
#
header_rewrite_cfgs = dict()

# Read in (global) header rewrite config file pathspecs from plugin.config file.
#
def handle_global(gbl_pathspec):

    if gbl_pathspec[:1] != "/":
        gbl_pathspec = pathspec_prefix + gbl_pathspec

    try:
        fd = open(gbl_pathspec, "r")
    except:
        eprint(f"fatal error: failure opening {gbl_pathspec} for reading")
        sys.exit(1)

    try:
        lines = fd.readlines()
    except:
        eprint(f"fatal error: failure reading {gbl_pathspec}")
        sys.exit(1)

    try:
        fd.close()
    except:
        eprint(f"fatal error: failure closing {gbl_pathspec}")
        sys.exit(1)

    ln_num = 0
    while ln_num < len(lines):
        ln = lines[ln_num]

        # Join continuation lines to the first line.
        #
        num_ln_joined = 1
        while ((ln_num + num_ln_joined) < len(lines)) and (ln[-2:] == (BACKSLASH + "\n")):
            ln[:-2] += " " + lines[ln_num + num_ln_joined]
            num_ln_joined += 1

        ofst = ln.find("#")
        if ofst >= 0:
            # Remove comment.
            #
            ln = ln[:ofst]

        ln = ln.split()

        if (len(ln) > 0) and (ln[0] == "header_rewrite.so"):
            for param in ln[1:]:
                hr_obj = Obj()
                hr_obj.other = None
                header_rewrite_cfgs[param] = hr_obj
            break

        ln_num += num_ln_joined

# A list of CfgFile instances for the main remap config file and any include files it contains, directly or
# indirectly.
#
remap_cfgs = []

# Handle the remap config file, and call this recursively to handle include files.
#
def handle_remap(filepath):

    def skip_white(a_str):
        if a_str[:1].isspace():
            return 1
        elif a_str.startswith(BACKSLASH + "\n"):
            return 2
        return 0

    rc = CfgFile(filepath)

    remap_cfgs.append(rc)

    ln_num = 0
    while ln_num < len(rc.lines):
        ln = rc.lines[ln_num]

        # Join continuation lines to the first line, and make them None in the rc.lines array.
        #
        num_ln_joined = 1
        while ((ln_num + num_ln_joined) < len(rc.lines)) and (ln[-2:] == (BACKSLASH + "\n")):
            ln += rc.lines[ln_num + num_ln_joined]
            rc.lines[ln_num + num_ln_joined] = None
            num_ln_joined += 1

        rc.lines[ln_num] = ln

        len_content = ln.find("#")
        if len_content < 0:
            len_content = len(ln) - 1

        # Only process lines with content.
        #
        if (len_content > 0) and not ln[:len_content].isspace():
            ofst = ln.find(".include")
            if (ofst == 0) or ((ofst > 0) and ln[:ofst].isspace()):
                ofst += len(".include")
                start_ps = -1
                while ofst < len_content:
                    sw = skip_white(ln[ofst:])
                    if sw == 0:
                        if start_ps < 0:
                            start_ps = ofst
                        ofst += 1
                    else:
                        if start_ps >= 0:
                            handle_remap(ln[start_ps:ofst])
                            start_ps = -1
                        ofst += sw

                if start_ps >= 0:
                    handle_remap(ln[start_ps:len_content])
            else:
                # First, gzip.so -> compress.so
                #
                lnc  = ln[:len_content]
                lncr = lnc.replace("@plugin=gzip.so", "@plugin=compress.so")
                if lncr != lnc:
                    ln = lncr + ln[len_content:]
                    len_content = len(lncr)
                    rc.lines[ln_num] = ln
                    rc.changed = True

                ofst = ln[:len_content].find("@plugin=")
                if ofst >= 0:
                    # Assuming it's some sort of remap line since it's got @plugin.
                    #
                    ofst += len("@plugin=")

                    new_ln = None

                    if ln[ofst:].startswith("header_rewrite.so"):
                        ofst += len("header_rewrite.so")

                        while ofst < len_content:
                            ofst2 = ln[ofst:len_content].find("@")
                            if ofst2 < 0:
                                break;

                            ofst += ofst2

                            if not ln[ofst:].startswith("@pparam="):
                                break

                            ofst += len("@pparam=")

                            ofst2 = ofst
                            while ofst2 < len_content:
                                sw = skip_white(ln[ofst2:])
                                if sw != 0:
                                    break
                                ofst2 += 1

                            hr_pathspec = ln[ofst:ofst2]
                            ofst = ofst2

                            if hr_pathspec not in header_rewrite_cfgs:
                                hr_obj = Obj()
                                header_rewrite_cfgs[hr_pathspec] = hr_obj;
                            else:
                                hr_obj = header_rewrite_cfgs[hr_pathspec]

                            if not hasattr(hr_obj, "first"):
                                hr_obj.first = []

                            hr_obj.first.append((rc, ln_num))

                    elif ln[ofst:].startswith("regex_remap.so"):
                        ofst += len("regex_remap.so")

                        ofst2 = ln[ofst:len_content].find("@plugin=")

                        if ofst2 < 0:
                            ofst2 = len_content
                        else:
                            ofst2 += ofst

                        if ((ln[ofst:ofst2].find("@pparam=pristine") < 0) and
                            (ln[ofst:ofst2].find("@pparam=no-pristine") < 0)):

                            new_ln = ln[:ofst2]

                            if not new_ln[-1].isspace():
                                new_ln += " "

                            new_ln += "@pparam=pristine"

                            if not ln[ofst2].isspace():
                                new_ln += " "

                            ofst = len(new_ln)

                            new_ln += ln[ofst2:]

                        else:
                            ofst = ofst2

                    if new_ln:
                        rc.lines[ln_num] = new_ln
                        rc.changed = True
                        len_content += len(new_ln) - len(ln)
                        ln = new_ln

                    # Handle it if header rewrite is called as a second or later plugin.
                    #
                    while ofst < len_content:
                        ofst2 = ln[ofst:len_content].find("@plugin=header_rewrite.so")

                        if ofst2 < 0:
                            break

                        ofst += ofst2 + len("@plugin=header_rewrite.so")

                        while ofst < len_content:
                            ofst2 = ln[ofst:len_content].find("@")
                            if ofst2 < 0:
                                break

                            ofst += ofst2 + 1

                            if not ln[ofst:].startswith("pparam="):
                                ofst -= 1
                                break

                            ofst += len("pparam=")

                            ofst2 = ofst
                            while ofst2 < len_content:
                                sw = skip_white(ln[ofst2:])
                                if sw != 0:
                                    break
                                ofst2 += 1

                            hr_pathspec = ln[ofst:ofst2]
                            ofst = ofst2

                            if hr_pathspec not in header_rewrite_cfgs:
                                hr_obj = Obj()
                                header_rewrite_cfgs[hr_pathspec] = hr_obj;
                            else:
                                hr_obj = header_rewrite_cfgs[hr_pathspec]

                            hr_obj.other = None  # Only the existence of this attr matters, not its value.

        ln_num += num_ln_joined

def handle_header_rewrite(pathspec, obj):

    # This class manages changes to a header rewrite config file.
    #
    class HRCfg:
        # 'pathspec' is the key in header_rewrite_cfgs and 'obj' is the generic object value.
        #
        def __init__(self, pathspec, obj):
            self._base = CfgFile(pathspec)
            self._obj  = obj

        def has_first(self):
            return hasattr(self._obj, "first")

        def has_other(self):
            return hasattr(self._obj, "other")

        def get_lines(self):
            return self._base.lines

        # Assumes has_first is true.
        #
        def chg_ln_first(self, ln_num, ln):
            if not hasattr(self, "_first"):
                if self.has_other():
                    # Must make two versions of header rewrite config file.
                    #
                    self._first = CfgFile(self._base)
                else:
                    self._first         = self._base
                    self._first.changed = True

            self._first.lines[ln_num] = ln

        # Assumes has_other is true.
        #
        def chg_ln_cmn(self, ln_num, ln):
            if hasattr(self, "_first") and not (self._first is self._base):
                self._first.lines[ln_num] = ln

            self._base.lines[ln_num] = ln
            self._base.changed       = True

        def close(self):
            self._base.close()
            if hasattr(self, "_first") and not (self._first is self._base):
                self._first.close()

                old_pparam = "@pparam=" + self._base.pathspec

                # Backpatch remap configuration files with pathspec of new header rewrite config file for call
                # to header rewrite as first remap plugin.
                #
                for pair in self._obj.first:
                    rc = pair[0]
                    rc.changed = True
                    ln = rc.lines[pair[1]]
                    ofst = ln.find(old_pparam) + len("@pparam=")
                    rc.lines[pair[1]] = ln[:ofst] + self._first.pathspec + ln[ofst + len(self._base.pathspec):]

    # Returns a pair: length of string before # comment, and a flag indicating if %< (beginning of a variable
    # expansion) was in any of the double-quoted strings.
    #
    def get_content_len(s):
        # States.
        #
        COPYING           = 0
        ESCAPE_COPYING    = 1
        IN_QUOTED         = 2
        ESCAPE_IN_QUOTED  = 3
        PERCENT_IN_QUOTED = 4

        state              = COPYING
        ofst               = 0
        variable_expansion = False

        while ofst < (len(s) - 1):
            if state == COPYING:
                if s[ofst] == "#":
                    # Start of comment.
                    #
                    return (ofst, variable_expansion)
                elif s[ofst] == '"':
                    state = IN_QUOTED
                else:
                    if s[ofst] == BACKSLASH:
                        state = ESCAPE_COPYING

            elif state == IN_QUOTED:
                if s[ofst] == BACKSLASH:
                    state = ESCAPE_IN_QUOTED
                elif s[ofst] == '"':
                    state = COPYING
                elif s[ofst] == '%':
                    state = PERCENT_IN_QUOTED

            elif state == ESCAPE_COPYING:
                state = COPYING

            elif state == ESCAPE_IN_QUOTED:
                if s[ofst] == '%':
                    state = PERCENT_IN_QUOTED
                else:
                    state = IN_QUOTED

            elif state == PERCENT_IN_QUOTED:
                if s[ofst] == '<':
                    variable_expansion = True
                    state = IN_QUOTED
                elif s[ofst] == BACKSLASH:
                    state = ESCAPE_IN_QUOTED
                elif s[ofst] == '"':
                    state = COPYING
                elif s[ofst] == '%':
                    state = PERCENT_IN_QUOTED
                else:
                    state = IN_QUOTED

            ofst += 1

        return (ofst, variable_expansion)

    hrc = HRCfg(pathspec, obj)
    lines = hrc.get_lines()
    ln_num = 0
    while ln_num < len(lines):
        ln            = lines[ln_num]
        prepend_error = False

        len_content, variable_expansion = get_content_len(ln)

        if variable_expansion:
            eprint(
                f"error: {pathspec}, line {ln_num + 1}: variable expansions cannot be automatically converted"
            )
            prepend_error = True

        lnc = ln[:len_content]
        lnc = lnc.replace("%{CLIENT-IP}",     "%{INBOUND:REMOTE-ADDR}")
        lnc = lnc.replace("%{INCOMING-PORT}", "%{INBOUND:LOCAL-PORT}")
        lnc = lnc.replace("%{PATH}",          "%{URL:PATH}")
        lnc = lnc.replace("%{QUERY}",         "%{URL:QUERY}")

        if hrc.has_other() and (prepend_error or (lnc != ln[:len_content])):
            if prepend_error:
                hrc.chg_ln_cmn(ln_num, "ERROR: " + lnc + ln[len_content:])
            else:
                hrc.chg_ln_cmn(ln_num, lnc + ln[len_content:])

        if hrc.has_first():
            lnc_save = lnc
            ofst = lnc.find("set-destination")
            if (ofst >= 0):
                if (ofst == 0) or lnc[:ofst].isspace():
                    ofst += len("set-destination")
                    ofst2 = lnc[ofst:].find("PATH")
                    if (ofst2 > 0) and lnc[ofst:(ofst + ofst2)].isspace():
                        eprint(
                            f"error: {pathspec}, line {ln_num + 1}: the functionality of set-destination PATH" +
                            " for the first remap plugin does not exist in ATS9"
                        )
                        prepend_error = True

            lnc = lnc.replace("%{URL:", "%{CLIENT-URL:")
            if lnc != lnc_save or prepend_error:
                if prepend_error:
                    hrc.chg_ln_first(ln_num, "ERROR: " + lnc + ln[len_content:])
                else:
                    hrc.chg_ln_first(ln_num, lnc + ln[len_content:])

        ln_num += 1

    hrc.close()

pathspec_prefix = os.path.dirname(args.filepath)
if (pathspec_prefix != "") and (pathspec_prefix != "/"):
    pathspec_prefix += "/"

if not (args.plugin is None):
    handle_global(args.plugin)

handle_remap(os.path.basename(args.filepath))

for pathspec in header_rewrite_cfgs:
    handle_header_rewrite(pathspec, header_rewrite_cfgs[pathspec])

# loop through remap_cfgs and close each one.
for rc in remap_cfgs:
    rc.close()

sys.exit(0)
