# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
#
# SPDX-License-Identifier: MPL-2.0
#
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0.  If a copy of the MPL was not distributed with this
# file, you can obtain one at https://mozilla.org/MPL/2.0/.
#
# See the COPYRIGHT file distributed with this work for additional
# information regarding copyright ownership.

from __future__ import print_function
import os
import sys
import argparse
import glob
import re
import time
import calendar
import pprint
from collections import defaultdict

prog = "dnssec-coverage"

from isc import dnskey, eventlist, keydict, keyevent, keyzone, utils


############################################################################
# print a fatal error and exit
############################################################################
def fatal(*args, **kwargs):
    print(*args, **kwargs)
    sys.exit(1)


############################################################################
# output:
############################################################################
_firstline = True


def output(*args, **kwargs):
    """output text, adding a vertical space this is *not* the first
    first section being printed since a call to vreset()"""
    global _firstline
    if "skip" in kwargs:
        skip = kwargs["skip"]
        kwargs.pop("skip", None)
    else:
        skip = True
    if _firstline:
        _firstline = False
    elif skip:
        print("")
    if args:
        print(*args, **kwargs)


def vreset():
    """reset vertical spacing"""
    global _firstline
    _firstline = True


############################################################################
# parse_time
############################################################################
def parse_time(s):
    """convert a formatted time (e.g., 1y, 6mo, 15mi, etc) into seconds
    :param s: String with some text representing a time interval
    :return: Integer with the number of seconds in the time interval
    """
    s = s.strip()

    # if s is an integer, we're done already
    try:
        return int(s)
    except ValueError:
        pass

    # try to parse as a number with a suffix indicating unit of time
    r = re.compile(r"([0-9][0-9]*)\s*([A-Za-z]*)")
    m = r.match(s)
    if not m:
        raise ValueError("Cannot parse %s" % s)
    n, unit = m.groups()
    n = int(n)
    unit = unit.lower()
    if unit.startswith("y"):
        return n * 31536000
    elif unit.startswith("mo"):
        return n * 2592000
    elif unit.startswith("w"):
        return n * 604800
    elif unit.startswith("d"):
        return n * 86400
    elif unit.startswith("h"):
        return n * 3600
    elif unit.startswith("mi"):
        return n * 60
    elif unit.startswith("s"):
        return n
    else:
        raise ValueError("Invalid suffix %s" % unit)


############################################################################
# set_path:
############################################################################
def set_path(command, default=None):
    """find the location of a specified command.  if a default is supplied
    and it works, we use it; otherwise we search PATH for a match.
    :param command: string with a command to look for in the path
    :param default: default location to use
    :return: detected location for the desired command
    """

    fpath = default
    if not fpath or not os.path.isfile(fpath) or not os.access(fpath, os.X_OK):
        path = os.environ["PATH"]
        if not path:
            path = os.path.defpath
        for directory in path.split(os.pathsep):
            fpath = os.path.join(directory, command)
            if os.path.isfile(fpath) and os.access(fpath, os.X_OK):
                break
            fpath = None

    return fpath


############################################################################
# parse_args:
############################################################################
def parse_args():
    """Read command line arguments, set global 'args' structure"""
    compilezone = set_path(
        "named-compilezone", os.path.join(utils.prefix("sbin"), "named-compilezone")
    )

    parser = argparse.ArgumentParser(
        description=prog + ": checks future " + "DNSKEY coverage for a zone"
    )

    parser.add_argument(
        "zone",
        type=str,
        nargs="*",
        default=None,
        help="zone(s) to check" + "(default: all zones in the directory)",
    )
    parser.add_argument(
        "-K",
        dest="path",
        default=".",
        type=str,
        help="a directory containing keys to process",
        metavar="dir",
    )
    parser.add_argument(
        "-f", dest="filename", type=str, help="zone master file", metavar="file"
    )
    parser.add_argument(
        "-m",
        dest="maxttl",
        type=str,
        help="the longest TTL in the zone(s)",
        metavar="time",
    )
    parser.add_argument(
        "-d", dest="keyttl", type=str, help="the DNSKEY TTL", metavar="time"
    )
    parser.add_argument(
        "-r",
        dest="resign",
        default="1944000",
        type=str,
        help="the RRSIG refresh interval " "in seconds [default: 22.5 days]",
        metavar="time",
    )
    parser.add_argument(
        "-c",
        dest="compilezone",
        default=compilezone,
        type=str,
        help="path to 'named-compilezone'",
        metavar="path",
    )
    parser.add_argument(
        "-l",
        dest="checklimit",
        type=str,
        default="0",
        help="Length of time to check for " "DNSSEC coverage [default: 0 (unlimited)]",
        metavar="time",
    )
    parser.add_argument(
        "-z",
        dest="no_ksk",
        action="store_true",
        default=False,
        help="Only check zone-signing keys (ZSKs)",
    )
    parser.add_argument(
        "-k",
        dest="no_zsk",
        action="store_true",
        default=False,
        help="Only check key-signing keys (KSKs)",
    )
    parser.add_argument(
        "-D",
        "--debug",
        dest="debug_mode",
        action="store_true",
        default=False,
        help="Turn on debugging output",
    )
    parser.add_argument("-v", "--version", action="version", version=utils.version)

    args = parser.parse_args()

    if args.no_zsk and args.no_ksk:
        fatal("ERROR: -z and -k cannot be used together.")
    elif args.no_zsk or args.no_ksk:
        args.keytype = "KSK" if args.no_zsk else "ZSK"
    else:
        args.keytype = None

    if args.filename and len(args.zone) > 1:
        fatal("ERROR: -f can only be used with one zone.")

    # strip trailing dots if any
    args.zone = [x[:-1] if (len(x) > 1 and x[-1] == ".") else x for x in args.zone]

    # convert from time arguments to seconds
    try:
        if args.maxttl:
            m = parse_time(args.maxttl)
            args.maxttl = m
    except ValueError:
        pass

    try:
        if args.keyttl:
            k = parse_time(args.keyttl)
            args.keyttl = k
    except ValueError:
        pass

    try:
        if args.resign:
            r = parse_time(args.resign)
            args.resign = r
    except ValueError:
        pass

    try:
        if args.checklimit:
            lim = args.checklimit
            r = parse_time(args.checklimit)
            if r == 0:
                args.checklimit = None
            else:
                args.checklimit = time.time() + r
    except ValueError:
        pass

    # if we've got the values we need from the command line, stop now
    if args.maxttl and args.keyttl:
        return args

    # load keyttl and maxttl data from zonefile
    if args.zone and args.filename:
        try:
            zone = keyzone(args.zone[0], args.filename, args.compilezone)
            args.maxttl = args.maxttl or zone.maxttl
            args.keyttl = args.maxttl or zone.keyttl
        except Exception as e:
            print("Unable to load zone data from %s: " % args.filename, e)

    if not args.maxttl:
        output(
            "WARNING: Maximum TTL value was not specified.  Using 1 week\n"
            "\t (604800 seconds); re-run with the -m option to get more\n"
            "\t accurate results."
        )
        args.maxttl = 604800

    return args


############################################################################
# Main
############################################################################
def main():
    args = parse_args()

    print("PHASE 1--Loading keys to check for internal timing problems")

    try:
        kd = keydict(path=args.path, zones=args.zone, keyttl=args.keyttl)
    except Exception as e:
        fatal("ERROR: Unable to build key dictionary: " + str(e))

    for key in kd:
        key.check_prepub(output)
        if key.sep:
            key.check_postpub(output)
        else:
            key.check_postpub(output, args.maxttl + args.resign)

    output("PHASE 2--Scanning future key events for coverage failures")
    vreset()

    try:
        elist = eventlist(kd)
    except Exception as e:
        fatal("ERROR: Unable to build event list: " + str(e))

    errors = False
    if not args.zone:
        if not elist.coverage(None, args.keytype, args.checklimit, output):
            errors = True
    else:
        for zone in args.zone:
            try:
                if not elist.coverage(zone, args.keytype, args.checklimit, output):
                    errors = True
            except:
                output("ERROR: Coverage check failed for zone " + zone)

    sys.exit(1 if errors else 0)
