#!/usr/bin/env python

import os
import logging
import argparse
import json
import re
from pathlib import Path
from azure_key_vault_report import azure_key_vault_report
from cert_report import certs as crt
from cert_report import report as crt_report
from message_handler import message_handler
from .slack_workflow_report import slack_workflow_report
from .post_payloads import post_payloads


########################################################################################################################


class Monitoring(object):
    """Creates alerts and reports from azure key vault records and ssl cert check. The alerts and reports may be posted
    to Slack or MS Teams webhooks.

        - azure_key_vault_report.AzureKeyVaultReport() : to create Key Vault reports or alert
        - cert_report.Certs() : to fetch the SSL certificate status from the URLs
        - cert_report.Report() : to create SSL certificate reports
        - message_handler.MessageHandler() : to post the payloads with the reports and alerts


       Attributes
       ----------
        args : parse_args() object from argparse.ArgumentParser()
            The arguments, passed from command line or the specified default values.
        report_full : dict
            The full report which are written to file. To be posted to API -> DB.
        kv : azure_key_vault_report.AzureKeyVaultReport() object
            The object to be called in order to generate the Key Vault reports and/or alerts.
        success : boolean
            If at least one payload is posted it is considered a success, then the notify webhook can be triggered.
        payloads : list
            The list of payloads to be posted.
        slack_workflow_posts : list
            The list of Slack WORKFLOW posts to be posted.
        slack_app : boolean
            Set to true if the webhook is detected to be a Slack APP (and not a Slack WORKFLOW).
        teams_output : boolean
            Set to true if webhook not detected to be a Slack webhook.
        msg_handler : message_handler.MessageHandler() object
            The object to be called in order to post the payloads.
        certs_report : The crt_report.Report() object
            The object to be called in order to generate the SSL certificate reports.
        certs_json = list
            The list of reports as dict for each SSL certificate. To be added to the report_full dict.
        json_data = list
            The list where the read json data will be stored
        default_file_ending = string
            The default file ending of json files to be read and written (default: ".json")
        default_output_filepath = string
            The default output dir to where json log file will be written (default: "/tmp")
        default_output_filename = string
            The filename of the json log output file


        Methods
        -------

        set_webhooks(webhook)
            Initiates the MessageHandler() with the provided webhook (if it starts with 'https')

            Also determines if the passed webhook is one of the following types:
            'Slack APP'
            'Slack WEBHOOK'

            If not, it is considered to a 'MS Teams' kind of webhook.


        read_json()
            Try to read every .json file in directory specified in the args.json_dir argument.
            If failed to read the file as json, data will be read from file as default text.
            The json files are expected to contain piped json output from 'az keyvault' commands.


        key_vault_report()
            Executes the az keyvault list commands on each provided Key Vault.
            The output results are stored as list of dict objects.

            Then azure_key_vault_report.AzureKeyVaultReport() is initiated with the list of outputs from the
            az commands.

            The methods of the AzureKeyVaultReport() are called based on the provided argparse arguments.


        report_full_json()
            Set 'report_full' to the value of what the called get_report_full() method of the AzureKeyVaultReport()
            returns (dict).

            It also enriches 'report_full' some other key / values (to be passed to API -> DB):
                name: workflow_output_name
                repository_name: GITHUB_REPOSITORY (ENV) (only the name part)
                client_id: AZURE_CLIENT_ID (ENV) (the 'Application ID' of the Azure App. Reg.)
                subscription_id: AZURE_SUBSCRIPTION_ID (ENV) (the Azure subscription id)
                tenant_id: AZURE_TENANT_ID (ENV) (the Azure tenant id)

            The full ssl certificate json report is also added to the 'report_full' dict.


        write_report_full_json()
            Writes the report_full as json to the file provided in the workflow_output_file argparse argument.
            Default: output.json


        kv_alert()
            If the 'alert_threshold' (INT) argument is provided then alert methods of
            AzureKeyVaultReport() will be called in order to return the alert payloads:

            get_teams_payloads() if webhook is detected to not be a Slack kind of webhook
            get_slack_payloads() if the webhook is previously determined to be a Slack APP webhook


        kv_report()
            One of the report methods of AzureKeyVaultReport() will be called in order to return the report payloads:

            get_slack_payloads() if the webhook is previously determined to be a Slack APP webhook
            get_slack_payloads(app=False) if the webhook is previously determined to be a Slack WORKFLOW webhook
            get_teams_payloads() if the webhook is previously determined to be an MS Teams webhook
                If the returned MS Team payload is too large, get_teams_payloads() will be recalled with custom text
                provided. The payload will then only contain the 'facts' from the report (the summary).


        ssl_cert()
            Initiates the crt_report.Report() object with the provided arguments:
                the urls to the SSL certs
                the warning and critical thresholds
                if to include all SSL certs in report (even the OK ones)

           Calls the gen_report() method of crt_report.Report() and
           the certs_report.get_report_json() (to be used in the 'report_full' -> API -> DB)


        ssl_cert_report()
            Calls the get_slack_report() method of crt_report.Report() if the webhook is previously determined
            to be a Slack APP webhook.
                The result will be added to the list of payloads to be posted.
            Calls the get_html_report() method of crt_report.Report() if the webhook is previously determined
            to be an MS Teams webhook
                The result (HTML table) is uses to create an MS Teams payload which are added to the list of payloads.


        post_payloads()
            Post the list of payloads. If at least one of the posts are posted to the report webhook
            (the value of 'WEBHOOK_REPORT'), it is considered to be a success.

            If success an empty post to the notify webhook will be posted (the value of 'WEBHOOK_NOTIFY')


        get_md_report()
            Returns the value of the get_report_summary_markdown() method of the
            azure_key_vault_report.AzureKeyVaultReport() object.

            This is to be used in print to stdout.


        get_success()
            returns the success status

            This is used to determine if an empty post to the notify webhook will be posted
            (the value of 'WEBHOOK_NOTIFY')

          """

    def __init__(self, args):
        """
        Parameters
        ----------
        args : parse_args() object from argparse.ArgumentParser()
        """

        self.args = args
        self.default_file_ending = ".json"
        self.default_output_filepath = "/tmp"
        self.default_output_filename = f"output{self.default_file_ending}"
        self.report_full = {}
        self.kv = None
        self.success = False
        self.payloads = []
        self.slack_workflow_posts = []
        self.slack_app = False
        self.teams_output = False
        self.msg_handler = None
        self.certs_report = None
        self.certs_json = []
        self.json_data = []

    def set_webhooks(self, webhook):
        """initiates the MessageHandler() with the provided webhook (if it starts with 'https')

        Parameters
        ----------
        webhook : string
            The URL of the webhook
        """

        if "slack.com/services" in str(webhook):
            logging.info("Slack services webhook detected.")
            self.slack_app = True
        elif "slack.com" not in str(webhook):
            logging.info("No slack webhook detected. Assuming post to MS Teams.")
            self.teams_output = True

        if str(webhook).startswith("https"):
            self.msg_handler = message_handler.MessageHandler(webhook)

    def read_json(self):
        """reads .json files from the json dir"""
        if not self.args.json_dir and not os.path.isdir(self.args.json_dir):
            logging.error(f"'{self.args.json_dir}' dir not found.")
            return

        for json_filename in os.listdir(self.args.json_dir):
            if json_filename.endswith(self.default_file_ending):
                json_file = os.path.join(self.args.json_dir, json_filename)
                with open(json_file) as f:
                    json_data = {}
                    failed = False
                    if os.stat(json_file).st_size > 0:
                        try:
                            json_data = json.load(f)
                        except ValueError:
                            logging.error(f"Unable to read file '{json_file}' as json")
                            failed = True

                    if json_data:
                        self.json_data.append(json_data)

                    if failed:
                        with open(json_file) as e:
                            error_message = e.read()
                            if error_message:
                                logging.error(f"Content of '{json_file}' read as error message")
                                self.json_data.append(str(error_message))

    def key_vault_report(self):
        """executes the az keyvault list commands on each provided Key Vault"""

        # Return if no vaults to parse
        if not self.json_data:
            logging.error("No Key Vault json data to parse.")
            return

        # The report is generated by using the pip package ops-py-azure-key-vault-report
        # If argument 'include_no_expiration' is not provided, then the variable
        # 'ignore_no_expiration' is then set to True
        self.kv = azure_key_vault_report.AzureKeyVaultReport(self.json_data)
        self.kv.parse_results()
        self.kv.add_summary()
        self.kv.add_report(expire_threshold=self.args.expire_threshold,
                           ignore_no_expiration=self.args.include_no_expiration,
                           include_all=self.args.include_all,
                           teams_json=self.teams_output,
                           alert_threshold=self.args.alert_threshold)

    def report_full_json(self):
        """set 'report_full' to the return value of get_report_full() method of the AzureKeyVaultReport()"""

        if self.kv:
            self.report_full = self.kv.get_report_full()
            workflow_output_name = str(WORKFLOW_OUTPUT_NAME).strip().lower().replace(" ", "_")[:40]
            self.report_full["name"] = workflow_output_name
            self.report_full["repository_name"] = str(GITHUB_REPOSITORY).split("/")[-1]
            self.report_full["client_id"] = AZURE_CLIENT_ID
            self.report_full["subscription_id"] = AZURE_SUBSCRIPTION_ID
            self.report_full["tenant_id"] = AZURE_TENANT_ID

        self.report_full["ssl_certs"] = self.certs_json

    def write_report_full_json(self):
        """writes the report_full as json to the file provided set in the workflow_output_file argparse argument"""

        # Get sanitized filename
        workflow_output_file = self.sanitize_filename()

        # Return if nothing to write
        if not workflow_output_file:
            return

        # Defines the safe base path (/tmp or /home)
        base_path = self.default_output_filepath
        if self.args.workflow_output_file_in_home:
            base_path = str(Path.home())

        logging.info(f"Output file '{workflow_output_file}' set to be written to {base_path}.")

        # Constructs the full path to the output file
        workflow_output_filepath = os.path.join(base_path, workflow_output_file)

        # Get the real, absolute path to the output file
        safe_path = os.path.realpath(workflow_output_filepath)

        # Abort if the full path of the file to be written does not match what is expected
        common_base = os.path.commonpath([base_path, safe_path])
        if common_base != base_path:
            logging.error(f"Write to '{safe_path}' is not allowed.")
            logging.error(f"The file must be written to the already defined location '{base_path}'. "
                          "Write to file aborted.")
            return

        # Verify basename of resolved path matches original file
        if os.path.basename(safe_path) != workflow_output_file:
            logging.error(f"Trying to write to file with filename '{workflow_output_file}', "
                          f"but filename '{os.path.basename(safe_path)}' was expected. Write to file aborted.")
            return

        # Write full report as json to file
        with open(workflow_output_filepath, 'w') as f:
            json.dump(self.report_full, f, default=str)

    def sanitize_filename(self):
        """Sanitizes the filename of the json data output"""

        # Ensure a valid filename is set. If that is not the case, then '/tmp/output.json' is used as default.
        if self.report_full:
            if self.args.workflow_output_file != self.default_output_filename:
                custom_workflow_output_file = str(self.args.workflow_output_file.lower()).replace(" ", "_")
                log_msg = (f"custom_workflow_output_file '{custom_workflow_output_file}' not valid. "
                           f"Using default output file '{self.default_output_filename}' instead.")

                # It the custom file do not have a valid filename, then the default output file is used.
                # Valid characters: 'a -> z', '0 -> 9', '.', '-' and '_'
                if not bool(re.match("^[a-z0-9._-]*$", custom_workflow_output_file)):
                    logging.warning(log_msg)
                    return self.default_output_filename

                # The file cannot contain two or more dots in a row
                if bool(re.match(r".*\.{2,}", custom_workflow_output_file)):
                    logging.warning(log_msg)
                    return self.default_output_filename

                # The custom filename is ok to use
                return custom_workflow_output_file

            # Returns the default output file if no custom file provided
            return self.default_output_filename

    def kv_alert(self):
        """calls the AzureKeyVaultReport() in order to return the alert payloads to be used"""

        if isinstance(self.args.alert_threshold, int):
            # If the webhook is previously determined to be an MS Teams webhook, then get the teams alert payloads
            if self.teams_output:
                payloads = self.kv.get_teams_payloads(self.args.title, alert=True)
                if payloads:
                    self.payloads += payloads

            # If the webhook is previously determined to be a Slack App webhook, then get the slack alert payloads
            if self.slack_app:
                payloads = self.kv.get_slack_payloads(self.args.title, max_chars=self.args.slack_split_chars,
                                                      alert=True)
                if payloads:
                    self.payloads += payloads

            if not self.slack_app and not self.teams_output:
                logging.error("Alerts to Slack Workflow not supported. Please use Slack App")

    def kv_report(self):
        """calls one of the report methods of AzureKeyVaultReport() order to return the report payloads"""

        # If the webhook is previously determined to be a Slack App webhook, then get the slack report payloads
        if self.slack_app:
            payloads = self.kv.get_slack_payloads(self.args.title, max_chars=self.args.slack_split_chars)
            if payloads:
                self.payloads += payloads

        # If Slack Workflow, then get the slack report Workflow payloads
        if not self.slack_app and not self.teams_output:
            self.slack_workflow_posts = self.kv.get_slack_payloads(self.args.title,
                                                                   max_chars=self.args.slack_split_chars, app=False)

        # If the webhook is previously determined to be an MS Teams webhook, the teams_report function is called
        # which also will post the report message
        if self.teams_output:
            payload = self.kv.get_teams_payloads(self.args.title)

            if not payload:
                logging.error("Unable to retrieve MS Teams payload")
                return

            # If stdout_only the full MS Teams payload is returned
            if self.args.stdout_only:
                return payload

            # If payload is too large with the report as html, a new payload is generated.
            # A custom text is provided instead, which will be used instead of the html report.
            if len(payload) > self.args.teams_max_chars:
                warning_msg = (f"The {self.args.title} length is above the character limit count "
                               f"of {self.args.teams_max_chars}")
                logging.warning(warning_msg)
                title = f"WARNING! {warning_msg}"
                text = "The html report have been omitted from the report due to size limits."
                payload = self.kv.get_teams_payloads(title, text=text)

            self.payloads += [payload]

    def ssl_cert(self):
        """initiates the cert report classes and calls needed methods to generate reports"""

        # Return if no vaults to parse
        if not self.args.ssl_certs:
            logging.error("No SSL certs provided.")
            return

        ssl_certs = self.args.ssl_certs

        # If only one key vault to check, ensure it is treated as a list
        if isinstance(ssl_certs, str):
            ssl_certs = [ssl_certs]

        if ssl_certs:
            c = crt.Certs(ssl_certs)
            c.parse_certs()
            certs = c.get_certs()
            self.certs_report = crt_report.Report(certs, self.args.ssl_warning_threshold,
                                                  self.args.ssl_critical_threshold,
                                                  skip_ok=self.args.ssl_include_ok)
            self.certs_report.gen_report()
            self.certs_json = self.certs_report.get_report_json()

    def ssl_cert_report(self):
        """set the ssl cert payloads by calling the needed methods of the cert_report object"""

        # Return if no vaults to parse
        if not self.certs_report:
            return

        if self.slack_app:
            rows = self.certs_report.get_slack_report()
            if rows:
                self.payloads += [{"text": rows}]

        if self.teams_output:
            title, rows = self.certs_report.get_html_report()
            if title and rows:
                payload = {
                    "@type": "MessageCard",
                    "@context": "http://schema.org/extensions",
                    "themeColor": "0076D7",
                    "summary": "-",
                    "sections": [
                        {
                            "activityTitle": title,
                            "activitySubtitle": "",
                            "activityImage": "",
                            "markdown": True
                        },
                        {
                            "startGroup": True,
                            "text": rows
                        }
                    ]
                }
                self.payloads += [payload]

        if not self.slack_app and not self.teams_output:
            logging.error("SSL reports to Slack Workflow is not supported atm. Please use Slack App")

    def post_payloads(self):
        """posts the list of payloads"""

        # If payloads, the slack_post function is called to post the report payloads
        if self.payloads and self.msg_handler and not self.args.stdout_only:
            self.success = post_payloads(self.msg_handler, self.payloads)

        if self.slack_workflow_posts and not self.args.stdout_only:
            # The slack_post function is called to post the Slack Workflow reports payloads
            self.success = slack_workflow_report(self.msg_handler, posts=self.slack_workflow_posts)

    def get_md_report(self):
        """returns the key vault plaintext markdown report"""

        if self.kv:
            return self.kv.get_report_summary_markdown()

    def get_success(self):
        """returns the success variable"""

        return self.success


def main():
    logging.basicConfig(format='%(asctime)s - %(message)s', level=logging.INFO)

    # The list of key vaults to check passed as command line arguments
    parser = argparse.ArgumentParser()
    parser.add_argument("-J", "--json_dir",
                        help="The directory containing the .json files.")

    parser.add_argument("-c", "--alert_threshold", type=int,
                        help="If set then only records that are +/- this value in days till expire/expired "
                             "will be alerted. Records will be alerted as individual Slack messages. "
                             "Summary report and other reports will not be posted.")

    parser.add_argument("-e", "--expire_threshold", type=int,
                        help="If a value (int) is set. The days to the record's Expiration Date must be below "
                             "this threshold in order to be included in the report (Default: not set).")

    parser.add_argument("-a", "--include_all", action='store_true',
                        help="Include all records in output (verbose) if provided.")

    parser.add_argument("-i", "--include_no_expiration", action='store_false',
                        help="Also include records which has no Expiration Date set.")

    parser.add_argument("-T", "--title", type=str, default="Azure Key Vault report",
                        help="The title of the message posted in Slack or MS Teams")

    parser.add_argument("-r", "--record_types", nargs='+',
                        help="List of record types to check for. E.g. certificate secret\n"
                             "Valid types are: certificate secret key\n"
                             "Default is all: certificate secret key",
                        default="certificate secret key")

    parser.add_argument("-L", "--slack_split_chars", type=int, default=3500,
                        help="Slack message above this value will be split into multiple post messages.")

    parser.add_argument("-M", "--teams_max_chars", type=int, default=17367,
                        help="The max characters the report can have due to the MS Teams payload size limits")

    parser.add_argument("-S", "--stdout_only", action='store_true',
                        help="Only print report to stdout. No post to messagehandler (Slack or MS Teams")

    parser.add_argument("-w", "--workflow_output_file", type=str, default="output.json",
                        help="The file where the full json report will be written.")

    parser.add_argument("-H", "--workflow_output_file_in_home", action='store_true',
                        help="Write the output file to home dir instead of default '/tmp'")

    parser.add_argument("-s", "--silence", action='store_true',
                        help="If provided the workflow will run and log, but no messages to Slack or MS Teams and "
                             "no print to stdout.")

    parser.add_argument("-l", "--ssl_certs", nargs='+',
                        help="List of ssl certs to check. E.g. kv-dev kv-test")

    parser.add_argument("-R", "--ssl_include_ok", action='store_false',
                        help="If provided all SSL certs will included in the report")

    parser.add_argument("-W", "--ssl_warning_threshold", type=int, default=45,
                        help="The SSL cert expire days warning threshold.")

    parser.add_argument("-C", "--ssl_critical_threshold", type=int, default=14,
                        help="The SSL cert expire days critical threshold.")

    parser.add_argument("-t", "--report_types", nargs='+', default=["kv_report"],
                        help="Kind of report to be posted: kv_report kv_alert cert_report")

    args = parser.parse_args()

    # Log each argparse argument
    for k, v in sorted(vars(args).items()):
        logging.info(f"Argument '{k}': '{v}'")

    ####################################################################################################################

    # Determines if a webhook is set, and if it is for a Slack App or a Slack Workflow. If not we assume for MS Teams.
    if not WEBHOOK_REPORT:
        logging.warning("'WEBHOOK_REPORT' not provided. Messages will not be posted to the message handler.")

    monitoring = Monitoring(args)
    monitoring.set_webhooks(WEBHOOK_REPORT)
    if args.json_dir:
        monitoring.read_json()
        monitoring.key_vault_report()

    if args.ssl_certs:
        monitoring.ssl_cert()

    monitoring.report_full_json()
    monitoring.write_report_full_json()

    # If the 'silence' argument is provided, then we are done once the json output file is written
    if args.silence:
        return

    # If the 'stdout_only' argument is provided, the plain text markdown reports are printed to stdout, and then exit.
    # If 'teams_output', the 'stdout_only' is handled by the 'teams_alert' function instead.
    if args.stdout_only and not monitoring.teams_output:
        print(args.title)
        print(monitoring.get_md_report())
        return

    if args.report_types:
        report_types = args.report_types
        if isinstance(args.report_types, str):
            report_types = args.report_types.split()

        # Do the reporting, alerting, ssl certs checks...
        if 'kv_report' in report_types:
            out = monitoring.kv_report()
            if out:
                print(out)

        if 'kv_alert' in report_types:
            monitoring.kv_alert()

        if 'cert_report' in report_types:
            monitoring.ssl_cert_report()

        monitoring.post_payloads()

    # If success and 'WEBHOOK_NOTIFY' is provided
    # an additional notify will be posted to the 'WEBHOOK_NOTIFY' webhook
    if monitoring.get_success() and WEBHOOK_NOTIFY:
        logging.info(f"Trigger additional alert about new report message(s)...")
        alert = message_handler.MessageHandler(WEBHOOK_NOTIFY)
        alert.post_payload()


########################################################################################################################


if __name__ == '__main__':
    # The actual report will be posted to the webhook exported in
    # the following environment variable
    WEBHOOK_REPORT = os.getenv("WEBHOOK_REPORT")

    # When all the reports have been posted, an additional POST is performed
    # to the webhook exported in following environment variable:
    WEBHOOK_NOTIFY = os.getenv("WEBHOOK_NOTIFY")

    # The value of the name key in the full json logfile
    WORKFLOW_OUTPUT_NAME = os.getenv("WORKFLOW_OUTPUT_NAME", "")

    # The value of the github_repo name key in the full json logfile
    GITHUB_REPOSITORY = os.getenv("GITHUB_REPOSITORY", "")

    # These Azure environment variables will be used in the full json logfile
    AZURE_CLIENT_ID = os.getenv("AZURE_CLIENT_ID")
    AZURE_SUBSCRIPTION_ID = os.getenv("AZURE_SUBSCRIPTION_ID")
    AZURE_TENANT_ID = os.getenv("AZURE_TENANT_ID")

    main()
