#  _  __
# | |/ /___ ___ _ __  ___ _ _ ®
# | ' </ -_) -_) '_ \/ -_) '_|
# |_|\_\___\___| .__/\___|_|
#              |_|
#
# Keeper Commander
# Copyright 2025 Keeper Security Inc.
# Contact: ops@keepersecurity.com
#

from __future__ import annotations
import argparse
import json
import logging
import os.path
import re

from enum import Enum
from itertools import chain
from pathlib import Path
from typing import Any, Dict, Optional, List, Union

from ..base import Command, GroupCommand
from ..ksm import KSMCommand
from ..pam import gateway_helper
from ..pam.config_helper import pam_configurations_get_all
from ..recordv3 import RecordAddCommand
from ..record_edit import RecordAddCommand as RecordEditAddCommand
from ..record_edit import RecordUploadAttachmentCommand
from ..tunnel.port_forward.TunnelGraph import TunnelDAG
from ..tunnel.port_forward.tunnel_helpers import get_keeper_tokens
from ... import api, attachment, crypto, utils, vault, vault_extensions, \
    record_facades, record_management
from ...display import bcolors
from ...error import CommandError
from ...importer import imp_exp
from ...importer.importer import SharedFolder, Permission
from ...params import KeeperParams, LAST_FOLDER_UID, LAST_SHARED_FOLDER_UID
from ...proto import record_pb2, APIRequest_pb2, enterprise_pb2
from ...recordv3 import RecordV3
from ...subfolder import BaseFolderNode


class PAMProjectCommand(GroupCommand):
    def __init__(self):
        super(PAMProjectCommand, self).__init__()
        self.register_command("import", PAMProjectImportCommand(), "Import PAM Project", "i")


PAM_RECORD_TYPES = ("pamDatabase", "pamDirectory", "pamMachine", "pamUser", "pamRemoteBrowser")
PAM_RESOURCES_RECORD_TYPES = ("pamDatabase", "pamDirectory", "pamMachine", "pamRemoteBrowser")
PAM_ROTATION_TYPES = ("pamDatabase", "pamDirectory", "pamMachine", "pamUser")
PAM_CONFIG_TYPES = ("pamNetworkConfiguration", "pamAwsConfiguration", "pamAzureConfiguration", "pamDomainConfiguration", "pamGcpConfiguration", "pamOciConfiguration")
PAM_ENVIRONMENT_TYPES = ("local", "aws", "azure", "domain", "gcp", "oci")

PROJECT_IMPORT_MIN_JSON_TEMPLATE = """{ "project": "Project 1" }"""
PROJECT_IMPORT_JSON_TEMPLATE = """
{
    "project": "Project 1",
    "shared_folder_users": {
        "manage_users": true,
        "manage_records": true,
        "can_edit": true,
        "can_share": true,
        "permissions": [
            {"name": "Team1", "manage_users": false, "manage_records": false},
            {"name": "user1@example.com", "manage_users": true, "manage_records": true}
        ]
    },
    "shared_folder_resources": {
        "permissions": [{"name": "Team1", "manage_users": false, "manage_records": false}]
    },
    "pam_configuration": {
        "environment": "local",
        "title": "Project 1 PAM Configuration",
        "connections": "on",
        "rotation": "on",
        "tunneling": "on",
        "remote_browser_isolation": "on"
    },
    "pam_data": {
        "resources": [
            { "type": "pamMachine", "title": "RDP Machine",
                "pam_settings": {"options" : {}, "connection" : {}},
                "users": [
                    {"type": "pamUser", "login": "admin1", "password": "xyz"},
                    {"type": "pamUser", "login": "user1", "password": "abc"}
                ]
            },
            { "type": "pamDatabase", "title": "PAM DB1", "pam_settings": {}, "users": [] },
            { "type": "pamDirectory", "title": "PAM AD1", "pam_settings": {}, "users": [] },
            { "type": "pamRemoteBrowser", "title": "PAM RBI/HTTP", "pam_settings": {}, "users": [] }
        ],
        "users": [
            { "type": "pamUser", "title": "PAM User1", "login": "pamuser1", "password": "qwer1234", "rotation_settings":{} },
            { "type": "login", "title": "RBI user1", "login": "user1@hotmail.com", "password": "qwer5678", "otpauth://totp/TestService:user1?secret=JBSWY3DPEHPK3PXP&issuer=TestService&algorithm=SHA1&digits=6&period=30" }
        ]
    }
}
"""

# PAMConfigurationNewCommand moves pamConfig v6 record to shared_folder_users
# Records will be split: pamUser types into users folder, the rest in resources
# Autogenerated options based on project name:
# "gateway": {
#     "name": "Project 1 Gateway",
#     "pam_config_name": "Project 1 PAM Config"
#     "ksm_app_name": "Project 1 KSM App"
# }


class PAMProjectImportCommand(Command):
    parser = argparse.ArgumentParser(prog="pam project import")
    parser.add_argument("--name", "-n", required=False, dest="project_name", action="store", help="Project name.")
    parser.add_argument("--filename", "-f", required=False, dest="file_name", action="store", help="File to load import data from.")
    parser.add_argument("--dry-run", "-d", required=False, dest="dry_run", action="store_true", default=False, help="Test import without modifying vault.")
    parser.add_argument("--sample-data", "-s", required=False, dest="sample_data", action="store_true", default=False, help="Generate sample data.")
    parser.add_argument("--show-template", "-t", required=False, dest="show_template", action="store_true", default=False, help="Print JSON template required for manual import.")
    # parser.add_argument("--force", "-e", required=False, dest="force", action="store_true", default=False, help="Force data import (re/configure later)")
    parser.add_argument("--output", "-o", required=False, dest="output", action="store", choices=["token", "base64", "json"], default="base64", help="Output format (token: one-time token, config: base64/json)")

    def get_parser(self):
        return PAMProjectImportCommand.parser

    def get_extra_args(self, excluded, **kwargs):
        # Get long option name from variable name
        def get_option_name(var_name):
            for action in self.parser._actions:
                if var_name in action.dest:  # Check if variable matches destination name
                    if action.option_strings and isinstance(action.option_strings, list):
                        return action.option_strings[0]  # Prefer long name
            return var_name

        excluded = [excluded] if isinstance(excluded, str) else excluded
        excluded = [] if not isinstance(excluded, list) else excluded

        args, _ = self.parser.parse_known_args()
        args = vars(args)  # default values here - get actual from kwargs
        extras = [f"{get_option_name(key)}={kwargs.get(key, value)}" 
                  for i, (key, value) in enumerate(args.items())
                  if key not in excluded and kwargs.get(key, value) != value]
        return extras

    def execute(self, params, **kwargs):
        # Create objects in following order:
        # Shared Folder, KSM Application, Gateway/Controller,
        # PAM Configuration, Records or Examples (if needed).

        project = {"options": {}, "data": {}, "folders": {}, "ksm_app": {}, "gateway": {}, "pam_config": {}}
        project["options"]["project_name"] = kwargs.get("project_name", "") or ""
        project["options"]["file_name"] = kwargs.get("file_name", "") or ""
        project["options"]["dry_run"] = kwargs.get("dry_run", False)
        project["options"]["sample_data"] = kwargs.get("sample_data", False)
        project["options"]["show_template"] = kwargs.get("show_template", False)
        project["options"]["force"] = kwargs.get("force", False)
        project["options"]["output"] = kwargs.get("output", "") or ""

        # --show-template|-t is highest priority
        if project["options"]["show_template"] is True:
            print(PROJECT_IMPORT_JSON_TEMPLATE)
            extra = self.get_extra_args(["show_template"], **kwargs)
            if extra: 
                logging.warning(f"{bcolors.WARNING}Warning: --show-template|-t overrides all other options {extra}{bcolors.ENDC}")
            return

        # --sample-data|-s is highest priority after --show-template|-t
        # and resets all other options (--show-template|-t is already processed)
        if project["options"]["sample_data"] is True:
            if not project["options"]["project_name"]:
                project["options"]["project_name"] = "Discovery Playground"
            project["options"]["file_name"] = ""
            # project["options"]["dry_run"] = False  # dry-run is allowed
            extra = self.get_extra_args(["sample_data", "dry_run"], **kwargs)
            if extra: 
                logging.warning(f"{bcolors.WARNING}Warning: --sample-data|-s overrides other options {extra}{bcolors.ENDC}")

        if project["options"]["sample_data"] is False:
            if project["options"]["file_name"] == "":
                print("the following arguments are required: --filename/-f")
                return

            if project["options"]["file_name"] != "" and os.path.isfile(project["options"]["file_name"]):
                with open(project["options"]["file_name"], encoding="utf-8") as f:
                    project["data"] = json.load(f)

        # Verify min required entries - project name, etc.
        # Project name from command line overrides project name from JSON file
        # to allow importing same JSON multiple times - creating different projects
        if project["options"]["project_name"] == "":
            project["options"]["project_name"] = project["data"].get("project", "") or ""
        if project["options"]["project_name"] == "":
            logging.warning(f"{bcolors.FAIL}Project name is required{bcolors.ENDC} - ex. "
                            f"""command-line option: {bcolors.OKBLUE}--name="Project 1"{bcolors.ENDC} or """
                            f"""JSON property: {bcolors.OKGREEN}"project"{bcolors.ENDC}: "Project 1" """)
            return

        if project["options"].get("dry_run", False) is True:
            print("[DRY RUN] No changes will be made. This is a simulation only.")

        # 1. Create Shared Folder for the Project (incl. parent folders)
        api.sync_down(params)
        project["folders"] = self.process_folders(params, project)

        # 2. Create KSM Application
        project["ksm_app"] = self.process_ksm_app(params, project)

        # 3. Create Gateway/Controller
        project["gateway"] = self.process_gateway(params, project)

        # Create PAM Configuration
        project["pam_config"] = self.process_pam_config(params, project)

        # 5. Create Examples (if needed)
        project["options"]["sample_data"] = project["options"]["sample_data"] or project["data"].get("options", {}).get("generate_sample_data", False) or False
        if project["options"]["sample_data"] == True:
            self.generate_sample_data(params, project)
        else:
            self.process_data(params, project)

        if project["options"].get("dry_run", False) is True:
            print("[DRY RUN COMPLETE] No changes were made. All actions were validated but not executed.")
            return

        res = {
            "access_token": project["gateway"].get("gateway_token", ""),
            "device_uid": project["gateway"].get("gateway_uid", ""),
            "shared_folder_resources_uid": project["folders"].get("resources_folder_uid", ""),
            "shared_folder_users_uid": project["folders"].get("users_folder_uid", ""),
            "note": "Ensure that the team or users have role permission to access connections or tunnels"
        }
        print(json.dumps(res, indent=2))
        print("Follow the official Keeper documentation on how to use "
              "the access_token during a Gateway install or reconfiguration: "
              "https://docs.keeper.io/en/keeperpam/privileged-access-manager/getting-started/gateways")

    PAM_ROOT_FOLDER_NAME = "PAM Environments"

    def process_folders(self, params, project: dict) -> dict:
        res = {
            "root_folder_target": self.PAM_ROOT_FOLDER_NAME,
            "root_folder": "",
            "root_folder_uid": "",
            "project_folder_target": project["options"]["project_name"],
            "project_folder": "",
            "project_folder_uid": "",
            "resources_folder": f"""{project["options"]["project_name"]} - Resources""",
            "resources_folder_uid": "",
            "users_folder": f"""{project["options"]["project_name"]} - Users""",
            "users_folder_uid": ""
        }

        # Project structure:
        # PAM Environments > Project 1 (shared) > Resources - All except PAMUser: Machine, DB, Directory, Browser
        # PAM Environments > Project 1 (shared) > Users - PAMUser ONLY: All other types go into Resources

        # if project["data"].get("tool_version", "") != "": # CLI generated export else: # Manually generated import file

        # FolderListCommand().execute(params, folders_only=True, pattern="/")
        folders = self.find_folders(params, "", res["root_folder_target"], False)
        if folders:
            # select first non-root non-shared sub/folder
            folders = [x for x in folders if x.type == x.UserFolderType]
            if len(folders) > 1:
                logging.warning(f"""Multiple user folders ({len(folders)}) match folder name "{res["root_folder_target"]}" """
                                f" using first match with UID: {bcolors.OKGREEN}{folders[0].uid}{bcolors.ENDC}")
                folders = folders[:1]
        res["root_folder"] = res["root_folder_target"]
        if folders:
            res["root_folder_uid"] = str(folders[0].uid)
        elif project["options"].get("dry_run", False) is not True:
            # FolderMakeCommand().execute(params, user_folder=True, folder=f"""/{res["root_folder_target"]}""")
            fuid = self.create_subfolder(params, res["root_folder_target"])
            res["root_folder_uid"] = fuid

        # find available project folder - incr. numeric suffix until distinct name found
        res["project_folder"] = res["project_folder_target"]
        if res["root_folder_uid"]:
            START_INDEX: int = 1
            n: int = START_INDEX
            while True:
                folder_name = res["project_folder_target"] if n <= START_INDEX else f"""{res["project_folder_target"]} #{n}"""
                folders = self.find_folders(params, res["root_folder_uid"], folder_name, False)
                folders = [x for x in folders if x.type == x.UserFolderType]
                n += 1
                if len(folders) > 0:
                    continue
                res["project_folder"] = folder_name
                if project["options"].get("dry_run", False) is not True:
                    # FolderMakeCommand().execute(params, shared_folder=True, folder=f"""/{res["root_folder"]}/{res["project_folder"]}""")
                    fuid = self.create_subfolder(params, folder_name=res["project_folder"], parent_uid=res["root_folder_uid"])
                    res["project_folder_uid"] = fuid

                    puid = res["project_folder_uid"]
                    sfn = project["data"].get("shared_folder_resources", None)
                    fname = sfn["folder_name"] if isinstance(sfn, dict) and isinstance(sfn.get("folder_name", None), str) else ""
                    fname = fname.strip() or f"""{res["project_folder"]} - Resources"""
                    fperm, rperm = self.get_folder_permissions(users_folder=False, data=project["data"])
                    fuid = self.create_subfolder(params, folder_name=fname, parent_uid=puid, permissions=fperm)
                    res["resources_folder_uid"] = fuid

                    sfn = project["data"].get("shared_folder_users", None)
                    fname = sfn["folder_name"] if isinstance(sfn, dict) and isinstance(sfn.get("folder_name", None), str) else ""
                    fname = fname.strip() or f"""{res["project_folder"]} - Users"""
                    fperm, uperm = self.get_folder_permissions(users_folder=True, data=project["data"])
                    fuid = self.create_subfolder(params, folder_name=fname, parent_uid=puid, permissions=fperm)
                    res["users_folder_uid"] = fuid

                    # add users and teams
                    self.verify_users_and_teams(params, rperm + uperm)
                    self.add_folder_permissions(params, res["resources_folder_uid"], rperm)
                    self.add_folder_permissions(params, res["users_folder_uid"], uperm)
                break

        if project["options"].get("dry_run", False) is True:
            if res["root_folder_uid"]:
                print(f"""Will use existing PAM root folder: {res["root_folder_uid"]} {res["root_folder"]}""")
            else:
                print(f"""Will create new PAM root folder: {res["root_folder_target"]}""")
            print(f"""Will create new Project folder: {res["project_folder"]}""")
        else:
            api.sync_down(params)

        return res

    def process_ksm_app(self, params, project: dict) -> dict:
        res = {
            "app_name_target": "",
            "app_name": "",
            "app_uid": ""
        }

        res["app_name_target"] = self.get_property(project["data"], "pam_configuration", "ksm_app_name", "")
        if not res["app_name_target"]:
            res["app_name_target"] = project["options"]["project_name"] + " Application"

        # Get KSM App names list
        app_titles = set()
        rs = api.communicate_rest(params, None, "vault/get_applications_summary",
                                  rs_type=APIRequest_pb2.GetApplicationsSummaryResponse)  # pylint: disable=E1101
        if isinstance(rs, APIRequest_pb2.GetApplicationsSummaryResponse):  # pylint: disable=E1101
            for x in rs.applicationSummary:  # type: ignore
                app_record = vault.KeeperRecord.load(params, utils.base64_url_encode(x.appRecordUid))
                if isinstance(app_record, vault.ApplicationRecord):
                    app_titles.add(app_record.title)

        # Find unique KSM App Name
        n = 1
        app_name = res["app_name_target"]
        while app_name in app_titles:
            n += 1
            app_name = f"""{res["app_name_target"]} #{n}"""
        res["app_name"] = app_name

        if project["options"].get("dry_run", False) is True:
            print(f"""Will create new KSM application: {res["app_name"]}""")
            return res

        # Create KSM App
        res["app_uid"] = self.create_ksm_app(params, res["app_name"])
        for sf_uid in [project["folders"].get("resources_folder_uid", ""),
                       project["folders"].get("users_folder_uid", "")]:
            if sf_uid.strip():
                KSMCommand().execute(params,
                                     command=("secret", "add"),
                                     app=res["app_uid"],
                                     secret=[sf_uid], editable=True)

        api.sync_down(params)
        return res

    def process_gateway(self, params, project: dict) -> dict:
        res = {
            "gateway_name_target": "",
            "gateway_name": "",
            "gateway_token": "",  # one-time token or config-b64
            "gateway_device_token": "",
            "gateway_uid": ""
        }

        res["gateway_name_target"] = self.get_property(project["data"], "pam_configuration", "gateway_name", "")
        if not res["gateway_name_target"]:
            res["gateway_name_target"] = project["options"]["project_name"] + " Gateway"

        # Find unique Gateway name
        n = 1
        gws = gateway_helper.get_all_gateways(params)
        gw_names = [x.controllerName for x in gws]
        gw_name = res["gateway_name_target"]
        while gw_name in gw_names:
            n += 1
            gw_name = f"""{res["gateway_name_target"]} #{n}"""
        res["gateway_name"] = gw_name

        if project["options"].get("dry_run", False) is True:
            print(f"""Will create new Gateway: {res["gateway_name"]}""")
            return res

        # Create new Gateway - PAMCreateGatewayCommand()
        token_format = None if project["options"]["output"] == "token" else "b64"
        ksm_app_uid = project["ksm_app"]["app_uid"]
        gw = self.create_gateway(
            params,
            gateway_name=res["gateway_name"],
            ksm_app=ksm_app_uid,
            config_init=token_format)

        if token_format is None:
            res["gateway_token"] = gw[0].get("oneTimeToken", "") if gw and gw_names else ""  # OTT
        else:
            res["gateway_token"] = gw[0].get("config", "") if gw and gw_names else ""  # Config
            if project["options"]["output"] == "json":
                res["gateway_token"] = json.loads(utils.base64_url_decode(res["gateway_token"]))
        res["gateway_device_token"] = gw[0].get("deviceToken", "") if gw and gw_names else ""

        # controller_uid is not returned by vault/app_client_add
        gws = gateway_helper.get_all_gateways(params)
        gw_names = [x.controllerUid for x in gws if x.deviceToken == res["gateway_device_token"]]
        res["gateway_uid"] = utils.base64_url_encode(gw_names[0]) if gw_names else ""
        # gateway_helper.remove_gateway(params, utils.base64_url_decode(res["gateway_uid"]))

        api.sync_down(params)
        return res

    def process_pam_config(self, params, project: dict) -> dict:
        from ..discoveryrotation import PAMConfigurationNewCommand
        res:Dict[str, Any] = {
            "pam_config_name_target": "",
            "pam_config_name": "",
            "pam_config_uid": ""
        }

        res["pam_config_name_target"] = self.get_property(project["data"], "pam_configuration", "title", "")
        if not res["pam_config_name_target"]:
            res["pam_config_name_target"] = project["options"]["project_name"] + " Configuration"

        # Find unique PAM Configuration name
        n = 1
        pams = pam_configurations_get_all(params)
        pam_names = [json.loads(x.get("data_unencrypted", "{}")).get("title", "") for x in pams]
        pam_name = res["pam_config_name_target"]
        while pam_name in pam_names:
            n += 1
            pam_name = f"""{res["pam_config_name_target"]} #{n}"""
        res["pam_config_name"] = pam_name

        if project["options"].get("dry_run", False) is True:
            print(f"""Will create new PAM Configuration: {res["pam_config_name"]}""")
            return res

        # Create new PAM Configuration/Environment: PamConfigEnvironment
        args = {
            "gateway": project["gateway"].get("gateway_uid", ""),
            "gateway_uid": project["gateway"].get("gateway_uid", ""),
            "shared_folder_uid": project["folders"].get("users_folder_uid", "")
        }
        pam_cfg = project["data"].get("pam_configuration", {})
        pce = PamConfigEnvironment("", pam_cfg, args["gateway_uid"], args["shared_folder_uid"])
        pce.title = res["pam_config_name"]  # adjusted title
        if project["options"].get("sample_data", False):
            # -s | --sample-data option overrides json data
            args.update({
                "config_type": "local",
                "environment": "local",
                "title": res["pam_config_name"],
                "port_mapping": ["2222=ssh"],
                # "network_cidr": "192.168.1.0/24",
                "network_id": "discovery-net",
                "connections": "on",
                "tunneling": "on",
                "rotation": "on",
                "remotebrowserisolation": "on",
                "recording": "on",
                "typescriptrecording": "on"
            })
        else:
            if pce.port_mapping: args["port_mapping"] = pce.port_mapping
            args.update({
                "config_type": pce.environment,
                "environment": pce.environment,
                "title": pce.title,
                "connections": pce.connections,
                "tunneling": pce.tunneling,
                "rotation": pce.rotation,
                "remotebrowserisolation": pce.remote_browser_isolation,
                "recording": pce.graphical_session_recording,
                "typescriptrecording": pce.text_session_recording
            })

            if pce.environment == "local":
                if pce.network_cidr: args["network_cidr"] = pce.network_cidr
                if pce.network_id: args["network_id"] = pce.network_id
            elif pce.environment == "aws":
                if pce.aws_id: args["aws_id"] = pce.aws_id
                if pce.aws_access_key_id: args["access_key_id"] = pce.aws_access_key_id
                if pce.aws_secret_access_key: args["access_secret_key"] = pce.aws_secret_access_key
                if pce.aws_region_names: args["region_names"] = pce.aws_region_names
            elif pce.environment == "azure":
                if pce.az_entra_id: args["azure_id"] = pce.az_entra_id
                if pce.az_client_id: args["client_id"] = pce.az_client_id
                if pce.az_client_secret: args["client_secret"] = pce.az_client_secret
                if pce.az_subscription_id: args["subscription_id"] = pce.az_subscription_id
                if pce.az_tenant_id: args["tenant_id"] = pce.az_tenant_id
                if pce.az_resource_groups: args["resource_groups"] = pce.az_resource_groups
            elif pce.environment == "domain":
                if pce.dom_domain_id: args["domain_id"] = pce.dom_domain_id
                if pce.dom_hostname: args["domain_hostname"] = pce.dom_hostname
                if pce.dom_port: args["domain_port"] = pce.dom_port
                if pce.dom_use_ssl is not None: args["domain_use_ssl"] = pce.dom_use_ssl
                if pce.dom_scan_dc_cidr is not None: args["domain_scan_dc_cidr"] = pce.dom_scan_dc_cidr
                if pce.dom_network_cidr: args["domain_network_cidr"] = pce.dom_network_cidr
                if pce.admin_credential_ref:
                    args["domain_administrative_credential"] = pce.admin_credential_ref
                    args["force_domain_admin"] = True  # add now - ACL link later
                if pce.dom_administrative_credential:  # to be resolved later
                    res["pam_config_object"] = pce
            elif pce.environment == "gcp":
                if pce.gcp_id: args["gcp_id"] = pce.gcp_id
                if pce.gcp_service_account_key: args["service_account_key"] = pce.gcp_service_account_key
                if pce.gcp_google_admin_email: args["google_admin_email"] = pce.gcp_google_admin_email
                if pce.gcp_region_names: args["region_names"] = pce.gcp_region_names
            elif pce.environment == "oci":
                if pce.oci_id: args["oci_id"] = pce.oci_id
                if pce.oci_admin_id: args["oci_admin_id"] = pce.oci_admin_id
                if pce.oci_admin_public_key: args["oci_admin_public_key"] = pce.oci_admin_public_key
                if pce.oci_admin_private_key: args["oci_admin_private_key"] = pce.oci_admin_private_key
                if pce.oci_tenancy: args["oci_tenancy"] = pce.oci_tenancy
                if pce.oci_region: args["oci_region"] = pce.oci_region

            if pce.default_rotation_schedule: args["default_schedule"] = pce.default_rotation_schedule

        res["pam_config_uid"] = PAMConfigurationNewCommand().execute(params, **args)
        api.sync_down(params)

        # add scripts and attachments after record create
        if pce.attachments:
            files = [x.file for x in pce.attachments.attachments if x.file]
            if files:
                ruac = RecordUploadAttachmentCommand()
                ruac.execute(params, record=res["pam_config_uid"], file=files)
        if pce.scripts:
            no_admin_creds = [x for x in pce.scripts.scripts if not x.additional_credentials]
            if no_admin_creds:
                api.sync_down(params)
                add_pam_scripts(params, res["pam_config_uid"], no_admin_creds)
            # if additional_credentials present - wait for UIDs...
            if any(x.additional_credentials for x in pce.scripts.scripts):
                res["pam_config_object"] = pce

        return res

    def generate_sample_data(self, params, project: dict):
        if project["options"].get("dry_run", False) is True:
            print("Will generate sample data here...")
            return

        # self.generate_simple_content_data(params, project)
        self.generate_discovery_playground_data(params, project)

    def generate_discovery_playground_data(self, params, project: dict):
        """ Generate data that works with discovery-playground docker setup """
        from ..tunnel_and_connections import PAMTunnelEditCommand
        from ..discoveryrotation import PAMCreateRecordRotationCommand

        # PUBLIC_KEY = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC0bH13XfBiKcej3/W"\
        # "mnc7GYbx+B+hmfYTaDFqfJ/vEGy3HTSz2t5nDb3+S1clBcCmse5FzEA7aXC3cZXurGBH"\
        # "irz2Ud8wCL2t95cJnrkzfft7lsILnchm0J0Y0TyDW42gLj1JWh/E5qQyUxF0F6xEBKcy"\
        # "5cYwlgtkBcrkF1xdpuTKTMBg+xjB9XSlvLv+4rwZ448tvyILuw4DcIZDWjNxn1v+a/43"\
        # "ybhUNjGdd6zeR1ZdfB6O209VU1V0zTNS/jGsKPDK03vmJ1j42S/ZyNZ16CKDmsixhSVI"\
        # "aZ+qNOQx4eF6l/cavX+LAm94jPFZSsjr3BdE6jOZhJN+XWBmIpYd9 linuxuser@local"

        PRIVATE_KEY = "-----BEGIN OPENSSH PRIVATE KEY-----\\n"\
        "b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABFwAAAAdzc2gtcn\\n"\
        "NhAAAAAwEAAQAAAQEAtGx9d13wYinHo9/1pp3OxmG8fgfoZn2E2gxanyf7xBstx00s9reZ\\n"\
        "w29/ktXJQXAprHuRcxAO2lwt3GV7qxgR4q89lHfMAi9rfeXCZ65M337e5bCC53IZtCdGNE\\n"\
        "8g1uNoC49SVofxOakMlMRdBesRASnMuXGMJYLZAXK5BdcXabkykzAYPsYwfV0pby7/uK8G\\n"\
        "eOPLb8iC7sOA3CGQ1ozcZ9b/mv+N8m4VDYxnXes3kdWXXwejttPVVNVdM0zUv4xrCjwytN\\n"\
        "75idY+Nkv2cjWdegig5rIsYUlSGmfqjTkMeHhepf3Gr1/iwJveIzxWUrI69wXROozmYSTf\\n"\
        "l1gZiKWHfQAAA8j5NtJt+TbSbQAAAAdzc2gtcnNhAAABAQC0bH13XfBiKcej3/Wmnc7GYb\\n"\
        "x+B+hmfYTaDFqfJ/vEGy3HTSz2t5nDb3+S1clBcCmse5FzEA7aXC3cZXurGBHirz2Ud8wC\\n"\
        "L2t95cJnrkzfft7lsILnchm0J0Y0TyDW42gLj1JWh/E5qQyUxF0F6xEBKcy5cYwlgtkBcr\\n"\
        "kF1xdpuTKTMBg+xjB9XSlvLv+4rwZ448tvyILuw4DcIZDWjNxn1v+a/43ybhUNjGdd6zeR\\n"\
        "1ZdfB6O209VU1V0zTNS/jGsKPDK03vmJ1j42S/ZyNZ16CKDmsixhSVIaZ+qNOQx4eF6l/c\\n"\
        "avX+LAm94jPFZSsjr3BdE6jOZhJN+XWBmIpYd9AAAAAwEAAQAAAQAEs0DV5iOxgviGKEfC\\n"\
        "syC9+7GiSa8M7UWTop4nwEearvSTcrGME3HIU035AGQrHFkEx8rpuvTc5mlBcRlc9mMQGA\\n"\
        "c1wdf8N8nU/UvO6w3Qn4IyBjx0YbB4VRkxZ3a2pZtbyO+MFopUhlCWfY98BhXEa7DY8ebR\\n"\
        "p798fkWCRYpNtDyja2m0zrFo6Kp0PusmAXWnu5z4SLgpdNKIaz+6AX+vQpv2QTTpunzGUr\\n"\
        "XvhlLpLhK5sOPhR88VuddNKJFZi7SzNUC3DW66NdtU8jVeTKOgOB8fdvzwkX5AgAFpj/cr\\n"\
        "MmbS5GpkrKpVjkWXfTxAit+S1Ykg/ay6po4y8s9RHjsxAAAAgQCPof49LmwUJuBiheAklQ\\n"\
        "fcxCv4CnGvT926FueqADuN2g85R8EjOQ7qB0xtZckIflyqMnCVEiA9D6m8LUtEAmB9nC2x\\n"\
        "5Iz+uNByfadxthAgQXBc1qCm8Q0CCwKGE4LzshugdJap5d4i5sOM8pvNb9lo81LjXjzBw9\\n"\
        "3aNR5cPxH1uwAAAIEA8An6rWHpq494jjWdbyKI65qgBAIuIHTGxhonze5q0mYQSkor9R3k\\n"\
        "0w1ZPzOI8U78qpzGmL7hKa5QT5SOYsTffb8ofYTky0Agbqo1Ax8JK4+JytC8u6Pjc4G1U/\\n"\
        "3Njxu2aPT0xEsIxdVdDqT0sbrY3Cmn2PPr1MWM2xYb/PS2l2UAAACBAMBruM9OswMXmJ6/\\n"\
        "MClr0X9JfqWSNMKvOEYnoCmroGnjNBbf+66U9a6ecDSXNF8EMCG1pDSVHwtTIb2vUUEnwW\\n"\
        "MxQ33Xl8pUHmP94FqD8wrhZta/YRWzPeQs6LOBGoAFoSJBhALIhjlj48HW1TqtAJ+TuiGU\\n"\
        "4nTd/dKHHH5aNmo5AAAAD2xpbnV4dXNlckBsb2NhbAECAw==\\n"\
        "-----END OPENSSH PRIVATE KEY-----"

        project_name = project["options"]["project_name"]
        users_folder_uid = project["folders"]["users_folder_uid"]
        resources_folder_uid = project["folders"]["resources_folder_uid"]
        pam_config_uid = project["pam_config"]["pam_config_uid"]

        encrypted_session_token, encrypted_transmission_key, _ = get_keeper_tokens(params)
        tdag = TunnelDAG(params, encrypted_session_token, encrypted_transmission_key, pam_config_uid, True)
        # if not tdag.check_tunneling_enabled_config(enable_connections=True):
        #     logging.warning(f"{bcolors.WARNING}Warning: {bcolors.ENDC} Connections are disabled by PAM Configuration!")
        # Fix: Rotation is disabled by the PAM configuration.
        tdag.set_resource_allowed(pam_config_uid, is_config=True, rotation=True, connections=True, tunneling=True, session_recording=True, typescript_recording=True, remote_browser_isolation=True)

        command = RecordAddCommand()
        pte = PAMTunnelEditCommand()

        # when NOOP rotation is added to PAMCreateRecordRotationCommand...
        # # create_noop_rotation_records
        # json_data = """{
        #     "type": "pamUser",
        #     "title": "#project_name# - NOOP Rotation User",
        #     "fields": [
        #         {"type": "login", "required": true, "value": ["noop"]},
        #         {"type": "password", "value": ["noop"]}
        #     ],
        #     "custom": [
        #         {"type": "text", "label": "NOOP", "value": [ "True" ] }
        #     ]}""".replace("#project_name#", project_name)
        # #       {"type": "script", "label": "rotationScripts", "value": [
        # #           {
        # #               "command": "pwsh.exe",
        # #               "fileRef": "D:/Download/00params.ps1",
        # #               "recordRef": []
        # #           },
        # #           {
        # #               "command": "cmd.exe",
        # #               "fileRef": "D:/Download/00params.bat",
        # #               "recordRef": []
        # #           }
        # #       ]}
        # rotation_user_uid = command.execute(params, folder = users_folder_uid, data = json_data)
        # # TO: Extract scripts from JSON: remove from record data and add after record creation
        # # NB! add_credential must be empty - NOOP records do not need additional resources
        # # RRC_BAD_REQUEST - Noop and resource cannot be both assigned
        # PAMScriptAddCommand().execute(params, record=rotation_user_uid, script="D:/Download/00params.ps1", script_command="pwsh.exe", add_credential="")
        # PAMScriptAddCommand().execute(params, record=rotation_user_uid, script="D:/Download/00params.bat", script_command="cmd.exe", add_credential="")
        # tdag.set_resource_allowed(pam_config_uid, is_config=True, rotation=True, connections=True, tunneling=True, session_recording=True, typescript_recording=True, remote_browser_isolation=True)
        # tdag.link_resource_to_config(rotation_user_uid)
        # api.sync_down(params)
        # PAMCreateRecordRotationCommand().execute(params, record_name=rotation_user_uid,
        #                                          config=pam_config_uid, noop=True,
        #                                          on_demand=True, pwd_complexity="20,4,4,4,4", enable=True, force=True)

        # create_mysql_records
        json_data = """{
            "type": "pamUser",
            "title": "#project_name# - MySQL Admin User",
            "fields": [
                {"type": "login", "required": true, "value": ["root"]},
                {"type": "password", "value": ["z@ggz?y|w#I_NFCW!41"]}
            ]}""".replace("#project_name#", project_name)
        admin_user_uid = command.execute(params, folder=users_folder_uid, data=json_data)

        json_data = """{
            "type": "pamUser",
            "title": "#project_name# - MySQL Rotation User",
            "fields": [
                {"type": "login", "required": true, "value": ["sqluser"]},
                {"type": "password", "value": ["alpine"]}
            ]}""".replace("#project_name#", project_name)
        # ,"custom": [{"type": "text", "label": "NOOP", "value": [ "True" ] }]
        rotation_user_uid = command.execute(params, folder=users_folder_uid, data=json_data)

        json_data = """{
            "type": "pamDatabase",
            "title": "#project_name# - MySQL Database",
            "fields": [
                {"type": "pamHostname", "required": true, "value": [{"hostName": "db-mysql-1","port": "3306"}]},
                {"type": "trafficEncryptionSeed", "value": []},
                {"type": "pamSettings", "value": [{
                    "connection": {
                        "protocol": "mysql",
                        "database": "salesdb",
                        "userRecords": ["#admin_user_uid#"]
                    },
                    "portForward": { "reusePort": true }
                }]}
            ]}""".replace("#project_name#", project_name).replace("#admin_user_uid#", admin_user_uid)
        database_machine_uid = command.execute(params, folder=resources_folder_uid, data=json_data)

        # Database Machine -> Config -> Enable connections/portForwards/set trafficEncryptionSeed
        tdag.link_resource_to_config(database_machine_uid)
        pte.execute(params, record=database_machine_uid, config=pam_config_uid, admin=admin_user_uid, enable_connections=True, enable_tunneling=True)
        tdag.set_resource_allowed(resource_uid=database_machine_uid, rotation=True, connections=True, tunneling=True, session_recording=True, typescript_recording=True, remote_browser_isolation=True)

        # bugfix: Apparently PAMCreateRecordRotationCommand do not create the links
        # Links: User -> Database Machine
        tdag.link_user_to_resource(admin_user_uid, database_machine_uid, True, True)
        tdag.link_user_to_resource(rotation_user_uid, database_machine_uid, False, True)
        # PAMCreateRecordRotationCommand().execute(params, record_name=admin_user_uid,admin=admin_user_uid,config=pam_config_uid, resource=database_machine_uid,on_demand=True, pwd_complexity="20,4,4,4,4", enable=True, force=True)
        PAMCreateRecordRotationCommand().execute(params, record_name=rotation_user_uid,
                                                 admin=admin_user_uid,
                                                 config=pam_config_uid, resource=database_machine_uid,
                                                 on_demand=True, pwd_complexity="20,4,4,4,4", enable=True, force=True)

        # create_ssh_with_password_records
        json_data = """{
            "type": "pamUser",
            "title": "#project_name# - SSH Admin with Password",
            "fields": [
                {"type": "login", "required": true, "value": ["linuxuser"]},
                {"type": "password", "value": ["alpine"]}
            ]}""".replace("#project_name#", project_name)
        admin_user_uid = command.execute(params, folder=users_folder_uid, data=json_data)

        json_data = """{
            "type": "pamMachine",
            "title": "#project_name# - SSH Machine with Password Access",
            "fields": [
                {"type": "pamHostname", "required": true, "value": [{"hostName": "server-ssh-with-pwd-1","port": "2222"}]},
                {"type": "trafficEncryptionSeed", "value": []},
                {"type": "pamSettings", "value": [{
                    "connection": {
                        "protocol": "ssh",
                        "userRecords": ["#admin_user_uid#"]
                    },
                    "portForward": { "reusePort": true }
                }]}
            ]}""".replace("#project_name#", project_name).replace("#admin_user_uid#", admin_user_uid)
        ssh_machine_uid = command.execute(params, folder=resources_folder_uid, data=json_data)

        # Machine -> Config -> Enable connections/portForwards/set trafficEncryptionSeed
        tdag.link_resource_to_config(ssh_machine_uid)
        pte.execute(params, record=ssh_machine_uid, config=pam_config_uid, admin=admin_user_uid, enable_connections=True, enable_tunneling=True)
        # if tdag.check_if_resource_allowed(ssh_machine_uid, "connections") != True:
        tdag.set_resource_allowed(resource_uid=ssh_machine_uid, rotation=True, connections=True, tunneling=True, session_recording=True, typescript_recording=True, remote_browser_isolation=True)

        # Admin User -> Machine; Admin User -> Rotation
        tdag.link_user_to_resource(admin_user_uid, ssh_machine_uid, True, True)
        PAMCreateRecordRotationCommand().execute(params, record_name=admin_user_uid,
                                                 admin=admin_user_uid,
                                                 config=pam_config_uid, resource=ssh_machine_uid,
                                                 on_demand=True, pwd_complexity="20,4,4,4,4", enable=True, force=True)

        # create_ssh_with_private_key_records
        json_data = """{
            "type": "pamUser",
            "title": "#project_name# - SSH Admin with Private Key",
            "fields": [
                {"type": "login", "required": true, "value": ["linuxuser"]},
                {"type": "password", "value": []},
                {"type": "secret", "label": "privatePEMKey", "value": ["#private_key#"]}
            ]}""".replace("#project_name#", project_name).replace("#private_key#", PRIVATE_KEY)
        admin_user_uid = command.execute(params, folder=users_folder_uid, data=json_data)

        json_data = """{
            "type": "pamMachine",
            "title": "#project_name# - SSH Machine with Private Key Access",
            "fields": [
                {"type": "pamHostname", "required": true, "value": [{"hostName": "server-ssh-with-key-1","port": "2222"}]},
                {"type": "trafficEncryptionSeed", "value": []},
                {"type": "pamSettings", "value": [{
                    "connection": {
                        "protocol": "ssh",
                        "userRecords": ["#admin_user_uid#"]
                    },
                    "portForward": { "reusePort": true }
                }]}
            ]}""".replace("#project_name#", project_name).replace("#admin_user_uid#", admin_user_uid)
        ssh_machine_uid = command.execute(params, folder=resources_folder_uid, data=json_data)

        # Machine -> Config -> Enable connections/portForwards/set trafficEncryptionSeed
        tdag.link_resource_to_config(ssh_machine_uid)
        pte.execute(params, record=ssh_machine_uid, config=pam_config_uid, admin=admin_user_uid, enable_connections=True, enable_tunneling=True)
        tdag.set_resource_allowed(resource_uid=ssh_machine_uid, rotation=True, connections=True, tunneling=True, session_recording=True, typescript_recording=True, remote_browser_isolation=True)

        # Admin User -> Machine; Admin User -> Rotation
        tdag.link_user_to_resource(admin_user_uid, ssh_machine_uid, True, True)
        PAMCreateRecordRotationCommand().execute(params, record_name=admin_user_uid,
                                                 admin=admin_user_uid,
                                                 config=pam_config_uid, resource=ssh_machine_uid,
                                                 on_demand=True, pwd_complexity="20,4,4,4,4", enable=True, force=True)

        # create_vnc_records
        json_data = """{
            "type": "pamUser",
            "title": "#project_name# - VNC Admin",
            "fields": [
                {"type": "login", "required": true, "value": ["vncuser"]},
                {"type": "password", "value": ["alpine"]}
            ]}""".replace("#project_name#", project_name)
        admin_user_uid = command.execute(params, folder=users_folder_uid, data=json_data)

        json_data = """{
            "type": "pamMachine",
            "title": "#project_name# - VNC Machine",
            "fields": [
                {"type": "pamHostname", "required": true, "value": [{"hostName": "server-vnc","port": "5901"}]},
                {"type": "trafficEncryptionSeed", "value": []},
                {"type": "pamSettings", "value": [{
                    "connection": {
                        "protocol": "vnc",
                        "userRecords": ["#admin_user_uid#"]
                    },
                    "portForward": { "reusePort": true }
                }]}
            ]}""".replace("#project_name#", project_name).replace("#admin_user_uid#", admin_user_uid)
        machine_uid = command.execute(params, folder=resources_folder_uid, data=json_data)

        # Machine -> Config -> Enable connections/portForwards/set trafficEncryptionSeed
        tdag.link_resource_to_config(machine_uid)
        pte.execute(params, record=machine_uid, config=pam_config_uid, admin=admin_user_uid, enable_connections=True, enable_tunneling=True)
        tdag.set_resource_allowed(resource_uid=machine_uid, rotation=True, connections=True, tunneling=True, session_recording=True, typescript_recording=True, remote_browser_isolation=True)

        # Admin User -> Machine; Admin User
        tdag.link_user_to_resource(admin_user_uid, machine_uid, True, True)

        # create_rdp_records
        json_data = """{
            "type": "pamUser",
            "title": "#project_name# - RDP User",
            "fields": [
                {"type": "login", "required": true, "value": ["linuxuser"]},
                {"type": "password", "value": ["alpine"]}
            ]}""".replace("#project_name#", project_name)
        user_uid = command.execute(params, folder=users_folder_uid, data=json_data)

        json_data = """{
            "type": "pamUser",
            "title": "#project_name# - RDP Admin",
            "fields": [
                {"type": "login", "required": true, "value": ["root"]},
                {"type": "password", "value": ["rootpassword"]}
            ]}""".replace("#project_name#", project_name)
        admin_user_uid = command.execute(params, folder=users_folder_uid, data=json_data)

        json_data = """{
            "type": "pamMachine",
            "title": "#project_name# - RDP Machine",
            "fields": [
                {"type": "pamHostname", "required": true, "value": [{"hostName": "server-rdp","port": "3389"}]},
                {"type": "trafficEncryptionSeed", "value": []},
                {"type": "pamSettings", "value": [{
                    "connection": {
                        "protocol": "rdp",
                        "security": "any",
                        "ignoreCert": true,
                        "resizeMethod": "display-update",
                        "userRecords": ["#admin_user_uid#"]
                    },
                    "portForward": { "reusePort": true }
                }]}
            ]}""".replace("#project_name#", project_name).replace("#admin_user_uid#", admin_user_uid)
        machine_uid = command.execute(params, folder=resources_folder_uid, data=json_data)

        # Machine -> Config -> Enable connections/portForwards/set trafficEncryptionSeed
        tdag.link_resource_to_config(machine_uid)
        pte.execute(params, record=machine_uid, config=pam_config_uid, admin=admin_user_uid, enable_connections=True, enable_tunneling=True)
        tdag.set_resource_allowed(resource_uid=machine_uid, rotation=True, connections=True, tunneling=True, session_recording=True, typescript_recording=True, remote_browser_isolation=True)

        # Admin User -> Machine; User -> Machine
        tdag.link_user_to_resource(admin_user_uid, machine_uid, True, True)
        tdag.link_user_to_resource(user_uid, machine_uid, False, True)

        # create_rbi_record
        json_data = """{
            "type": "pamRemoteBrowser",
            "title": "#project_name# - Bing Remote Browser",
            "fields": [
                {"type": "rbiUrl", "required": true, "value": ["https://bing.com"]},
                {"type": "trafficEncryptionSeed", "value": []},
                {"type": "pamRemoteBrowserSettings", "value": [{
                    "connection": {
                        "protocol": "http",
                        "allowUrlManipulation": true,
                        "userRecords": []
                    }
                }]}
            ]}""".replace("#project_name#", project_name)
        rbi_uid = command.execute(params, folder=resources_folder_uid, data=json_data)
        tdag.link_resource_to_config(rbi_uid)
        pte.execute(params, record=rbi_uid, config=pam_config_uid,
                    enable_rotation=False, enable_connections=True, enable_tunneling=False,
                    enable_typescripts_recording=False, enable_connections_recording=True)
        # bugfix: Edit command not always populates correctly everything
        tdag.set_resource_allowed(resource_uid=rbi_uid, rotation=False, connections=True, tunneling=False, session_recording=True, typescript_recording=False, remote_browser_isolation=True)

        # Additional discovery-playground resources: postgres, mariadb, mssql, mongodb, telnet
        # create_postgresql_records
        json_data = """{
            "type": "pamUser",
            "title": "#project_name# - PostgreSQL Admin User",
            "fields": [
                {"type": "login", "required": true, "value": ["postgres"]},
                {"type": "password", "value": ["postgres"]}
            ]}""".replace("#project_name#", project_name)
        admin_user_uid = command.execute(params, folder=users_folder_uid, data=json_data)

        json_data = """{
            "type": "pamDatabase",
            "title": "#project_name# - PostgreSQL Database",
            "fields": [
                {"type": "pamHostname", "required": true, "value": [{"hostName": "db-postgres-1","port": "5432"}]},
                {"type": "trafficEncryptionSeed", "value": []},
                {"type": "databaseType", "value": ["postgresql"]},
                {"type": "pamSettings", "value": [{
                    "connection": {
                        "protocol": "postgresql",
                        "database": "postgresql",
                        "userRecords": ["#admin_user_uid#"]
                    },
                    "portForward": { "reusePort": true }
                }]}
            ]}""".replace("#project_name#", project_name).replace("#admin_user_uid#", admin_user_uid)
        database_machine_uid = command.execute(params, folder=resources_folder_uid, data=json_data)
        tdag.link_resource_to_config(database_machine_uid)
        pte.execute(params, record=database_machine_uid, config=pam_config_uid, admin=admin_user_uid, enable_connections=True, enable_tunneling=True)
        tdag.set_resource_allowed(resource_uid=database_machine_uid, rotation=True, connections=True, tunneling=True, session_recording=True, typescript_recording=True, remote_browser_isolation=True)
        tdag.link_user_to_resource(admin_user_uid, database_machine_uid, True, True)
        PAMCreateRecordRotationCommand().execute(params, record_name=admin_user_uid,
                                                 admin=admin_user_uid,
                                                 config=pam_config_uid, resource=database_machine_uid,
                                                 on_demand=True, pwd_complexity="20,4,4,4,4", enable=True, force=True)

        # create_mariadb_records
        json_data = """{
            "type": "pamUser",
            "title": "#project_name# - MariaDB Admin User",
            "fields": [
                {"type": "login", "required": true, "value": ["root"]},
                {"type": "password", "value": ["z@ggz?y|w#I_NFCW!41"]}
            ]}""".replace("#project_name#", project_name)
        admin_user_uid = command.execute(params, folder=users_folder_uid, data=json_data)

        json_data = """{
            "type": "pamUser",
            "title": "#project_name# - MariaDB Rotation User",
            "fields": [
                {"type": "login", "required": true, "value": ["max"]},
                {"type": "password", "value": ["maxpass"]}
            ]}""".replace("#project_name#", project_name)
        rotation_user_uid = command.execute(params, folder=users_folder_uid, data=json_data)

        json_data = """{
            "type": "pamDatabase",
            "title": "#project_name# - MariaDB Database",
            "fields": [
                {"type": "pamHostname", "required": true, "value": [{"hostName": "db-mariadb-1","port": "3306"}]},
                {"type": "trafficEncryptionSeed", "value": []},
                {"type": "databaseType", "value": ["mariadb"]},
                {"type": "pamSettings", "value": [{
                    "connection": {
                        "protocol": "mysql",
                        "database": "mydb",
                        "userRecords": ["#admin_user_uid#"]
                    },
                    "portForward": { "reusePort": true }
                }]}
            ]}""".replace("#project_name#", project_name).replace("#admin_user_uid#", admin_user_uid)
        database_machine_uid = command.execute(params, folder=resources_folder_uid, data=json_data)
        tdag.link_resource_to_config(database_machine_uid)
        pte.execute(params, record=database_machine_uid, config=pam_config_uid, admin=admin_user_uid, enable_connections=True, enable_tunneling=True)
        tdag.set_resource_allowed(resource_uid=database_machine_uid, rotation=True, connections=True, tunneling=True, session_recording=True, typescript_recording=True, remote_browser_isolation=True)
        tdag.link_user_to_resource(admin_user_uid, database_machine_uid, True, True)
        tdag.link_user_to_resource(rotation_user_uid, database_machine_uid, False, True)
        PAMCreateRecordRotationCommand().execute(params, record_name=rotation_user_uid,
                                                 admin=admin_user_uid,
                                                 config=pam_config_uid, resource=database_machine_uid,
                                                 on_demand=True, pwd_complexity="20,4,4,4,4", enable=True, force=True)

        # create_mssql_records
        json_data = """{
            "type": "pamUser",
            "title": "#project_name# - Microsoft SQL Server Admin User",
            "fields": [
                {"type": "login", "required": true, "value": ["sa"]},
                {"type": "password", "value": ["password"]}
            ]}""".replace("#project_name#", project_name)
        admin_user_uid = command.execute(params, folder=users_folder_uid, data=json_data)

        json_data = """{
            "type": "pamDatabase",
            "title": "#project_name# - Microsoft SQL Server Database",
            "fields": [
                {"type": "pamHostname", "required": true, "value": [{"hostName": "db-mssql","port": "1433"}]},
                {"type": "trafficEncryptionSeed", "value": []},
                {"type": "databaseType", "value": ["mssql"]},
                {"type": "pamSettings", "value": [{
                    "connection": {
                        "protocol": "sql-server",
                        "database": "master",
                        "userRecords": ["#admin_user_uid#"]
                    },
                    "portForward": { "reusePort": true }
                }]}
            ]}""".replace("#project_name#", project_name).replace("#admin_user_uid#", admin_user_uid)
        database_machine_uid = command.execute(params, folder=resources_folder_uid, data=json_data)
        tdag.link_resource_to_config(database_machine_uid)
        pte.execute(params, record=database_machine_uid, config=pam_config_uid, admin=admin_user_uid, enable_connections=True, enable_tunneling=True)
        tdag.set_resource_allowed(resource_uid=database_machine_uid, rotation=True, connections=True, tunneling=True, session_recording=True, typescript_recording=True, remote_browser_isolation=True)
        tdag.link_user_to_resource(admin_user_uid, database_machine_uid, True, True)
        PAMCreateRecordRotationCommand().execute(params, record_name=admin_user_uid,
                                                 admin=admin_user_uid,
                                                 config=pam_config_uid, resource=database_machine_uid,
                                                 on_demand=True, pwd_complexity="20,4,4,4,4", enable=True, force=True)

        # create_mongodb_records - protocol not supported yet, so only RBI currently available
        # MongoDB Wire Protocol is a simple socket-based, request-response style protocol over TCP/IP socket
        json_data = """{
            "type": "pamUser",
            "title": "#project_name# - MongoDB Admin User",
            "fields": [
                {"type": "login", "required": true, "value": ["root"]},
                {"type": "password", "value": ["root_password"]}
            ]}""".replace("#project_name#", project_name)
        admin_user_uid = command.execute(params, folder=users_folder_uid, data=json_data)

        json_data = """{
            "type": "pamUser",
            "title": "#project_name# - MongoDB Rotation User",
            "fields": [
                {"type": "login", "required": true, "value": ["user1"]},
                {"type": "password", "value": ["user1pwd"]}
            ]}""".replace("#project_name#", project_name)
        rotation_user_uid = command.execute(params, folder=users_folder_uid, data=json_data)

        json_data = """{
            "type": "pamDatabase",
            "title": "#project_name# - MongoDB Database",
            "fields": [
                {"type": "pamHostname", "required": true, "value": [{"hostName": "db-mongo","port": "27017"}]},
                {"type": "trafficEncryptionSeed", "value": []},
                {"type": "databaseType", "value": ["mongodb"]},
                {"type": "pamSettings", "value": [{
                    "connection": {
                        "protocol": "http",
                        "database": "mydatabase",
                        "userRecords": ["#admin_user_uid#"]
                    },
                    "portForward": { "reusePort": true }
                }]}
            ]}""".replace("#project_name#", project_name).replace("#admin_user_uid#", admin_user_uid)
        database_machine_uid = command.execute(params, folder=resources_folder_uid, data=json_data)
        tdag.link_resource_to_config(database_machine_uid)
        pte.execute(params, record=database_machine_uid, config=pam_config_uid, admin=admin_user_uid, enable_connections=True, enable_tunneling=True)
        tdag.set_resource_allowed(resource_uid=database_machine_uid, rotation=True, connections=True, tunneling=True, session_recording=True, typescript_recording=True, remote_browser_isolation=True)
        tdag.link_user_to_resource(admin_user_uid, database_machine_uid, True, True)
        tdag.link_user_to_resource(rotation_user_uid, database_machine_uid, False, True)
        PAMCreateRecordRotationCommand().execute(params, record_name=rotation_user_uid,
                                                 admin=admin_user_uid,
                                                 config=pam_config_uid, resource=database_machine_uid,
                                                 on_demand=True, pwd_complexity="20,4,4,4,4", enable=True, force=True)

        # create_telnet_records
        json_data = """{
            "type": "pamUser",
            "title": "#project_name# - Telnet Admin",
            "fields": [
                {"type": "login", "required": true, "value": ["user"]},
                {"type": "password", "value": ["user1pwd"]}
            ]}""".replace("#project_name#", project_name)
        admin_user_uid = command.execute(params, folder=users_folder_uid, data=json_data)

        json_data = """{
            "type": "pamMachine",
            "title": "#project_name# - Telnet Machine",
            "fields": [
                {"type": "pamHostname", "required": true, "value": [{"hostName": "server-telnet","port": "23"}]},
                {"type": "trafficEncryptionSeed", "value": []},
                {"type": "pamSettings", "value": [{
                    "connection": {
                        "protocol": "telnet",
                        "userRecords": ["#admin_user_uid#"]
                    },
                    "portForward": { "reusePort": true }
                }]}
            ]}""".replace("#project_name#", project_name).replace("#admin_user_uid#", admin_user_uid)
        ssh_machine_uid = command.execute(params, folder=resources_folder_uid, data=json_data)
        tdag.link_resource_to_config(ssh_machine_uid)
        pte.execute(params, record=ssh_machine_uid, config=pam_config_uid, admin=admin_user_uid, enable_connections=True, enable_tunneling=True)
        tdag.set_resource_allowed(resource_uid=ssh_machine_uid, rotation=True, connections=True, tunneling=True, session_recording=True, typescript_recording=True, remote_browser_isolation=True)
        tdag.link_user_to_resource(admin_user_uid, ssh_machine_uid, True, True)
        PAMCreateRecordRotationCommand().execute(params, record_name=admin_user_uid,
                                                 admin=admin_user_uid,
                                                 config=pam_config_uid, resource=ssh_machine_uid,
                                                 on_demand=True, pwd_complexity="20,4,4,4,4", enable=True, force=True)

        api.sync_down(params)

    # def generate_simple_content_data(self, params, project: dict):
    #     """ Generate one Connection and one Tunnel """
    #     from ..tunnel_and_connections import PAMTunnelEditCommand
    #     from ..discoveryrotation import PAMCreateRecordRotationCommand
    #     users_folder_uid = project["folders"]["users_folder_uid"]
    #     command = RecordAddCommand()
    #     json_data = """{
    #         "type": "pamUser",
    #         "title": "Admin User",
    #         "fields": [{"type": "login", "value": ["administrator"]}]}"""
    #     admin_user_uid = command.execute(params, folder=users_folder_uid, data=json_data, generate=True)

    #     json_data = """{
    #         "type": "pamUser",
    #         "title": "Rotation User",
    #         "fields": [{"type": "login", "value": ["user1"]}]}"""
    #     rotation_user_uid = command.execute(params, folder=users_folder_uid, data=json_data, generate=True)

    #     resources_folder_uid = project["folders"]["resources_folder_uid"]
    #     json_data = """{
    #         "type": "pamMachine",
    #         "title": "Tunnel Machine1",
    #         "fields": [
    #             {"type": "pamHostname", "value": [{"hostName": "127.0.0.1","port": "22"}]},
    #             {"type": "trafficEncryptionSeed", "value": []},
    #             {"type": "pamSettings", "value": [{"portForward": {}}]}
    #         ]}"""

    #     port_forward_machine_uid = command.execute(params, folder=resources_folder_uid, data=json_data)

    #     json_data = """{
    #         "type": "pamMachine",
    #         "title": "Connection Machine1",
    #         "fields": [
    #             {"type": "pamHostname", "value": [{"hostName": "127.0.0.1","port": "443"}]},
    #             {"type": "trafficEncryptionSeed", "value": []},
    #             {"type": "pamSettings", "value": [
    #                 {"connection":
    #                     {"protocol": "rdp", "ignore-cert": true, "security": "any", "userRecords": ["#admin_user_uid#"]}
    #                 }
    #             ]}
    #         ]}""".replace("#admin_user_uid#", admin_user_uid)
    #     connection_machine_uid = command.execute(params, folder=resources_folder_uid, data=json_data)

    #     pam_config_uid = project["pam_config"]["pam_config_uid"]
    #     encrypted_session_token, encrypted_transmission_key, _ =get_keeper_tokens(params)
    #     tdag = TunnelDAG(params, encrypted_session_token, encrypted_transmission_key, pam_config_uid, True)
    #     pte = PAMTunnelEditCommand()
    #     # Fix: Rotation is disabled by the PAM configuration.
    #     tdag.set_resource_allowed(pam_config_uid, is_config=True, rotation=True, connections=True, tunneling=True, session_recording=True, typescript_recording=True, remote_browser_isolation=True)

    #     # Connection Machine -> Config -> Enable Connections/set trafficEncryptionSeed
    #     tdag.link_resource_to_config(connection_machine_uid)
    #     pte.execute(params, record=connection_machine_uid, config=pam_config_uid, admin=admin_user_uid, enable_connections=True)
    #     # PortForward Machine -> Config -> Enable PortForwards/set trafficEncryptionSeed
    #     tdag.link_resource_to_config(port_forward_machine_uid)
    #     pte.execute(params, record=port_forward_machine_uid, config=pam_config_uid, admin=admin_user_uid, enable_tunneling=True)

    #     # bugfix: Apparently PAMCreateRecordRotationCommand do not create the links
    #     # Admin User -> Connection Machine
    #     tdag.link_user_to_resource(admin_user_uid, connection_machine_uid, True, True)
    #     # Rotation User -> Connection Machine
    #     tdag.link_user_to_resource(rotation_user_uid, connection_machine_uid, False, True)

    #     PAMCreateRecordRotationCommand().execute(params, record_name=rotation_user_uid,
    #                                              admin=admin_user_uid,
    #                                              config=pam_config_uid, resource=connection_machine_uid,
    #                                              on_demand=True, pwd_complexity="20,4,4,4,4", enable=True, force=True)
    #     api.sync_down(params)

    def get_folder_permissions(self, users_folder: bool, data: dict):
        folder_name = "shared_folder_users" if users_folder else "shared_folder_resources"
        uperm = []
        # defaults - used only if no permissions set at all
        fperm = { "manage_records": True, "manage_users": True,  "can_edit": True, "can_share": True }
        # fperm = { "manage_records": True, "manage_users": False, "can_edit": True, "can_share": False }

        sfu = data.get(folder_name, None)
        if isinstance(sfu, dict):
            perm = {
                "manage_users": sfu.get("manage_users", None),
                "manage_records": sfu.get("manage_records", None),
                "can_edit": sfu.get("can_edit", None),
                "can_share": sfu.get("can_share", None)
            }
            # if at least one permission is set - all default values change to False
            # i.e. any permisson not explicitly set will default to False
            changes = any(map(lambda x: x is not None, perm.values()))
            if changes:
                fperm = {
                    "manage_users": True if str(perm["manage_users"]).upper() == "TRUE" else False,
                    "manage_records": True if str(perm["manage_records"]).upper() == "TRUE" else False,
                    "can_edit": True if str(perm["can_edit"]).upper() == "TRUE" else False,
                    "can_share": True if str(perm["can_share"]).upper() == "TRUE" else False
                }

            perms = sfu.get("permissions", [])
            if isinstance(perms, list):
                for item in perms:
                    if isinstance(item, dict):
                        uid = item.get("uid", None)
                        name = item.get("name", None)
                        manage_users = True if str(item.get("manage_users", None)).upper() == "TRUE" else False
                        manage_records = True if str(item.get("manage_records", None)).upper() == "TRUE" else False
                        if uid is None and name is None:
                            logging.warning("Incorrect permissions - both (team) uid and (user) name cannot be empty (skipped)")
                        else:
                            uperm.append({"uid": uid, "name": name, "manage_users": manage_users, "manage_records": manage_records})
        return fperm, uperm

    def add_folder_permissions(self, params, folder_uid: str, permissions: list):
        if permissions:
            shf = SharedFolder()
            shf.uid = folder_uid # type: ignore
            shf.path = imp_exp.get_folder_path(params, folder_uid) # type: ignore
            shf.permissions = []
            for perm in permissions:
                if any(map(lambda x: x is not None, perm.values())):
                    p = Permission()
                    p.uid = perm.get("uid", None)
                    p.name = perm.get("name", None)
                    p.manage_users = perm.get("manage_users", None)
                    p.manage_records = perm.get("manage_records", None)
                    shf.permissions.append(p)
            imp_exp.import_user_permissions(params, [shf], full_sync=True)

            # # Do not use ShareFolderCommand - Legacy: 1) add user to folder /uses defaults only/ 2) grant 3) revoke
            # # ShareFolderCommand will automatically resolve uid, username or team
            # args = { "folder": folder_uid, "force": True, "user": [user] }
            # ShareFolderCommand().execute(params, **args)
            # api.sync_down(params)

            # # these merge with folder defaults (skipped/False may still default to True)
            # # hence the two step process: 1) grant 2) revoke
            # manage_records=perm.get("manage_records", False)
            # manage_users=perm.get("manage_users", False)

            # args["action"] = "grant"
            # args["manage_records"] = True if manage_records == True else False
            # args["manage_users"] = True if manage_users == True else False
            # ShareFolderCommand().execute(params, **args)

            # args["action"] = "revoke"
            # args["manage_records"] = True if manage_records == False else False
            # args["manage_users"] = True if manage_users == False else False
            # ShareFolderCommand().execute(params, **args)

    def create_subfolder(self, params, folder_name:str, parent_uid:str="", permissions:Optional[Dict]=None):
        """ Creates subfolder inside parent folder:
        either `user folder`, `shared folder` or `shared folder folder`.
        If `parent_uid == ""` then creates subfolder in root folder.
        If `permissions` is not None then creates `shared folder`.
        If `permissions` is None then creates `user folder` or `shared folder folder`
        depending on parent folder type.
        Note: Currently not possible to create sf inside another sf (throws)
        """

        name = str(folder_name or "").strip()
        base_folder = params.folder_cache.get(parent_uid, None) or params.root_folder

        shared_folder = True if permissions else False
        user_folder = True if not permissions else False  # uf or sff (split later)
        folder_uid = api.generate_record_uid()
        request: Dict[str, Any] = {
            "command": "folder_add",
            "folder_type": "user_folder",
            "folder_uid": folder_uid
            }

        if shared_folder:
            if base_folder.type in {BaseFolderNode.RootFolderType, BaseFolderNode.UserFolderType}:
                request["folder_type"] = "shared_folder"
                for perm in ["manage_users", "manage_records", "can_share", "can_edit"]:
                    if permissions and permissions.get(perm, False) == True:
                        request[perm] = True
            else:
                raise CommandError("pam", "Shared folders cannot be nested")
        elif user_folder:
            if base_folder.type in {BaseFolderNode.SharedFolderType, BaseFolderNode.SharedFolderFolderType}:
                request["folder_type"] = "shared_folder_folder"
            else:
                request["folder_type"] = "user_folder"

        if request.get("folder_type") is None:
            if base_folder.type in {BaseFolderNode.SharedFolderType, BaseFolderNode.SharedFolderFolderType}:
                request["folder_type"] = "shared_folder_folder"

        folder_key = os.urandom(32)
        encryption_key = params.data_key
        if request["folder_type"] == "shared_folder_folder":
            sf_uid = base_folder.shared_folder_uid if base_folder.type == BaseFolderNode.SharedFolderFolderType else base_folder.uid
            sf = params.shared_folder_cache[sf_uid]
            encryption_key = sf["shared_folder_key_unencrypted"]
            request["shared_folder_uid"] = sf_uid

        request["key"] = utils.base64_url_encode(crypto.encrypt_aes_v1(folder_key, encryption_key))
        if base_folder.type not in {BaseFolderNode.RootFolderType, BaseFolderNode.SharedFolderType}:
            request["parent_uid"] = base_folder.uid

        if request["folder_type"] == "shared_folder":
            request["name"] = utils.base64_url_encode(crypto.encrypt_aes_v1(name.encode("utf-8"), folder_key))
        data_dict = {"name": name}
        data = json.dumps(data_dict)
        request["data"] = utils.base64_url_encode(crypto.encrypt_aes_v1(data.encode("utf-8"), folder_key))

        api.communicate(params, request)
        api.sync_down(params)
        params.environment_variables[LAST_FOLDER_UID] = folder_uid
        if request["folder_type"] == "shared_folder":
            params.environment_variables[LAST_SHARED_FOLDER_UID] = folder_uid
        return folder_uid

    def get_property(self, data: dict, obj: str, property: str, default: str):
        data = data or {}
        dic = data.get(obj, {}) or {}
        prop = dic.get(property, default) or default
        return prop

    def find_folders(self, params, parent_uid:str, folder:str, is_shared_folder:bool) -> List[BaseFolderNode]:
        result: List[BaseFolderNode] = []
        folders = params.folder_cache if params and params.folder_cache else {}
        if not isinstance(folders, dict):
            return result

        puid = parent_uid if parent_uid else None # root folder parent uid is set to None
        matches = {k: v for k, v in folders.items() if v.parent_uid == puid and v.name == folder}
        result = [v for k, v in matches.items() if
                  (is_shared_folder and v.type == BaseFolderNode.SharedFolderType) or
                  (not is_shared_folder and v.type == BaseFolderNode.UserFolderType)]
        return result

    def create_ksm_app(self, params, app_name) -> str:
        app_record_data = {
            "title": app_name,
            "type": "app"
        }

        data_json = json.dumps(app_record_data)
        record_key_unencrypted = utils.generate_aes_key()
        record_key_encrypted = crypto.encrypt_aes_v2(record_key_unencrypted, params.data_key)

        app_record_uid_str = api.generate_record_uid()
        app_record_uid = utils.base64_url_decode(app_record_uid_str)

        data = data_json.decode("utf-8") if isinstance(data_json, bytes) else data_json
        data = api.pad_aes_gcm(data)

        rdata = bytes(data, "utf-8") # type: ignore
        rdata = crypto.encrypt_aes_v2(rdata, record_key_unencrypted)

        ra = record_pb2.ApplicationAddRequest()  # pylint: disable=E1101
        ra.app_uid = app_record_uid # type: ignore
        ra.record_key = record_key_encrypted # type: ignore
        ra.client_modified_time = api.current_milli_time() # type: ignore
        ra.data = rdata # type: ignore

        api.communicate_rest(params, ra, "vault/application_add")
        api.sync_down(params)
        return app_record_uid_str

    def create_gateway(
        self, params, gateway_name, ksm_app, config_init, ott_expire_in_min=5
    ):
        token = KSMCommand.add_client(
            params,
            app_name_or_uid=ksm_app,
            count=1,
            unlock_ip=True,
            first_access_expire_on=ott_expire_in_min,
            access_expire_in_min=None,  # None=Never, int = num of min
            client_name=gateway_name,
            config_init=config_init,
            silent=True,
            client_type=enterprise_pb2.DISCOVERY_AND_ROTATION_CONTROLLER)  # pylint: disable=E1101
        api.sync_down(params)

        return token

    def verify_users_and_teams(self, params, users_and_teams):
        api.load_available_teams(params)
        for item in users_and_teams:
            name = item.get("name", "")
            teams = [] 
            # do not use params.team_cache:
            for team in params.available_team_cache or []:
                team = api.Team(team_uid=team.get("team_uid", ""), name=team.get("team_name", ""))
                if name == team.team_uid or name.casefold() == team.name.casefold():
                    teams.append(team)
            users = []
            for user in params.enterprise.get("users", []):
                # if user["node_id"] not in node_scope: continue
                # skip: node_id, status, lock, tfa_enabled, account_share_expiration
                usr = {
                    "id": user.get("enterprise_user_id", "") or "",
                    "username": user.get("username", "") or "",
                    "name": user.get("data", {}).get("displayname", "") or ""
                }
                if name in usr.values(): users.append(usr)

            teams_users = teams + users
            num_found = len(teams_users)
            if num_found == 0:
                logging.warning(f"""Team/User: {bcolors.WARNING}"{name}"{bcolors.ENDC} - not found (skipped).""")
            elif num_found > 1:
                logging.warning(f"""Multiple matches ({num_found}) for team/user: {bcolors.WARNING}"{name}"{bcolors.ENDC} found (skipped).""")
                if logging.getLogger().getEffectiveLevel() <= logging.DEBUG:
                    msg = ""
                    for x in teams_users:
                        msg += "\n" + (f"team_uid: {x.team_uid}, name: {x.name}" if isinstance(x, api.Team) else str(x))
                    logging.debug(f"Matches from team/user lookup: {msg}")


    def process_data(self, params, project):
        """Process project data (JSON)"""
        from ..tunnel_and_connections import PAMTunnelEditCommand
        from ..discoveryrotation import PAMCreateRecordRotationCommand

        # users section is mostly RBI login users, but occasional "disconnected"
        # PAM User (ex. NOOP rotation) requires explicit record type to be set
        # also for shared users b/n ssh, vnc, rdp on same host (one pamMachine each)
        users = []
        resources = []
        # errors = 0  # --force option overrides (allows later setup)

        print("Started parsing import data...")
        pam_data = (project["data"].get("pam_data")
                    if "data" in project and isinstance(project["data"], dict)
                    else {})
        pam_data = pam_data if isinstance(pam_data, dict) else {}
        rotation_profiles = (pam_data.get("rotation_profiles")
                            if "rotation_profiles" in pam_data and isinstance(pam_data["rotation_profiles"], dict)
                            else {})
        rotation_profiles = rotation_profiles if isinstance(rotation_profiles, dict) else {}
        pam_cfg_uid = project["pam_config"]["pam_config_uid"]
        rotation_params = PamRotationParams(configUid=pam_cfg_uid, profiles=rotation_profiles)

        shfres = project["folders"].get("resources_folder_uid", "")
        shfusr = project["folders"].get("users_folder_uid", "")

        usrs = pam_data["users"] if "users" in pam_data and isinstance(pam_data["users"], list) else []
        rsrs = pam_data["resources"] if "resources" in pam_data and isinstance(pam_data["resources"], list) else []

        for user in usrs:
            rt = str(user.get("type", "")) if isinstance(user, dict) else ""
            rt = next((x for x in ("login", "pamUser") if x.lower() == rt.lower()), rt)
            if rt not in ("login", "pamUser") and isinstance(user, dict):
                pam_keys = ("private_pem_key", "distinguished_name", "connect_database", "managed", "scripts", "rotation_settings")
                if "url" in user: rt = "login"  # RBI login record
                elif any(key in user for key in pam_keys): rt = "pamUser"
            rt = next((x for x in ("login", "pamUser") if x.lower() == rt.lower()), "login")
            # If not found default to "login" which can be changed later

            if rt == "login":
                usr = LoginUserObject.load(user)
            else:
                usr = PamUserObject.load(user)
            if usr:
                users.append(usr)

        # each machine has its own users list of pamUser
        for machine in rsrs:
            rt = str(machine.get("type", "")).strip() if isinstance(machine, dict) else ""
            if rt.lower() not in (x.lower() for x in PAM_RESOURCES_RECORD_TYPES):
                prefix = "Incorrect " if rt else "Missing "
                title = str(machine.get("title", "")).strip() if isinstance(machine, dict) else ""
                logging.error(f"""{prefix} record type "{rt}" - should be one of {PAM_RESOURCES_RECORD_TYPES}, "{title}" record skipped.""")
                continue

            obj = None
            rt = rt.lower()
            if rt == "pamDatabase".lower():
                obj = PamDatabaseObject.load(machine, rotation_params)
            elif rt == "pamDirectory".lower():
                obj = PamDirectoryObject.load(machine, rotation_params)
            elif rt == "pamMachine".lower():
                obj = PamMachineObject.load(machine, rotation_params)
            elif rt == "pamRemoteBrowser".lower():
                obj = PamRemoteBrowserObject.load(machine, rotation_params)
            else:
                logging.warning(f"""Skipping unknown resource type "{rt}" """)

            if obj:
                resources.append(obj)

        # generate record UIDs used for DAG links
        for obj in chain(resources, users):
            # preserve any valid UID from JSON otherwise generate new UID
            if not(isinstance(obj.uid, str) and RecordV3.is_valid_ref_uid(obj.uid)):
                obj.uid = utils.generate_uid()
            if hasattr(obj, "users") and isinstance(obj.users, list):
                for usr in obj.users:
                    if not(isinstance(usr.uid, str) and RecordV3.is_valid_ref_uid(usr.uid)):
                        usr.uid = utils.generate_uid()

        # resolve linked object UIDs (machines and users)
        # pam_settings.connection.administrative_credentials must reference
        # one of its own users[] -> userRecords["admin_user_record_UID"]
        machines = [x for x in resources if not isinstance(x, PamRemoteBrowserObject)]
        for mach in resources:
            if not mach: continue
            admin_cred = get_admin_credential(mach)
            sftp_user = get_sftp_attribute(mach, "sftpUser")
            sftp_res = get_sftp_attribute(mach, "sftpResource")

            # sftpResourceUid could reference any machine (except RBI)
            if sftp_res:
                ruids = [x for x in machines if getattr(x, "title", None) == sftp_res]
                ruids = ruids or [x for x in machines if getattr(x, "login", None) == sftp_res]
                if len(ruids) != 1:
                    logging.warning(f"{bcolors.WARNING}{len(ruids)} matches found for sftpResource in {mach.title}.{bcolors.ENDC} ")
                ruid = getattr(ruids[0], "uid", "") if ruids else ""
                if ruid:
                    set_sftp_uid(mach, "sftpResourceUid", ruid)

            # sftpUserUid could reference any user (except RBI)
            if sftp_user:
                ruids = find_user(mach, users, sftp_user)  # try local user first
                ruids = ruids or find_user(machines, users, sftp_user)  # global search
                if len(ruids) != 1:
                    logging.warning(f"{bcolors.WARNING}{len(ruids)} matches found for sftpUser in {mach.title}.{bcolors.ENDC} ")
                ruid = getattr(ruids[0], "uid", "") if ruids else ""
                if ruid:
                    set_sftp_uid(mach, "sftpUserUid", ruid)

            # userRecordUid could reference local or users[] - resolved from userRecords
            if admin_cred:
                is_external = False
                ruids = find_user(mach, users, admin_cred)
                if not ruids:  # search all pamDirectory for external AD admin user
                    ruids = find_external_user(mach, machines, admin_cred)
                    is_external = True
                if len(ruids) != 1:
                    logging.warning(f"{bcolors.WARNING}{len(ruids)} matches found for userRecords in {mach.title}.{bcolors.ENDC} ")
                ruid = getattr(ruids[0], "uid", "") if ruids else ""
                if ruid:
                    set_user_record_uid(mach, ruid, is_external)

            # resolve machine PRS creds: additional_credentials[] -> recordRef[]
            resolve_script_creds(mach, users, resources)

            # resolve users PRS creds and user.rotation_settings.resource
            if hasattr(mach, "users") and isinstance(mach.users, list):
                for usr in mach.users:
                    if (usr and hasattr(usr, "rotation_settings") and usr.rotation_settings
                        and hasattr(usr.rotation_settings, "rotation")
                        and usr.rotation_settings.rotation):
                        if usr.rotation_settings.rotation == "general":
                            usr.rotation_settings.resourceUid = mach.uid
                            # rotation_settings.resource is always owner machine (uid)
                        elif usr.rotation_settings.rotation in ("iam_user", "scripts_only"):
                            usr.rotation_settings.resourceUid = pam_cfg_uid
                            # rotation_settings.resource is always pam config uid here
                    # resolve machine users PRS creds additional_credentials[] -> recordRef[]
                    resolve_script_creds(usr, users, resources)

            # RBI autofill_credentials -> httpCredentialsUid (rt:login/pamUser)
            if (hasattr(mach, "rbi_settings") and
                hasattr(mach.rbi_settings, "connection") and
                hasattr(mach.rbi_settings.connection, "protocol") and
                str(mach.rbi_settings.connection.protocol.value).lower() == "http"):
                if (hasattr(mach.rbi_settings.connection, "httpCredentials")
                    and mach.rbi_settings.connection.httpCredentials):
                    cred = mach.rbi_settings.connection.httpCredentials
                    cred = str(cred[0]) if isinstance(cred, list) else str(cred)

                    # RBI resources do not own any users - search global users[]
                    # connection.userRecords[] not used by RBI
                    matches = [x for x in users if getattr(x, "title", None) == cred]
                    matches = matches or [x for x in users if getattr(x, "login", None) == cred]
                    if len(matches) != 1:
                        logging.warning(f"{bcolors.WARNING}{len(matches)} matches found for RBI record {mach.title}.{bcolors.ENDC} ")
                    uid = getattr(matches[0], "uid", "") if matches else ""
                    if uid:
                        mach.rbi_settings.connection.httpCredentialsUid = [uid]

        for usr in users:
            # resolve user.rotation_settings.resource - "iam_user", "scripts_only"
            if (usr and hasattr(usr, "rotation_settings") and usr.rotation_settings
                and hasattr(usr.rotation_settings, "rotation")
                and usr.rotation_settings.rotation):
                if usr.rotation_settings.rotation == "general":
                    # rotation_settings.resource is always owner machine (uid)
                    logging.warning(f"This user {usr.title} belongs to its own machine users list (consider removal from global users list)")
                    resource = getattr(usr.rotation_settings, "resource", "")
                    if resource:
                        ruids = [x for x in machines if getattr(x, "title", None) == resource]
                        ruids = ruids or [x for x in machines if getattr(x, "login", None) == resource]
                        if ruids:
                            usr.rotation_settings.resourceUid = ruids[0].uid
                elif usr.rotation_settings.rotation in ("iam_user", "scripts_only"):
                    usr.rotation_settings.resourceUid = pam_cfg_uid
                    # rotation_settings.resource is always pam config uid here
            # resolve users PRS additional_credentials[] -> recordRef[]
            resolve_script_creds(usr, users, resources)

        # resolve PAM Config PRS additional_credentials[] -> recordRef[]
        pce = project["pam_config"].get("pam_config_object")
        if pce and pce.scripts and pce.scripts.scripts:
            resolve_script_creds(pce, users, resources)

        # resolve domain admin if Domain PAM Config
        # Domain users are the equivalent to cloud users, IAM/Azure users. The parent of the pamUser is the configuration record.
        # The user does not belong to a machine, database or directory resource.
        # so check global users[] only
        if pce and pce.environment == "domain" and pce.dom_administrative_credential:
            resolve_domain_admin(pce, users)
        # only resolve here - create after machine and user creation

        # dry run
        if project["options"].get("dry_run", False) is True:
            print("Will import file data here...")
            return

        # if errors > 0:
        #     logging.warning(f"{bcolors.WARNING}{errors} errors found.{bcolors.ENDC} ")
        #     if project["options"]["force"] is True:
        #         print("Starting data import (--force option present)")
        #     else:
        #         print("Exiting. If you want to continue use --force option")
        #         return
        print("Started importing data...")

        encrypted_session_token, encrypted_transmission_key, _ = get_keeper_tokens(params)
        tdag = TunnelDAG(params, encrypted_session_token, encrypted_transmission_key, pam_cfg_uid, True)
        pte = PAMTunnelEditCommand()
        prc = PAMCreateRecordRotationCommand()

        pdelta = 10  # progress delta (update progress stats every pdelta items)
        msg = "Start data processing "
        msg += f" {len(resources)} resources" if resources else ""
        msg += f" {len(users)} external users" if users else ""
        msg += " - This could take a while..." if len(resources) + len(users) > 0 else ""
        logging.warning(msg)

        # Create records
        if users:
            logging.warning(f"Processing external users: {len(users)}")
            for n, user in enumerate(users):  # standalone users
                user.create_record(params, shfusr)
                if n % pdelta == 0: print(f"{n}/{len(users)}")
            print(f"{len(users)}/{len(users)}\n")

        # we need pamDirectory first in case AD Admin user is used in Local pamMachine
        resources.sort(key=lambda r: r.type != "pamDirectory")
        if resources: logging.warning(f"Processing resources: {len(resources)}")
        for n, mach in enumerate(resources):
            if n % pdelta == 0: print(f"{n}/{len(resources)}")
            # Machine - create machine first to avoid error:
            # Resource <UID> does not belong to the configuration
            admin_uid = get_admin_credential(mach, True)
            mach.create_record(params, shfres)
            tdag.link_resource_to_config(mach.uid)
            if isinstance(mach, PamRemoteBrowserObject): # RBI
                args = parse_command_options(mach, True)
                pte.execute(params, config=pam_cfg_uid, silent=True, **args)
                args = parse_command_options(mach, False)
                # bugfix: RBI=True also needs connections=True to enable RBI (in web vault)
                if args.get("remote_browser_isolation", False) is True:
                    args["connections"] = True
                tdag.set_resource_allowed(**args)
            else: # machine/db/directory
                args = parse_command_options(mach, True)
                if admin_uid: args["admin"] = admin_uid
                pte.execute(params, config=pam_cfg_uid, silent=True, **args)
                if admin_uid and is_admin_external(mach):
                    tdag.link_user_to_resource(admin_uid, mach.uid, is_admin=True, belongs_to=False)
                args = parse_command_options(mach, False)
                tdag.set_resource_allowed(**args)

            # Machine - create its users (if any)
            users = getattr(mach, "users", [])
            users = users if isinstance(users, list) else []
            for user in users:
                if (isinstance(user, PamUserObject) and user.rotation_settings and
                    user.rotation_settings.rotation.lower() == "general"):
                    user.rotation_settings.resourceUid = mach.uid # DAG only
                user.create_record(params, shfusr)
                if isinstance(user, PamUserObject):  # rotation setup
                    tdag.link_user_to_resource(user.uid, mach.uid, admin_uid==user.uid, True)
                    if user.rotation_settings:
                        args = {"force": True, "config": pam_cfg_uid, "record_name": user.uid, "admin": admin_uid, "resource": mach.uid}
                        enabled = user.rotation_settings.enabled # on|off|default
                        key = {"on": "enable", "off": "disable"}.get(enabled, "")
                        if key: args[key] = True
                        # args["schedule_config"] = True  # Schedule from Configuration
                        schedule_type = user.rotation_settings.schedule.type if user.rotation_settings.schedule and user.rotation_settings.schedule.type else ""
                        if schedule_type == "on-demand":
                            args["on_demand"] = True
                        elif schedule_type == "cron":
                            if user.rotation_settings.schedule.cron:
                                args["schedule_cron_data"] = user.rotation_settings.schedule.cron
                            else:
                                logging.warning(f"{bcolors.WARNING}schedule.type=cron but schedule.cron is empty (skipped){bcolors.ENDC} ")
                        if user.rotation_settings.password_complexity:
                            args["pwd_complexity"]=user.rotation_settings.password_complexity
                        prc.execute(params, silent=True, **args)
        if resources: print(f"{len(resources)}/{len(resources)}\n")

        # add scripts with resolved additional credentials - owner records must exist
        if pce and pce.scripts and pce.scripts.scripts:
            refs = [x for x in pce.scripts.scripts if x.record_refs]
            if refs:
                api.sync_down(params)
                add_pam_scripts(params, pam_cfg_uid, refs)

        # PAM Domain Config - update domain admin creds
        if pce and pce.environment == "domain":
            if pce.admin_credential_ref:
                pcuid = project["pam_config"].get("pam_config_uid")
                pcrec = vault.KeeperRecord.load(params, pcuid) if pcuid else None
                if pcrec and isinstance(pcrec, vault.TypedRecord) and pcrec.version == 6:
                    if pcrec.record_type == "pamDomainConfiguration":
                        prf = pcrec.get_typed_field('pamResources')
                        if not prf:
                            prf = vault.TypedField.new_field('pamResources', {})
                            pcrec.fields.append(prf)
                        prf.value = prf.value or [{}]
                        prf.value[0]["adminCredentialRef"] = pce.admin_credential_ref
                        record_management.update_record(params, pcrec)
                        tdag.link_user_to_config_with_options(pce.admin_credential_ref, is_admin='on')
            else:
                logging.debug(f"Unable to resolve domain admin '{pce.dom_administrative_credential}' for PAM Domain configuration.")

        logging.debug("Done processing project data.")


class PamConfigEnvironment():
    def _initialize(self):
        self.uid: str = ""  # known after creation
        self.environment: str = ""  # local|aws|azure|domain|gcp|oci
        self.title: str = ""
        # self.gateway_name: str = "" # used externally, use controllerUid here
        # self.ksm_app_name: str = "" # used externally, use controllerUid here
        # self.application_folder_uid: str = "" # auto (Users folder) in pam_resources

        # default settings
        self.connections: str = "on"
        self.rotation: str = "on"
        self.tunneling: str = "on"
        self.remote_browser_isolation: str = "on"
        self.graphical_session_recording: str = "on"
        self.text_session_recording: str = "on"

        self.port_mapping: List[str] = []  # ex. ["2222=ssh", "33306=mysql"] for discovery, rotation etc.
        self.default_rotation_schedule: dict = {}  # "type": "On-Demand|CRON"
        self.scripts = None # PamScriptsObject - PAM Config scripts run on gateway after every rotation
        self.attachments = None # PamAttachmentsObject

        # common settings (shared across all config types)
        self.pam_resources = {} # {"folderUid": "", "controllerUid": ""} - "resourceRef": unused/legacy

        # Local environment: pamNetworkConfiguration
        self.network_id: str = ""   # required, text:networkId prefix for naming resources during discovery
        self.network_cidr: str = "" # required, text:networkCIDR network CIDR used for discovery

        # AWS environment: pamAwsConfiguration
        self.aws_id: str = ""                   # required, text:awsId
        self.aws_access_key_id: str = ""        # required, secret:accessKeyId
        self.aws_secret_access_key: str = ""    # required, secret:accessSecretKey
        self.aws_region_names: List[str] = []   # optional, multiline:regionNames

        # Azure environment: pamAzureConfiguration
        self.az_entra_id: str = ""              # required, text:azureId
        self.az_client_id: str = ""             # required, secret:clientId
        self.az_client_secret: str = ""         # required, secret:clientSecret
        self.az_subscription_id: str = ""       # required, secret:subscriptionId
        self.az_tenant_id: str = ""             # required, secret:tenantId
        self.az_resource_groups: List[str] = [] # optional, multiline:resourceGroups

        # Domain environment: pamDomainConfiguration
        self.dom_domain_id: str = ""            # required, text:pamDomainId
        self.dom_hostname: str = ""             # required, pamHostname:
        self.dom_port: str = ""                 # required, pamHostname:
        self.dom_use_ssl: bool = False          # required, checkbox:useSSL
        self.dom_scan_dc_cidr: bool = False     # optional, checkbox:scanDCCIDR
        self.dom_network_cidr: str = ""         # optional, text:networkCIDR
        self.dom_administrative_credential: str = ""  # required, existing pamUser: pamResources.value[0][adminCredentialRef]
        self.admin_credential_ref: str = ""     # UID resolved from dom_administrative_credential by record title
        # Domain Administrator User: pamUser record should have an ACL edge to the pamDomainConfiguration record with is_admin = True
        # Domain users are the equivalent to cloud users, IAM/Azure users. The parent of the pamUser is the configuration record.
        # The user does not belong to a machine, database or directory resource.

        # Google Cloud Platform (GCP) environment: pamGcpConfiguration
        self.gcp_id: str = ""                   # required, text:pamGcpId
        self.gcp_service_account_key: str = ""  # required, json:pamServiceAccountKey
        self.gcp_google_admin_email: str = ""   # required, email:pamGoogleAdminEmail
        self.gcp_region_names: List[str] = []   # required, multiline:pamGcpRegionName

        # Oracle Cloud Infrastructure (OCI) environment: pamOciConfiguration
        # NB! OCI settings subject to change:
        self.oci_id: str = ""                   # required, text:pamOciId
        self.oci_admin_id: str = ""             # required, secret:adminOcid
        self.oci_admin_public_key: str = ""     # required, secret:adminPublicKey
        self.oci_admin_private_key: str = ""    # required, secret:adminPrivateKey
        self.oci_tenancy: str = ""              # required, text:tenancyOci
        self.oci_region: str = ""               # required, text:regionOci

    def __init__(self, environment_type:str, settings:dict, controller_uid:str, folder_uid:str) -> None:
        self._initialize()
        settings = settings if isinstance(settings, dict) else {}
        environment_type = str(environment_type).strip()
        if environment_type not in PAM_ENVIRONMENT_TYPES:
            environment_type = str(settings.get("environment", "")).strip()
        if environment_type not in PAM_ENVIRONMENT_TYPES:
            logging.warning("Unrecognized environment type "\
                            f"""{bcolors.WARNING}"{environment_type}"{bcolors.ENDC} """\
                            f"""must be one of {PAM_ENVIRONMENT_TYPES} - switching to "local" """)
            environment_type = "local"
        self.environment = environment_type

        # common properties shared across all PAM config types:
        self.pam_resources = {
            "controllerUid": controller_uid,
            "folderUid": folder_uid
            # "resourceRef": "" - unused/legacy
        }
        val = settings.get("title", None)
        if isinstance(val, str): self.title = val

        # gateway_name, ksm_app_name used externally during gw creation, use controllerUid here

        choices = ("on", "off", "default")
        val = settings.get("connections", None)
        if isinstance(val, str) and val in choices: self.connections = val
        val = settings.get("rotation", None)
        if isinstance(val, str) and val in choices: self.rotation = val
        val = settings.get("tunneling", None)
        if isinstance(val, str) and val in choices: self.tunneling = val
        val = settings.get("remote_browser_isolation", None)
        if isinstance(val, str) and val in choices: self.remote_browser_isolation = val
        val = settings.get("graphical_session_recording", None)
        if isinstance(val, str) and val in choices: self.graphical_session_recording = val
        val = settings.get("text_session_recording", None)
        if isinstance(val, str) and val in choices: self.text_session_recording = val

        val = settings.get("port_mapping", None) # multiline
        if isinstance(val, str): val = [val]
        if (isinstance(val, list) and all(isinstance(x, str) and x != "" for x in val)):
            self.port_mapping = val
        elif val is not None:
            logging.warning("Unrecognized port_mapping values (skipped) - expecting list of strings,"\
                            """ ex. ["2222=ssh", "33060=mysql"]""")

        # {"type": "on-demand"} or {"type": "CRON", "cron": "30 18 * * *", "tz": "America/Chicago" }
        val = settings.get("default_rotation_schedule", None)
        if isinstance(val, dict):
            schedule_type = str(val.get("type", "")).lower()
            schedule_type = {"on-demand": "ON_DEMAND", "cron": "CRON"}.get(schedule_type, "")
            if schedule_type != "":
                if schedule_type == "ON_DEMAND":
                    self.default_rotation_schedule = { "type": "ON_DEMAND" }
                elif schedule_type == "CRON":
                    cron = str(val.get("cron", "")).strip()
                    if cron:
                        self.default_rotation_schedule = { "type": "CRON", "cron": cron }
                        tz = str(val.get("tz", "")).strip()
                        if tz: self.default_rotation_schedule["tz"] = tz
                    else:
                        logging.warning("Skipped unrecognized CRON settings in default_rotation_schedule")
            else:
                logging.warning("Skipped unrecognized default_rotation_schedule type")

        self.scripts = PamScriptsObject.load(settings.get("scripts", None))
        self.attachments = PamAttachmentsObject.load(settings.get("attachments", None))

        # Local Network
        if environment_type == "local":
            val = settings.get("network_id", None)
            if isinstance(val, str): self.network_id = val
            val = settings.get("network_cidr", None)
            if isinstance(val, str): self.network_cidr = val
        elif environment_type == "aws":
            val = settings.get("aws_id", None) # required
            if isinstance(val, str): self.aws_id = val
            val = settings.get("aws_access_key_id", None)
            if isinstance(val, str): self.aws_access_key_id = val
            val = settings.get("aws_secret_access_key", None)
            if isinstance(val, str): self.aws_secret_access_key = val

            val = settings.get("aws_region_names", None) # multiline
            if isinstance(val, str): val = [val]
            if (isinstance(val, list) and all(isinstance(x, str) and x != "" for x in val)):
                self.aws_region_names = val
            elif val is not None:
                logging.warning("Unrecognized aws_region_names values (skipped) - expecting list of strings")
        elif environment_type == "azure":
            val = settings.get("az_entra_id", None) # required
            if isinstance(val, str): self.az_entra_id = val
            val = settings.get("az_client_id", None) # required
            if isinstance(val, str): self.az_client_id = val
            val = settings.get("az_client_secret", None) # required
            if isinstance(val, str): self.az_client_secret = val
            val = settings.get("az_subscription_id", None) # required
            if isinstance(val, str): self.az_subscription_id = val
            val = settings.get("az_tenant_id", None) # required
            if isinstance(val, str): self.az_tenant_id = val
            val = settings.get("az_resource_groups", None) # multiline
            if isinstance(val, str): val = [val]
            if (isinstance(val, list) and all(isinstance(x, str) and x != "" for x in val)):
                self.az_resource_groups = val
            elif val is not None:
                logging.warning("Unrecognized az_resource_groups values (skipped) - expecting list of strings")
        elif environment_type == "domain":
            val = settings.get("dom_domain_id", None) # required
            if isinstance(val, str): self.dom_domain_id = val
            val = settings.get("dom_hostname", None) # required
            if isinstance(val, str): self.dom_hostname = val
            val = settings.get("dom_port", None) # required
            if isinstance(val, int) and 0 <= val <= 65535: val = str(val)
            if isinstance(val, str): self.dom_port = val
            val = utils.value_to_boolean(settings.get("dom_use_ssl")) # required, bool
            if isinstance(val, bool): self.dom_use_ssl = val
            val = utils.value_to_boolean(settings.get("dom_scan_dc_cidr")) # optional, bool
            if isinstance(val, bool): self.dom_scan_dc_cidr = val
            val = settings.get("dom_network_cidr", None) # optional
            if isinstance(val, str): self.dom_network_cidr = val
            val = settings.get("dom_administrative_credential", None) # required, existing pamUser
            if isinstance(val, str): self.dom_administrative_credential = val
            # self.admin_credential_ref - will be resolved from dom_administrative_credential (later)
        elif environment_type == "gcp":
            val = settings.get("gcp_id", None) # required
            if isinstance(val, str): self.gcp_id = val
            # --service-account-key accepts only JSON.stringify(value) anyways
            val = settings.get("gcp_service_account_key", None) # required
            if isinstance(val, str): self.gcp_service_account_key = val
            val = settings.get("gcp_google_admin_email", None) # required
            if isinstance(val, str): self.gcp_google_admin_email = val
            val = settings.get("gcp_region_names", None) # required, multiline
            if isinstance(val, str): val = [val]
            if (isinstance(val, list) and all(isinstance(x, str) and x != "" for x in val)):
                self.gcp_region_names = val
            elif val is not None:
                logging.warning("Unrecognized gcp_region_names values (skipped) - expecting list of strings")
        elif environment_type == "oci":
            val = settings.get("oci_id", None) # required
            if isinstance(val, str): self.oci_id = val
            val = settings.get("oci_admin_id", None) # required
            if isinstance(val, str): self.oci_admin_id = val
            val = settings.get("oci_admin_public_key", None) # required
            if isinstance(val, str): self.oci_admin_public_key = val
            val = settings.get("oci_admin_private_key", None) # required
            if isinstance(val, str): self.oci_admin_private_key = val
            val = settings.get("oci_tenancy", None) # required
            if isinstance(val, str): self.oci_tenancy = val
            val = settings.get("oci_region", None) # required
            if isinstance(val, str): self.oci_region = val


class PamScriptsObject():
    def __init__(self):
        self.scripts: List[PamScriptObject] = []

    @classmethod
    def load(cls, data: Optional[Union[str, list]]) -> PamScriptsObject:
        obj = cls()
        try: data = json.loads(data) if isinstance(data, str) else data
        except: logging.error(f"Pam Scripts failed to load from: {str(data)[:80]}...")
        if not(data and isinstance(data, list)): return obj

        for s in data:
            so = PamScriptObject.load(s)
            if so.validate():
                obj.scripts.append(so)
            else:
                logging.warning(f"""Script file not found (skipped): "{str(so.file)}" """)
        if not obj.scripts: logging.warning("Skipped empty scripts section")
        return obj

    # def to_json(self): pass  # File upload will create the JSON format


class PamScriptObject():
    def __init__(self):
        self.file: str = ""
        self.script_command: str = ""
        self.additional_credentials: List[str] = []
        self.file_ref: str = ""  # fileRef generated by file upload
        self.record_refs: List[str] = []  # "recordRef":["uid1","uid2"] from additional_credentials

    def validate(self):
        valid = isinstance(self.file, str)
        valid = valid and Path(self.file).resolve().exists()
        return valid

    @classmethod
    def load(cls, data: Union[str, dict]) -> PamScriptObject:
        obj = cls()
        try: data = json.loads(data) if isinstance(data, str) else data
        except: logging.error(f"PAM script failed to load from: {str(data)[:80]}")
        if not isinstance(data, dict): return obj

        # TUI:  "script": { "script_command": "pwsh.exe", "file": "path/file.ext", "additional_credentials": ["admin1", "user2"] },
        # JSON: "script": [{"command":"", "fileRef":"path/file.ext", "recordRef": ["uid1", "uid2"]}]
        # use file upload to attach to existing record and get UIDs
        cmd = data.get("script_command", None)
        if isinstance(cmd, str) and cmd.strip() != "": obj.script_command = cmd.strip()
        file = data.get("file", None)
        if isinstance(file, str) and file.strip() != "": obj.file = file.strip()
        # before use call validate() which also checks if file exists

        # NB! If script has additional_credentials these must be added later,
        # after pamUser creation
        acs = data.get("additional_credentials", None)
        if isinstance(acs, str): acs = [acs]
        if isinstance(acs, list) and acs: obj.additional_credentials = acs

        return obj

    # def to_json(self): pass  # File upload will create the JSON format


class PamAttachmentsObject():
    def __init__(self):
        self.attachments: List[PamAttachmentObject] = []
        # self.file_ref: List[str]  # fileRef: [] populated by file upload

    @classmethod
    def load(cls, data: Optional[Union[str, list]]) -> PamAttachmentsObject:
        obj = cls()
        try: data = json.loads(data) if isinstance(data, str) else data
        except: logging.error(f"PAM Attachments failed to load from: {str(data)[:80]}...")
        if not(data and isinstance(data, list)): return obj

        for a in data:
            if isinstance(a, str): a = { "file": a }
            ao = PamAttachmentObject.load(a)
            if ao.validate():
                obj.attachments.append(ao)
            else:
                logging.warning(f"""File attachment not found (skipped): "{str(ao.file)}" """)
        if not obj.attachments: logging.warning("Skipped empty file attachments section")
        return obj

    # def to_json(self): pass  # File upload will create the JSON format


class PamAttachmentObject():
    def __init__(self):
        self.file: str = ""
        self.title: str = ""
        self.file_ref: str = ""  # fileRef generated by file upload

    def validate(self):
        valid = isinstance(self.file, str)
        valid = valid and Path(self.file).resolve().exists()
        return valid

    @classmethod
    def load(cls, data: Union[str, dict]) -> PamAttachmentObject:
        obj = cls()
        try: data = json.loads(data) if isinstance(data, str) else data
        except: logging.error(f"Failed to load file attachment from: {str(data)[:80]}")
        if isinstance(data, str): data = {"file": data}
        if not isinstance(data, dict): return obj

        # TUI: "attachments": [{ "file": "path/file.ext", "title": "File1" }]
        # TUI: "attachments": ["path/file1", "file2"] - currently / title=filename
        # JSON: "fileRef": ["uid1", "uid2"] # file upload generated
        # use file upload to attach to existing record and get UIDs
        title = data.get("title", None)
        if isinstance(title, str) and title.strip() != "": obj.title = title.strip()
        file = data.get("file", None)
        if isinstance(file, str) and file.strip() != "": obj.file = file.strip()
        # before use call validate() which also checks if file exists

        return obj

    # def to_json(self): pass  # File upload will create the JSON format


class PamRotationScheduleObject():
    def __init__(self):
        self.type: str = ""  # on-demand|CRON
        self.cron: str = ""  # ex. "cron": "30 18 * * *"
        self.tz: str = ""    # timezone - default = "Etc/UTC"
        # {"type": "on-demand"}|{"type": "CRON", "cron": "30 18 * * *"}
        # http://www.quartz-scheduler.org/documentation/quartz-2.3.0/tutorials/crontrigger.html#examples

    @classmethod
    def load(cls, data: Union[str, dict]) -> PamRotationScheduleObject:
        schedule_types = ("on-demand", "cron")
        obj = cls()
        try: data = json.loads(data) if isinstance(data, str) else data
        except: logging.error(f"Failed to load rotation schedule from: {str(data)[:80]}")
        if not isinstance(data, dict): return obj

        type = data.get("type", None)
        if type and isinstance(type, str) and type.strip().lower() in schedule_types:
            obj.type = type.strip().lower()
        elif type:
            logging.error(f"""Schedule type "{str(type)[:80]}" is unknown - must be one of {schedule_types}""")

        if obj.type.lower() == "cron":
            cron = data.get("cron", None)
            if isinstance(cron, str) and cron.strip() != "": obj.cron = cron.strip()
            if obj.cron:  # validate
                try:
                    parsed_cron = vault.TypedField.import_schedule_field(obj.cron)
                except:
                    parsed_cron = {}
                if not (parsed_cron and parsed_cron.get("time", "")):
                    logging.error(f"Failed to load CRON from: {obj.cron}")
            tz = data.get("tz", None)
            if isinstance(tz, str) and tz.strip() != "": obj.tz = tz.strip()

        return obj

class PamRotationParams():
    def __init__(self, configUid: str, profiles: dict):
        self.configUid: str = configUid  # iam_user|scripts_only=NOOP
        self.ownerUid: str = ""  # general - pamMachine rec UID
        self.ownerTitle: str = ""  # general - pamMachine rec title
        self.rotation_profiles: dict = profiles or {}

class PamRotationSettingsObject():
    def __init__(self):
        self.rotation: str = ""  # general|iam_user|scripts_only=NOOP
        self.resource: str = ""  # general:MachineTitle, IAM/Scripts:skip - auto/PamConfig
        self.enabled: str = ""   # on|off|default
        self.schedule = None  # {"type": "on-demand"}|{"type": "CRON", "cron": "30 18 * * *"}
        self.password_complexity: str = ""  # "32,5,5,5,5<length,upper,lower,digits,symbols>"
        self.resourceUid: str = "" # general:machineUID, iam_user,scripts_only:PamConfigUID

    @classmethod
    def load(cls, data: Optional[Union[str, dict]], rotation_params: Optional[PamRotationParams] = None) -> PamRotationSettingsObject:
        rotation_types = ("general", "iam_user", "scripts_only")
        enabled_types = ("on", "off", "default")
        rx_complexity = r"^(\d+,\d+,\d+,\d+,\d+)$"
        obj = cls()

        # autodetect profile name (and load from rotation_profiles section)
        if isinstance(data, str) and rotation_params and isinstance(rotation_params.rotation_profiles, dict):
            profile = rotation_params.rotation_profiles.get(data, None)
            if profile and isinstance(profile, dict):
                data = profile

        try: data = json.loads(data) if isinstance(data, str) else data
        except: logging.error(f"Failed to load rotation settings from: {str(data)[:80]}")
        if not isinstance(data, dict): return obj

        rotation = data.get("rotation", None)
        if rotation and isinstance(rotation, str) and rotation.strip().lower() in rotation_types:
            obj.rotation = rotation.strip().lower()
        elif rotation:
            logging.error(f"""Rotation type "{str(rotation)[:80]}" is unknown - must be one of {rotation_types}""")

        # type: iam_user|scripts_only=NOOP - automatically pick up current PAM Config
        # type: general - automatically picks owner record (uid by title)
        if obj.rotation == "general":
            resource = data.get("resource", None)
            if isinstance(resource, str) and resource.strip() != "":
                obj.resource = resource.strip()
            if rotation_params and rotation_params.ownerTitle:
                if obj.resource and obj.resource.lower() != rotation_params.ownerTitle.lower():
                    logging.warning("Rotation record owner must be its parent - replacing "
                                    f"""configured owner "resource":"{obj.resource}" """
                                    f"""with actual parent "{rotation_params.ownerTitle}" """)
                obj.resource = rotation_params.ownerTitle
        elif obj.rotation in ("iam_user", "scripts_only"):
            if rotation_params and rotation_params.configUid:
                obj.resource = rotation_params.configUid

        enabled = data.get("enabled", None)
        if enabled and isinstance(enabled, str) and enabled.strip().lower() in enabled_types:
            obj.enabled = enabled.strip().lower()
        elif enabled:
            logging.error(f"""Unknown rotation enablement type "{str(enabled)[:80]}" - must be one of {enabled_types}""")

        obj.schedule = PamRotationScheduleObject.load(data.get("schedule", None) or "")
        complexity = data.get("password_complexity", None)
        if complexity and isinstance(complexity, str):
            if re.fullmatch(rx_complexity, complexity):
                obj.password_complexity = complexity.strip()
        if complexity and not obj.password_complexity:
            logging.error(f"""Invalid password complexity "{str(enabled)[:20]}" - must be in csv format, ex. "32,5,5,5,5" """)
        # pwd_complexity_rule_list = {} populated by password_complexity

        return obj


class DagOptionValue(Enum):
    ON = "on"
    OFF = "off"
    DEFAULT = "default"

    @classmethod
    def map(cls, dag_option: str):
        try: return cls(str(dag_option).lower())
        except ValueError: return None

class DagSettingsObject():
    def __init__(self):
        self.pam_resource: Optional[str] = None
        self.rotation: Optional[DagOptionValue] = None
        self.connections: Optional[DagOptionValue] = None
        self.tunneling: Optional[DagOptionValue] = None
        self.remote_browser_isolation: Optional[DagOptionValue] = None
        self.graphical_session_recording: Optional[DagOptionValue] = None
        self.text_session_recording: Optional[DagOptionValue] = None
        # NB! PAM User has its own rotation_settings: {}, cannot enable con/tun on user anyways
        # remote_browser_isolation uses rbi, pam_resource, graphical_session_recording
        # rotation uses only pam_resource, rotation
        # machine/db/dir uses all

    @classmethod
    def load(cls, data: Union[str, dict]):
        obj = cls()
        try: data = json.loads(data) if isinstance(data, str) else data
        except: logging.error(f"DAG settings failed to load from: {str(data)[:80]}")
        if not isinstance(data, dict): return obj

        val = data.get("resource", None)
        if isinstance(val, str): obj.pam_resource = val
        obj.rotation = DagOptionValue.map(data.get("rotation", None) or "")
        obj.connections = DagOptionValue.map(data.get("connections", None) or "")
        obj.tunneling = DagOptionValue.map(data.get("tunneling", None) or "")
        obj.remote_browser_isolation = DagOptionValue.map(data.get("remote_browser_isolation", None) or "")
        obj.graphical_session_recording = DagOptionValue.map(data.get("graphical_session_recording", None) or "")
        obj.text_session_recording = DagOptionValue.map(data.get("text_session_recording", None) or "")

        return obj


class PamUserObject():
    def __init__(self):
        self.uid = ""
        self.type = "pamUser"
        self.title = None
        self.notes = None
        self.login = None
        self.password = None
        self.privatePEMKey = None
        self.distinguishedName = None
        self.connectDatabase = None
        self.managed = None
        self.oneTimeCode = None
        self.attachments = None  # fileRef
        self.scripts = None  # script
        self.rotation_settings = None  # DAG: rotation settings

    @classmethod
    def load(cls, data: Union[str, dict], rotation_params: Optional[PamRotationParams] = None):
        obj = cls()
        try: data = json.loads(data) if isinstance(data, str) else data
        except: logging.error(f"PAM User failed to load from: {str(data)[:80]}")
        if not isinstance(data, dict): return obj

        dtype = str(data["type"]) if "type" in data else "pamUser"
        if dtype and dtype.lower() != "pamUser".lower():
            logging.warning(f"""PAM User data using wrong type "pamUser" != "{dtype[:80]}" """)

        obj.type = "pamUser"
        obj.title = str(data["title"]) if "title" in data else None
        obj.notes = str(data["notes"]) if "notes" in data else None

        obj.login = str(data["login"]) if "login" in data else None
        obj.password = str(data["password"]) if "password" in data else None
        obj.privatePEMKey = str(data["private_pem_key"]) if "private_pem_key" in data else None
        obj.distinguishedName = str(data["distinguished_name"]) if "distinguished_name" in data else None
        obj.connectDatabase = str(data["connect_database"]) if "connect_database" in data else None
        obj.managed = utils.value_to_boolean(data["managed"]) if "managed" in data else None
        obj.oneTimeCode = str(data["otp"]) if "otp" in data else None

        obj.attachments = PamAttachmentsObject.load(data.get("attachments", None))
        obj.scripts = PamScriptsObject.load(data.get("scripts", None))
        rso = PamRotationSettingsObject.load(data.get("rotation_settings", None), rotation_params)
        if not is_blank_instance(rso):
            obj.rotation_settings = rso

        if (obj.title is None or not obj.title.strip()) and obj.login and obj.login.strip():
            obj.title = f"PAM User - {str(obj.login).strip()}"

        obj.validate_record()

        return obj

    def create_record(self, params, folder_uid):
        args = {
            "force": True,
            "folder": folder_uid,
            "record_type": self.type
        }
        if self.uid: args["record_uid"] = self.uid
        if self.title: args["title"] = self.title
        if self.notes: args["notes"] = self.notes

        fields = []
        if self.login: fields.append(f"f.login={self.login}")
        if self.password: fields.append(f"f.password={self.password}")
        if self.privatePEMKey: fields.append(f"f.secret.privatePEMKey={self.privatePEMKey}")
        if self.distinguishedName: fields.append(f"f.text.distinguishedName={self.distinguishedName}")
        if self.connectDatabase: fields.append(f"f.text.connectDatabase={self.connectDatabase}")

        managed = utils.value_to_boolean(self.managed)
        if managed is not None: fields.append(f"f.checkbox.managed={str(managed).lower()}")

        if self.oneTimeCode: fields.append(f"f.oneTimeCode={self.oneTimeCode}")

        files = self.attachments.attachments if self.attachments and isinstance(self.attachments, PamAttachmentsObject) else []
        if files and isinstance(files, list):
            for x in files:
                if x and isinstance(x, PamAttachmentObject) and x.file:
                    fields.append(f"file=@{x.file}")

        if fields: args["fields"] = fields
        uid = RecordEditAddCommand().execute(params, **args)
        if uid and isinstance(uid, str):
            self.uid = uid

        # after record creation add PAM scripts
        if uid and self.scripts and self.scripts.scripts:
            add_pam_scripts(params, uid, self.scripts.scripts)

        # DAG: after record creation - self.scripts, self.rotation_settings
        return uid

    def validate_record(self):
        if not self.password:
            logging.warning("PAM User is missing required field `login`")
        if not self.rotation_settings:
            logging.debug("PAM User is missing rotation settings")
        if isinstance(self.rotation_settings, PamRotationSettingsObject):
            if (str(self.rotation_settings.rotation).lower() == "general" and
                not self.rotation_settings.resource):
                logging.warning("PAM User with rotation type=general is missing required machine `resource=xxx`")


class LoginUserObject():
    def __init__(self):
        self.uid = ""
        self.type = "login"
        self.title = None
        self.notes = None
        self.login = None
        self.password = None
        self.url = None
        self.oneTimeCode = None
        self.attachments = None

    @classmethod
    def load(cls, data: Union[str, dict]):
        obj = cls()
        try: data = json.loads(data) if isinstance(data, str) else data
        except: logging.error(f"Record type `login` failed to load from: {str(data)[:80]}")
        if not isinstance(data, dict): return obj

        dtype = str(data["type"]) if "type" in data else "login"
        if dtype.lower() != "login".lower():
            logging.warning(f"""User data using wrong type "login" != "{dtype[:80]}" """)

        obj.uid = ""
        obj.type = "login"
        obj.title = str(data["title"]) if "title" in data else None
        obj.notes = str(data["notes"]) if "notes" in data else None

        obj.login = str(data["login"]) if "login" in data else None
        obj.password = str(data["password"]) if "password" in data else None
        obj.url = str(data["url"]) if "url" in data else None
        obj.oneTimeCode = str(data["otp"]) if "otp" in data else None
        obj.attachments = PamAttachmentsObject.load(data.get("attachments", None))

        return obj

    def create_record(self, params, folder_uid):
        args = {
            "force": True,
            "folder": folder_uid,
            "record_type": self.type
        }
        if self.uid: args["record_uid"] = self.uid
        if self.title: args["title"] = self.title
        if self.notes: args["notes"] = self.notes

        fields = []
        if self.login: fields.append(f"f.login={self.login}")
        if self.password: fields.append(f"f.password={self.password}")
        if self.url: fields.append(f"f.url={self.url}")
        if self.oneTimeCode: fields.append(f"f.oneTimeCode={self.oneTimeCode}")

        files = self.attachments.attachments if self.attachments and isinstance(self.attachments, PamAttachmentsObject) else []
        if files and isinstance(files, list):
            for x in files:
                if x and isinstance(x, PamAttachmentObject) and x.file:
                    fields.append(f"file=@{x.file}")

        if fields: args["fields"] = fields
        uid = RecordEditAddCommand().execute(params, **args)
        if uid and isinstance(uid, str):
            self.uid = uid
        return uid

class PamBaseMachineParser():
    def __init__(self):
        self.type = ""
        self.title = None
        self.notes = None
        self.host = None
        self.port = None
        self.sslVerification = None
        self.providerGroup = None
        self.providerRegion = None
        self.oneTimeCode = None
        self.attachments = None
        self.scripts = None
        self.pam_settings : Optional[PamSettingsFieldData] = None

        # pamMachine
        self.operatingSystem = None
        self.instanceName = None
        self.instanceId = None
        # Warning! Unused, split into linked pamUser record
        self.login = None
        self.password = None
        self.privatePEMKey = None

        # pamDatabase
        self.useSSL = None
        self.databaseId = None
        self.databaseType = None # postgresql|postgresql-flexible|mysql|mysql-flexible|mariadb|mariadb-flexible|mssql|oracle|mongodb

        # pamDirectory
        self.domainName = None
        self.alternativeIPs = None
        self.directoryId = None
        self.directoryType = None # active_directory|openldap
        self.userMatch = None

    @classmethod
    def load(cls, record_type: str, data: Union[str, dict]):
        pam_machine_types = ("pamMachine", "pamDatabase", "pamDirectory")
        pam_db_types = ("postgresql", "postgresql-flexible", "mysql", "mysql-flexible", "mariadb", "mariadb-flexible", "mssql", "oracle", "mongodb")
        pam_dir_types = ("active_directory", "openldap")

        obj = cls()
        try: data = json.loads(data) if isinstance(data, str) else data
        except: logging.error(f"""Record type "{record_type}" failed to load from: {str(data)[:80]}""")
        if not isinstance(data, dict): return obj

        dtype = str(data.get("type", None))
        data_type = next((s for s in pam_machine_types if s.lower() == dtype.lower()), None)
        rec_type = next((s for s in pam_machine_types if s.lower() == str(record_type).lower()), None)
        if rec_type and data_type and rec_type != data_type:
            logging.warning(f"""Expected machine record type "{rec_type}" != data record type "{data_type}" - Parsing as "{rec_type}" """)
        if rec_type is None:
            msg = f"""Unknown expected record type "{record_type}". """
            if data_type is None:
                msg = msg + f"""Unknown data record type "{dtype}" - Parsing it as generic pamMachine."""
            else:
                msg = msg + f"""Using data record type "{data_type}"."""
            logging.error(f"""{msg} Expected record types "{pam_machine_types}" """)

        obj.type = rec_type or data_type or "pamMachine"
        obj.title = str(data["title"]) if "title" in data else None
        obj.notes = str(data["notes"]) if "notes" in data else None
        obj.host = str(data["host"]) if "host" in data else None
        obj.port = str(data["port"]) if "port" in data else None
        obj.sslVerification = utils.value_to_boolean(data["ssl_verification"]) if "ssl_verification" in data else None
        obj.providerGroup = str(data["provider_group"]) if "provider_group" in data else None
        obj.providerRegion = str(data["provider_region"]) if "provider_region" in data else None
        obj.oneTimeCode = str(data["otp"]) if "otp" in data else None
        obj.attachments = PamAttachmentsObject.load(data.get("attachments", None))
        obj.scripts = PamScriptsObject.load(data.get("scripts", None))

        psd = data.get("pam_settings", None)
        if psd:
            obj.pam_settings = PamSettingsFieldData.load(psd)
            if not obj.pam_settings:
                logging.error(f"""{obj.type}: failed to load PAM Settings from "{str(data)[:80]}" """)

        # pamMachine
        obj.operatingSystem = str(data["operating_system"]) if "operating_system" in data else None
        obj.instanceName = str(data["instance_name"]) if "instance_name" in data else None
        obj.instanceId = str(data["instance_id"]) if "instance_id" in data else None
        # Warning! Unused, split into linked pamUser record
        obj.login = str(data["login"]) if "login" in data else None
        obj.password = str(data["password"]) if "password" in data else None
        obj.privatePEMKey = str(data["private_pem_key"]) if "private_pem_key" in data else None

        # pamDatabase
        obj.useSSL = utils.value_to_boolean(data["use_ssl"]) if "use_ssl" in data else None
        obj.databaseId = str(data["database_id"]) if "database_id" in data else None

        dbtype = str(data["database_type"]) if "database_type" in data else None
        pamdbt = next((s for s in pam_db_types if s.lower() == str(dbtype).lower()), None)
        if dbtype and not pamdbt:
            logging.error(f"""Unexpected DB type "{dbtype}" - should be one of the known DB types "{pam_db_types}" """)
            pamdbt = dbtype.lower()  # use provided db type "as-is"
        if not pamdbt and obj.type == "pamDatabase":
            logging.debug(f"""pamDatabase - unable to determine DB type: database_type should be one of "{pam_db_types}" """)
        obj.databaseType = pamdbt

        # pamDirectory
        obj.domainName = str(data["domain_name"]) if "domain_name" in data else None
        obj.alternativeIPs = multiline_to_str(parse_multiline(data, "alternative_ips", "Error parsing alternative_ips"))
        obj.directoryId = str(data["directory_id"]) if "directory_id" in data else None
        obj.userMatch = str(data["user_match"]) if "user_match" in data else None

        dt = str(data["directory_type"]) if "directory_type" in data else None
        pamdt = next((s for s in pam_dir_types if s.lower() == str(dt).lower()), None)
        if dt and not pamdt:
            logging.error(f"""Unexpected Directory type "{dt}" - should be one of "{pam_dir_types}" """)
            pamdt = dt.lower()  # use provided directory type "as-is"
        if not pamdt and obj.type == "pamDirectory":
            logging.debug(f"""pamDirectory - unable to determine Directory type: directory_type should be one of "{pam_dir_types}" """)
        obj.directoryType = pamdt  # active_directory|openldap

        return obj

class PamMachineObject():
    def __init__(self):
        self.uid = ""
        self.type = "pamMachine"
        self.title = None
        self.notes = None
        self.host = None  # pamHostname
        self.port = None  # pamHostname
        self.sslVerification = None
        self.operatingSystem = None
        self.instanceName = None
        self.instanceId = None
        self.providerGroup = None
        self.providerRegion = None
        self.oneTimeCode = None
        self.attachments = None  # fileRef
        self.scripts = None  # script

        # Warning! unused - use users[] to link users, rotation scripts etc.
        self.login = None
        self.password = None
        self.privatePEMKey = None

        self.pam_settings : Optional[PamSettingsFieldData] = None
        self.users = None # List[PamUserObject] - one is admin(istrative credential)

        self.is_admin_external: bool = False  # (True<=>found:pamDirectory#Title.pamUser#Title)
        self.administrative_credentials_uid: str = ""  # external or internal user UID

    @classmethod
    def load(cls, data: Union[str, dict], rotation_params: Optional[PamRotationParams] = None):
        obj = cls()
        try: data = json.loads(data) if isinstance(data, str) else data
        except: logging.error(f"""Record type "pamMachine" failed to load from: {str(data)[:80]}""")
        if not isinstance(data, dict): return obj

        bmp = PamBaseMachineParser.load("pamMachine", data)

        if bmp and bmp.type.lower() != "pamMachine".lower():
            logging.warning(f"""PAM Machine data using wrong type "pamMachine" != "{bmp.type}" """)

        obj.type = "pamMachine"
        obj.title = bmp.title
        obj.notes = bmp.notes
        obj.host = bmp.host
        obj.port = bmp.port
        obj.sslVerification = bmp.sslVerification
        obj.operatingSystem = bmp.operatingSystem
        obj.instanceName = bmp.instanceName
        obj.instanceId = bmp.instanceId
        obj.providerGroup = bmp.providerGroup
        obj.providerRegion = bmp.providerRegion
        obj.oneTimeCode = bmp.oneTimeCode
        obj.attachments = bmp.attachments
        obj.scripts = bmp.scripts
        obj.pam_settings = bmp.pam_settings

        # Warning! unused - use users[] to link users, rotation scripts etc.
        obj.login = bmp.login
        obj.password = bmp.password
        obj.privatePEMKey = bmp.privatePEMKey
        
        if (obj.title is None or not obj.title.strip()) and obj.login and obj.login.strip():
            obj.title = f"PAM Machine - {str(obj.login).strip()}"
        if rotation_params:
            rotation_params.ownerTitle = obj.title or ""

        obj.users = []
        users = data.get("users", None)
        if users:
            for user in users:
                rt = str(user.get("type", "")) if isinstance(user, dict) else ""
                if not rt: rt = "pamUser"  # pamMachine user list is pamUser recs only
                if rt.lower() != "pamUser".lower():
                    logging.error(f"""{obj.title}:{obj.type}.users[] Expected record type pamUser, got "{rt}" - skipped.""")
                    continue
                usr = PamUserObject.load(user, rotation_params)
                if usr:
                    obj.users.append(usr)
        else:
            logging.warning(f"""Warning: PAM Machine "{obj.title}" with empty users section.""")

        obj.validate_record()

        return obj

    def create_record(self, params, folder_uid):
        args = {
            "force": True,
            "folder": folder_uid,
            "record_type": self.type
        }
        if self.uid: args["record_uid"] = self.uid
        if self.title: args["title"] = self.title
        if self.notes: args["notes"] = self.notes

        fields = []
        hostname = self.host.strip() if isinstance(self.host, str) and self.host.strip() else ""
        port = self.port.strip() if isinstance(self.port, str) and self.port.strip() else ""
        if hostname or port:
            val = json.dumps({"hostName": hostname, "port": port})
            fields.append(f"f.pamHostname=$JSON:{val}")

        sslv = utils.value_to_boolean(self.sslVerification)
        if sslv is not None: fields.append(f"checkbox.sslVerification={str(sslv).lower()}")
        if self.operatingSystem: fields.append(f"f.text.operatingSystem={self.operatingSystem}")
        if self.instanceName: fields.append(f"f.text.instanceName={self.instanceName}")
        if self.instanceId: fields.append(f"f.text.instanceId={self.instanceId}")
        if self.providerGroup: fields.append(f"f.text.providerGroup={self.providerGroup}")
        if self.providerRegion: fields.append(f"f.text.providerRegion={self.providerRegion}")

        # Warning! unused - use users[] to link users, rotation scripts etc.
        # if self.login: fields.append(f"f.login={self.login}")
        # if self.password: fields.append(f"f.password={self.password}")
        # if self.privatePEMKey: fields.append(f"f.secret.privatePEMKey={self.privatePEMKey}")

        if self.oneTimeCode: fields.append(f"f.oneTimeCode={self.oneTimeCode}")

        files = self.attachments.attachments if self.attachments and isinstance(self.attachments, PamAttachmentsObject) else []
        if files and isinstance(files, list):
            for x in files:
                if x and isinstance(x, PamAttachmentObject) and x.file:
                    fields.append(f"file=@{x.file}")

        # pam_settings port_forward/connection belong to the record
        if self.pam_settings and isinstance(self.pam_settings, PamSettingsFieldData):
            allowSupplyHost = True if self.pam_settings.allowSupplyHost is True else False
            portForward = self.pam_settings.portForward.to_record_dict() if self.pam_settings.portForward else {}
            connection = self.pam_settings.connection.to_record_dict() if self.pam_settings.connection else {}
            if portForward or connection or allowSupplyHost:
                val = json.dumps({"allowSupplyHost": allowSupplyHost, "portForward": portForward or {}, "connection": connection or {}})
                fields.append(f"c.pamSettings=$JSON:{val}")
                # switch to f.* once RT definition(s) update w/ pamSettings field

        if fields: args["fields"] = fields
        uid = RecordEditAddCommand().execute(params, **args)
        if uid and isinstance(uid, str):
            self.uid = uid

        # after record creation add PAM scripts
        if uid and self.scripts and self.scripts.scripts:
            add_pam_scripts(params, uid, self.scripts.scripts)

        # DAG: after record creation - self.scripts, self.pam_settings.options
        return uid

    def validate_record(self):
        # Warning! unused - use users[] to link users, rotation scripts etc.
        if self.login or self.password or self.privatePEMKey:
            logging.warning(f"""PAM Machine "{self.title}" detected legacy format - """
                            "please create separate pamUser record with login, password, privatePEMKey")
        if not (self.host or self.port):
            logging.warning(f"""PAM Machine "{self.title}" is missing required field `pamHostname` data (host/port)""")
        errmsg = validate_pam_connection(self.pam_settings.connection, "pamMachine") if self.pam_settings else ""
        if errmsg:
            logging.warning(f"""PAM Machine "{self.title}" has incorrect connection setup: {errmsg}""")

def validate_pam_connection(connection, record_type):
    errmsg = ""
    if connection:
        # Apparently all machine types allow connections using ANY protocol
        # ex. pamDatabase allowing SSH/RDP or pamMachine allowing proto: mysql
        # known_mach_types = [ConnectionSettingsRDP, ConnectionSettingsVNC, ConnectionSettingsTelnet, ConnectionSettingsSSH, ConnectionSettingsKubernetes]
        # known_db_types = [ConnectionSettingsSqlServer, ConnectionSettingsPostgreSQL, ConnectionSettingsMySQL]

        known_conn_types = PamSettingsFieldData.pam_connection_classes + [ConnectionSettingsHTTP]
        known_mach_types = PamSettingsFieldData.pam_connection_classes
        known_db_types = known_mach_types
        known_rbi_types = [ConnectionSettingsHTTP]

        # known_conn_proto = [x.protocol.value.lower() for x in known_conn_types]  # pylint: disable=E1101
        known_mach_proto = [x.protocol.value.lower() for x in known_mach_types]  # pylint: disable=E1101
        known_db_proto = [x.protocol.value.lower() for x in known_db_types]  # pylint: disable=E1101
        known_rbi_proto = [x.protocol.value.lower() for x in known_rbi_types]  # pylint: disable=E1101

        rt = str(record_type).lower().strip()
        if type(connection) not in known_conn_types:
            errmsg = f"""PAM Connection of unknown type "{type(connection).__name__}" """
        elif rt == "pamMachine".lower():
            if type(connection) not in known_mach_types:
                errmsg = f"""PAM Connection of type "{type(connection).__name__}" is incompatible with "{record_type}" """
            if (isinstance(getattr(connection, "protocol", ""), ConnectionProtocol) and
                connection.protocol.value.lower() not in known_mach_proto):
                errmsg = errmsg + f""" Unexpected PAM Machine connection protocol "{connection.protocol.value}" """
        elif rt == "pamDatabase".lower():
            if type(connection) not in known_db_types:
                errmsg = f"""PAM Connection of type "{type(connection).__name__}" is incompatible with "{record_type}" """
            if (isinstance(getattr(connection, "protocol", ""), ConnectionProtocol) and
                connection.protocol.value.lower() not in known_db_proto):
                errmsg = errmsg + f""" Unexpected PAM Database connection protocol "{connection.protocol.value}" """
        elif rt == "pamDirectory".lower():
            if type(connection) not in known_mach_types:
                errmsg = f"""PAM Connection of type "{type(connection).__name__}" is incompatible with "{record_type}" """
            if (isinstance(getattr(connection, "protocol", ""), ConnectionProtocol) and
                connection.protocol.value.lower() not in known_mach_proto):
                errmsg = errmsg + f""" Unexpected PAM Directory connection protocol "{connection.protocol.value}" """
        elif rt == "pamRemoteBrowser".lower():
            if type(connection) not in known_rbi_types:
                errmsg = f"""PAM Connection of type "{type(connection).__name__}" is incompatible with "{record_type}" """
            if (isinstance(getattr(connection, "protocol", ""), ConnectionProtocol) and
                connection.protocol.value.lower() not in known_rbi_proto):
                errmsg = errmsg + f""" Unexpected PAM Remote Browser connection protocol "{connection.protocol.value}" """
    if errmsg:
        logging.debug(errmsg)
    return errmsg


class PamDatabaseObject():
    def __init__(self):
        self.uid = ""
        self.type = "pamDatabase"
        self.title = None
        self.notes = None
        self.host = None  # pamHostname
        self.port = None  # pamHostname
        self.useSSL = None
        self.databaseId = None
        self.databaseType = None
        self.providerGroup = None
        self.providerRegion = None
        self.oneTimeCode = None
        self.attachments = None  # fileRef
        self.scripts = None  # script

        self.trafficEncryptionSeed = None
        self.pam_settings : Optional[PamSettingsFieldData] = None
        self.users = None # List[PamUserObject] - one is admin(istrative credential)

    @classmethod
    def load(cls, data: Union[str, dict], rotation_params: Optional[PamRotationParams] = None):
        obj = cls()
        try: data = json.loads(data) if isinstance(data, str) else data
        except: logging.error(f"""Record type "pamDatabase" failed to load from: {str(data)[:80]}""")
        if not isinstance(data, dict): return obj

        bmp = PamBaseMachineParser.load("pamDatabase", data)

        if bmp and bmp.type.lower() != "pamDatabase".lower():
            logging.warning(f"""PAM Database data using wrong type "pamDatabase" != "{bmp.type}" """)

        obj.type = "pamDatabase"
        obj.title = bmp.title
        obj.notes = bmp.notes
        obj.host = bmp.host
        obj.port = bmp.port
        obj.useSSL = bmp.useSSL
        obj.databaseId = bmp.databaseId
        obj.databaseType = bmp.databaseType
        obj.providerGroup = bmp.providerGroup
        obj.providerRegion = bmp.providerRegion
        obj.oneTimeCode = bmp.oneTimeCode
        obj.attachments = bmp.attachments
        obj.scripts = bmp.scripts
        obj.pam_settings = bmp.pam_settings

        if (obj.title is None or not obj.title.strip()) and obj.databaseId and obj.databaseId.strip():
            obj.title = f"PAM Database - {str(obj.databaseId).strip()}"
        if rotation_params:
            rotation_params.ownerTitle = obj.title or ""

        obj.users = []
        users = data.get("users", None)
        if users:
            for user in users:
                rt = str(user.get("type", "")) if isinstance(user, dict) else ""
                if not rt: rt = "pamUser"  # pamDatabase user list is pamUser recs only
                if rt.lower() != "pamUser".lower():
                    logging.error(f"""{obj.title}:{obj.type}.users[] Expected record type pamUser, got "{rt}" - skipped.""")
                    continue
                usr = PamUserObject.load(user, rotation_params)
                if usr:
                    obj.users.append(usr)
        else:
            logging.warning(f"""Warning: PAM Database "{obj.title}" with empty users section.""")

        obj.validate_record()

        return obj

    def create_record(self, params, folder_uid):
        args = {
            "force": True,
            "folder": folder_uid,
            "record_type": self.type
        }
        if self.uid: args["record_uid"] = self.uid
        if self.title: args["title"] = self.title
        if self.notes: args["notes"] = self.notes

        fields = []
        hostname = self.host.strip() if isinstance(self.host, str) and self.host.strip() else ""
        port = self.port.strip() if isinstance(self.port, str) and self.port.strip() else ""
        if hostname or port:
            val = json.dumps({"hostName": hostname, "port": port})
            fields.append(f"f.pamHostname=$JSON:{val}")

        ssl = utils.value_to_boolean(self.useSSL)
        if ssl is not None: fields.append(f"f.checkbox.useSSL={str(ssl).lower()}")
        if self.databaseId: fields.append(f"f.text.databaseId={self.databaseId}")
        if self.databaseType: fields.append(f"f.databaseType={self.databaseType}")
        if self.providerGroup: fields.append(f"f.text.providerGroup={self.providerGroup}")
        if self.providerRegion: fields.append(f"f.text.providerRegion={self.providerRegion}")

        if self.oneTimeCode: fields.append(f"f.oneTimeCode={self.oneTimeCode}")

        files = self.attachments.attachments if self.attachments and isinstance(self.attachments, PamAttachmentsObject) else []
        if files and isinstance(files, list):
            for x in files:
                if x and isinstance(x, PamAttachmentObject) and x.file:
                    fields.append(f"file=@{x.file}")

        # pam_settings port_forward/connection belong to the record
        if self.pam_settings and isinstance(self.pam_settings, PamSettingsFieldData):
            allowSupplyHost = True if self.pam_settings.allowSupplyHost is True else False
            portForward = self.pam_settings.portForward.to_record_dict() if self.pam_settings.portForward else {}
            connection = self.pam_settings.connection.to_record_dict() if self.pam_settings.connection else {}
            if portForward or connection or allowSupplyHost:
                val = json.dumps({"allowSupplyHost": allowSupplyHost, "portForward": portForward or {}, "connection": connection or {}})
                fields.append(f"c.pamSettings=$JSON:{val}")
                # switch to f.* once RT definition(s) update w/ pamSettings field

        if fields: args["fields"] = fields
        uid = RecordEditAddCommand().execute(params, **args)
        if uid and isinstance(uid, str):
            self.uid = uid

        # after record creation add PAM scripts
        if uid and self.scripts and self.scripts.scripts:
            add_pam_scripts(params, uid, self.scripts.scripts)

        # DAG: after record creation - self.scripts, self.pam_settings.options
        return uid

    def validate_record(self):
        if not (self.host or self.port):
            logging.warning(f"""PAM Database "{self.title}" is missing required field "pamHostname" data (host/port)""")
        errmsg = validate_pam_connection(self.pam_settings.connection, "pamDatabase") if self.pam_settings else ""
        if errmsg:
            logging.warning(f"""PAM Database "{self.title}" has incorrect connection setup: {errmsg}""")

class PamDirectoryObject():
    def __init__(self):
        self.uid = ""
        self.type = "pamDirectory"
        self.title = None
        self.notes = None
        self.host = None  # pamHostname
        self.port = None  # pamHostname
        self.useSSL = None
        self.domainName = None
        self.alternativeIPs = None
        self.directoryId = None
        self.directoryType = None  # <active_directory|openldap>"
        self.userMatch = None
        self.providerGroup = None
        self.providerRegion = None
        self.oneTimeCode = None
        self.attachments = None  # fileRef
        self.scripts = None  # script

        self.pam_settings : Optional[PamSettingsFieldData] = None
        self.users = None # List[PamUserObject] - one is admin(istrative credential)

    @classmethod
    def load(cls, data: Union[str, dict], rotation_params: Optional[PamRotationParams] = None):
        obj = cls()
        try: data = json.loads(data) if isinstance(data, str) else data
        except: logging.error(f"""Record type "pamDirectory" failed to load from: {str(data)[:80]}""")
        if not isinstance(data, dict): return obj

        bmp = PamBaseMachineParser.load("pamDirectory", data)

        if bmp and bmp.type.lower() != "pamDirectory".lower():
            logging.warning(f"""PAM Directory data using wrong type "pamDirectory" != "{bmp.type}" """)

        obj.type = "pamDirectory"
        obj.title = bmp.title
        obj.notes = bmp.notes
        obj.host = bmp.host
        obj.port = bmp.port
        obj.useSSL = bmp.useSSL
        obj.domainName = bmp.domainName
        obj.alternativeIPs = bmp.alternativeIPs
        obj.directoryId = bmp.directoryId
        obj.directoryType = bmp.directoryType
        obj.userMatch = bmp.userMatch
        obj.providerGroup = bmp.providerGroup
        obj.providerRegion = bmp.providerRegion
        obj.oneTimeCode = bmp.oneTimeCode
        obj.attachments = bmp.attachments
        obj.scripts = bmp.scripts
        obj.pam_settings = bmp.pam_settings

        if (obj.title is None or not obj.title.strip()) and obj.domainName and obj.domainName.strip():
            obj.title = f"PAM Directory - {str(obj.domainName).strip()}"
        if rotation_params:
            rotation_params.ownerTitle = obj.title or ""

        obj.users = []
        users = data.get("users", None)
        if users:
            for user in users:
                rt = str(user.get("type", "")) if isinstance(user, dict) else ""
                if not rt: rt = "pamUser"  # pamDirectory user list is pamUser recs only
                if rt.lower() != "pamUser".lower():
                    logging.error(f"""{obj.title}:{obj.type}.users[] Expected record type pamUser, got "{rt}" - skipped.""")
                    continue
                usr = PamUserObject.load(user, rotation_params)
                if usr:
                    obj.users.append(usr)
        else:
            logging.warning(f"""Warning: PAM Directory "{obj.title}" with empty users section.""")

        obj.validate_record()

        return obj

    def create_record(self, params, folder_uid):
        args = {
            "force": True,
            "folder": folder_uid,
            "record_type": self.type
        }
        if self.uid: args["record_uid"] = self.uid
        if self.title: args["title"] = self.title
        if self.notes: args["notes"] = self.notes

        fields = []
        hostname = self.host.strip() if isinstance(self.host, str) and self.host.strip() else ""
        port = self.port.strip() if isinstance(self.port, str) and self.port.strip() else ""
        if hostname or port:
            val = json.dumps({"hostName": hostname, "port": port})
            fields.append(f"f.pamHostname=$JSON:{val}")

        ssl = utils.value_to_boolean(self.useSSL)
        if ssl is not None: fields.append(f"f.checkbox.useSSL={str(ssl).lower()}")
        if self.domainName: fields.append(f"f.text.domainName={self.domainName}")
        if self.alternativeIPs: fields.append(f"f.multiline.alternativeIPs={self.alternativeIPs}")
        if self.directoryId: fields.append(f"f.text.directoryId={self.directoryId}")
        if self.directoryType: fields.append(f"f.directoryType={self.directoryType}")
        if self.userMatch: fields.append(f"f.text.userMatch={self.userMatch}")
        if self.providerGroup: fields.append(f"f.text.providerGroup={self.providerGroup}")
        if self.providerRegion: fields.append(f"f.text.providerRegion={self.providerRegion}")

        if self.oneTimeCode: fields.append(f"f.oneTimeCode={self.oneTimeCode}")

        files = self.attachments.attachments if self.attachments and isinstance(self.attachments, PamAttachmentsObject) else []
        if files and isinstance(files, list):
            for x in files:
                if x and isinstance(x, PamAttachmentObject) and x.file:
                    fields.append(f"file=@{x.file}")

        # pam_settings port_forward/connection belong to the record
        if self.pam_settings and isinstance(self.pam_settings, PamSettingsFieldData):
            allowSupplyHost = True if self.pam_settings.allowSupplyHost is True else False
            portForward = self.pam_settings.portForward.to_record_dict() if self.pam_settings.portForward else {}
            connection = self.pam_settings.connection.to_record_dict() if self.pam_settings.connection else {}
            if portForward or connection or allowSupplyHost:
                val = json.dumps({"allowSupplyHost": allowSupplyHost, "portForward": portForward or {}, "connection": connection or {}})
                fields.append(f"c.pamSettings=$JSON:{val}")
                # switch to f.* once RT definition(s) update w/ pamSettings field

        if fields: args["fields"] = fields
        uid = RecordEditAddCommand().execute(params, **args)
        if uid and isinstance(uid, str):
            self.uid = uid

        # after record creation add PAM scripts
        if uid and self.scripts and self.scripts.scripts:
            add_pam_scripts(params, uid, self.scripts.scripts)

        # DAG: after record creation - self.scripts, self.pam_settings.options
        return uid

    def validate_record(self):
        if not (self.host or self.port):
            logging.warning(f"""PAM Directory "{self.title}" is missing required field `pamHostname` data (host/port)""")
        errmsg = validate_pam_connection(self.pam_settings.connection, "pamDirectory") if self.pam_settings else ""
        if errmsg:
            logging.warning(f"""PAM Directory "{self.title}" has incorrect connection setup: {errmsg}""")

class PamRemoteBrowserObject():
    def __init__(self):
        self.uid = ""
        self.type = "pamRemoteBrowser"
        self.title = None
        self.notes = None
        self.rbiUrl = None
        self.oneTimeCode = None
        self.attachments = None  # fileRef

        self.rbi_settings : Optional[PamRemoteBrowserSettings] = None  # ft: pamRemoteBrowserSettings
        # Use httpCredentialsUid <- resolved from autofill_credentials (ref rt:Login in pam_data.users[])

    @classmethod
    def load(cls, data: Union[str, dict], rotation_params: Optional[PamRotationParams] = None):
        obj = cls()
        try: data = json.loads(data) if isinstance(data, str) else data
        except: logging.error(f"""Record type "pamRemoteBrowser" failed to load from: {str(data)[:80]}""")
        if not isinstance(data, dict): return obj

        dtype = data.get("type", None)
        if dtype and str(dtype).lower() != "pamRemoteBrowser".lower():
            logging.warning(f"""PAM RBI data using wrong type "pamRemoteBrowser" != "{dtype}" """)

        obj.type = "pamRemoteBrowser"
        obj.title = str(data["title"]) if "title" in data else None
        obj.notes = str(data["notes"]) if "notes" in data else None
        obj.rbiUrl = str(data["url"]) if "url" in data else None
        obj.oneTimeCode = str(data["otp"]) if "otp" in data else None
        obj.attachments = PamAttachmentsObject.load(data.get("attachments", None))

        psd = data.get("pam_settings", None)
        rbi_settings = PamRemoteBrowserSettings.load(psd)
        obj.rbi_settings = None if is_empty_instance(rbi_settings) else rbi_settings
        if psd and not obj.rbi_settings:
            logging.error(f"""{obj.type}: failed to load RBI Settings from "{str(psd)[:80]}" """)

        if (obj.title is None or not obj.title.strip()) and obj.rbiUrl and str(obj.rbiUrl).strip():
            hostname = str(obj.rbiUrl).lower()
            hostname = re.sub(r"^\s*https?://", "", hostname, flags=re.IGNORECASE)
            hostname = hostname.split("/", 1)[0]
            if hostname:
                obj.title = f"PAM RBI - {hostname}"

        obj.validate_record()

        return obj

    def create_record(self, params, folder_uid):
        args = {
            "force": True,
            "folder": folder_uid,
            "record_type": self.type
        }
        if self.uid: args["record_uid"] = self.uid
        if self.title: args["title"] = self.title
        if self.notes: args["notes"] = self.notes

        fields = []
        if self.rbiUrl: fields.append(f"rbiUrl={self.rbiUrl}")

        if self.oneTimeCode: fields.append(f"oneTimeCode={self.oneTimeCode}")

        files = self.attachments.attachments if self.attachments and isinstance(self.attachments, PamAttachmentsObject) else []
        if files and isinstance(files, list):
            for x in files:
                if x and isinstance(x, PamAttachmentObject) and x.file:
                    fields.append(f"file=@{x.file}")

        # pam_settings connection belongs to the record
        connection = {}
        if self.rbi_settings and isinstance(self.rbi_settings, PamRemoteBrowserSettings):
            if self.rbi_settings.connection:
                connection = self.rbi_settings.connection.to_record_dict()
        if connection:
            val = json.dumps({"connection": connection or {}})
            fields.append(f"pamRemoteBrowserSettings=$JSON:{val}")
            # switch to f.* once RT definition(s) update w/ pamRemoteBrowserSettings field

        if fields: args["fields"] = fields
        uid = RecordEditAddCommand().execute(params, **args)
        if uid and isinstance(uid, str):
            self.uid = uid

        # DAG: after record creation - self.pam_settings.options
        return uid

    def validate_record(self):
        errmsg = validate_pam_connection(self.rbi_settings.connection, "pamRemoteBrowser") if self.rbi_settings else ""
        if errmsg:
            logging.warning(f"""PAM RBI "{self.title}" has incorrect connection setup: {errmsg}""")

# PAM Settings field data
FONT_SIZES = (8, 9, 10, 11, 12, 14, 18, 24, 30, 36, 48, 60, 72, 96)
class ConnectionProtocol(Enum):
    RDP = "rdp"
    VNC = "vnc"
    TELNET = "telnet"
    SSH = "ssh"
    KUBERNETES = "kubernetes"
    SQLSERVER = "sql-server"
    POSTGRESQL = "postgresql"
    MYSQL = "mysql"
    HTTP = "http"

class RDPSecurity(Enum):
    ANY = "any"
    NLA = "nla"
    TLS = "tls"
    VMCONNECT = "vmconnect"
    RDP = "rdp"

    @classmethod
    def map(cls, rdp_security: str):
        try: return cls(str(rdp_security).lower())
        except ValueError: return None

class TerminalThemes(Enum):
    BLACK_WHITE = "black-white" # Black on white
    GRAY_BLACK = "gray-black"   # Gray on black
    GREEN_BLACK = "green-black" # Green on black
    WHITE_BLACK = "white-black" # White on black
    CUSTOM = "custom"  # Not a valid value to send to guac
    # example custom color scheme:
    # "colorScheme": "background: rgb:00/3D/FC;\nforeground: rgb:74/1A/1A;\ncolor0: rgb:00/00/00;\ncolor1: rgb:99/3E/3E;\ncolor2: rgb:3E/99/3E;\ncolor3: rgb:99/99/3E;\ncolor4: rgb:3E/3E/99;\ncolor5: rgb:99/3E/99;\ncolor6: rgb:3E/99/99;\ncolor7: rgb:99/99/99;\ncolor8: rgb:3E/3E/3E;\ncolor9: rgb:FF/67/67;\ncolor10: rgb:67/FF/67;\ncolor11: rgb:FF/FF/67;\ncolor12: rgb:67/67/FF;\ncolor13: rgb:FF/67/FF;\ncolor14: rgb:67/FF/FF;\ncolor15: rgb:FF/FF/FF;"

    @classmethod
    def map(cls, tty_theme: str):
        try: return cls(str(tty_theme).lower())
        except ValueError: return None

def parse_multiline(data: dict, key: str, message: str = "") -> Optional[List[str]]:
    if data and isinstance(data, dict) and key and isinstance(key, str):
        val = data.get(key, None) # "multiline": ["line1" "line2"]
        if isinstance(val, str): val = [val] # allow for "multiline": "line1"
        if val and isinstance(val, list):
            if any(not isinstance(x, str) or x == "" for x in val):
                logging.warning(f"{message} - value: {val[:24]}" if (isinstance(message, str) and message != "")
                                else "Error parsing multiline value (skipped): "\
                                f"found empty or non string values - value: {val[:24]}")
            else:
                return val
    return None

def multiline_to_str(lines: Optional[List[str]]) -> Optional[str]:
    if lines and isinstance(lines, list):
        return "\n".join(lines)
    return None

def multiline_stringify(lines: Optional[List[str]]) -> Optional[str]:
    if lines and isinstance(lines, list):
        # nb! strip() may remove more quotes esp. at end of string
        val = json.dumps("\n".join(lines))
        if val and val.startswith("\"") and val.endswith("\""):
            val = val[1:-1]
        return val
    return None

def parse_dag_option(option: Optional[str]) -> Optional[str]:
    key = str(option).lower()
    if key in ("on", "off", "default"):
        return key
    return None

class ClipboardConnectionSettings:
    def __init__(self, disableCopy: Optional[bool] = None, disablePaste: Optional[bool] = None):
        self.disableCopy = disableCopy
        self.disablePaste = disablePaste

    @classmethod
    def load(cls, data: Union[str, dict]):
        obj = cls()
        try: data = json.loads(data) if isinstance(data, str) else data
        except: logging.error(f"Clipboard Connection Settings failed to load from: {str(data)[:80]}")
        if not isinstance(data, dict): return obj

        obj.disableCopy = utils.value_to_boolean(data.get("disable_copy", None))
        obj.disablePaste = utils.value_to_boolean(data.get("disable_paste", None))
        return obj

def clipboard_connection_settings(connection_settings: Union[PamConnectionSettings, ConnectionSettingsHTTP]) -> Optional[ClipboardConnectionSettings]:
    if connection_settings and connection_settings.protocol and connection_settings.protocol in (
        ConnectionProtocol.RDP,
        ConnectionProtocol.VNC,
        ConnectionProtocol.TELNET,
        ConnectionProtocol.SSH,
        ConnectionProtocol.SQLSERVER,
        ConnectionProtocol.MYSQL,
        ConnectionProtocol.POSTGRESQL,
        ConnectionProtocol.HTTP
    ):
        disableCopy = getattr(connection_settings, "disableCopy", None)
        disablePaste = getattr(connection_settings, "disablePaste", None)
        return ClipboardConnectionSettings(disableCopy, disablePaste)

class SFTPRootDirectorySettings:
    def __init__(self, enableSftp: Optional[bool] = None, sftpRootDirectory: Optional[str] = None):
        self.enableSftp = enableSftp
        self.sftpRootDirectory = sftpRootDirectory

    @classmethod
    def load(cls, data: Union[str, dict]):
        obj = cls()
        try: data = json.loads(data) if isinstance(data, str) else data
        except: logging.error(f"SFTP Root Directory Settings failed to load from: {str(data)[:80]}")
        if not isinstance(data, dict): return obj

        obj.enableSftp = utils.value_to_boolean(data.get("enable_sftp", None))
        val = data.get("sftp_root_directory", None)
        if isinstance(val, str): obj.sftpRootDirectory = val
        return obj

    def to_dict(self):
        dict: Dict[str, Any] = {}
        if self.enableSftp is not None and isinstance(self.enableSftp, bool):
            dict["enableSftp"] = self.enableSftp
        if self.sftpRootDirectory and isinstance(self.sftpRootDirectory, str) and self.sftpRootDirectory.strip():
            dict["sftpRootDirectory"] = self.sftpRootDirectory.strip()

        return dict

class SFTPConnectionSettings(SFTPRootDirectorySettings):
    def __init__(
        self,
        enableSftp: Optional[bool] = None,
        sftpRootDirectory: Optional[str] = None,
        sftpResource: Optional[List[str]] = None,
        sftpUser: Optional[List[str]] = None,
        sftpDirectory: Optional[str] = None,
        sftpServerAliveInterval: Optional[int] = None
    ):
        super().__init__(enableSftp, sftpRootDirectory)
        self.sftpResource = sftpResource
        self.sftpUser = sftpUser
        self.sftpDirectory = sftpDirectory
        self.sftpServerAliveInterval = sftpServerAliveInterval
        self.sftpResourceUid = None # resolve from sftpResource
        self.sftpUserUid = None # resolve from sftpUser

    @classmethod
    def load(cls, data: Union[str, dict]):
        obj = cls()
        try: data = json.loads(data) if isinstance(data, str) else data
        except: logging.error(f"SFTP Connection Settings failed to load from: {str(data)[:80]}")
        if not isinstance(data, dict): return obj

        rds = SFTPRootDirectorySettings.load(data)
        if rds:
            obj.enableSftp = rds.enableSftp
            obj.sftpRootDirectory = rds.sftpRootDirectory

        # which is the resource record (not yet in web UI)
        obj.sftpResource = parse_multiline(data, "sftp_resource", "Error parsing sftp_resource")
        obj.sftpUser = parse_multiline(data, "sftp_user_credentials", "Error parsing sftp_user_credentials")
        val = data.get("sftp_upload_directory", None)
        if isinstance(val, str): obj.sftpDirectory = val
        val = data.get("sftp_keepalive_interval", None)
        if type(val) is int: obj.sftpServerAliveInterval = abs(val)
        elif val and str(val).isdecimal(): obj.sftpServerAliveInterval = int(val)

        return obj

    def to_dict(self):
        dict: Dict[str, Any] = {}
        if self.sftpRootDirectory and isinstance(self.sftpRootDirectory, str) and self.sftpRootDirectory.strip():
            dict["sftpRootDirectory"] = self.sftpRootDirectory.strip()
        if self.enableSftp is not None and isinstance(self.enableSftp, bool):
            dict["enableSftp"] = self.enableSftp

        # if resolved from sftpResource
        if self.sftpResourceUid and isinstance(self.sftpResourceUid, str) and self.sftpResourceUid.strip():
            dict["sftpResourceUid"] = self.sftpResourceUid.strip()
        # if resolved from sftpUser
        if self.sftpUserUid and isinstance(self.sftpUserUid, str) and self.sftpUserUid.strip():
            dict["sftpUserUid"] = self.sftpUserUid.strip()

        if self.sftpDirectory and isinstance(self.sftpDirectory, str) and self.sftpDirectory.strip():
            dict["sftpDirectory"] = self.sftpDirectory.strip()
        if self.sftpServerAliveInterval and type(self.sftpServerAliveInterval) is int and abs(self.sftpServerAliveInterval) > 0:
            dict["sftpServerAliveInterval"] = abs(self.sftpServerAliveInterval)

        return dict

def sftp_enabled(connection_settings: Union[PamConnectionSettings, ConnectionSettingsHTTP]) -> Optional[bool]:
    if connection_settings and connection_settings.protocol and connection_settings.protocol in (
        ConnectionProtocol.RDP,
        ConnectionProtocol.VNC,
        ConnectionProtocol.SSH
    ):
        sftp = getattr(connection_settings, "sftp", None)
        if sftp:
            enabled = getattr(sftp, "enableSftp", None)
            return enabled

class TerminalDisplayConnectionSettings:
    fontSizes: List[int] = [8,9,10,11,12,14,18,24,30,36,48,60,72,96]
    def __init__(self, colorScheme: Optional[str] = None, fontSize: Optional[int] = None):
        self.colorScheme = colorScheme
        self.fontSize = fontSize

    @classmethod
    def load(cls, data: Union[str, dict]):
        obj = cls()
        try: data = json.loads(data) if isinstance(data, str) else data
        except: logging.error(f"Terminal Display Connection Settings failed to load from: {str(data)[:80]}")
        if not isinstance(data, dict): return obj

        val = data.get("color_scheme", None)
        if isinstance(val, str): obj.colorScheme = val
        val = data.get("font_size", None)
        if type(val) is int: obj.fontSize = val
        elif val and str(val).isdecimal(): obj.fontSize = int(val)
        if obj.fontSize and type(obj.fontSize) is int:
            font_size: int = obj.fontSize
            closest_number = min(obj.fontSizes, key=lambda x: abs(x - font_size))
            if closest_number != font_size:
                logging.error(f"Terminal Display Connection Settings - adjusted invalid font_size from: {obj.fontSize} to: {closest_number}")
                obj.fontSize = closest_number
        return obj

class BaseConnectionSettings:
    def __init__(self, port: Optional[str] = None, allowSupplyUser: Optional[bool] = None, userRecords: Optional[List[str]] = None, recordingIncludeKeys: Optional[bool] = None):
        self.port = port  # Override port from host
        self.allowSupplyUser = allowSupplyUser
        self.recordingIncludeKeys = recordingIncludeKeys
        self.userRecords = userRecords
        self.userRecordUid = None # resolved from userRecords

    @classmethod
    def load(cls, data: Union[str, dict]):
        obj = cls()
        try: data = json.loads(data) if isinstance(data, str) else data
        except: logging.error(f"Base Connection Settings failed to load from: {str(data)[:80]}")
        if not isinstance(data, dict): return obj

        val = data.get("port", None)  # Override port from host
        if isinstance(val, str) or str(val).isdecimal(): obj.port = str(val)

        obj.allowSupplyUser = utils.value_to_boolean(data.get("allow_supply_user", None))
        obj.userRecords = parse_multiline(data, "administrative_credentials", "Error parsing administrative_credentials")
        obj.recordingIncludeKeys = utils.value_to_boolean(data.get("recording_include_keys", None))
        return obj

class ConnectionSettingsRDP(BaseConnectionSettings, ClipboardConnectionSettings):
    protocol = ConnectionProtocol.RDP
    def __init__(
        self,
        port: Optional[str] = None,  # Override port from host
        allowSupplyUser: Optional[bool] = None,
        userRecords: Optional[List[str]] = None,
        recordingIncludeKeys: Optional[bool] = None,
        disableCopy: Optional[bool] = None,
        disablePaste: Optional[bool] = None,
        security: Optional[RDPSecurity] = None,
        disableAuth: Optional[bool] = None,
        ignoreCert: Optional[bool] = None,
        loadBalanceInfo: Optional[str] = None,
        preconnectionId: Optional[str] = None,
        preconnectionBlob: Optional[str] = None,
        sftp: Optional[SFTPConnectionSettings] = None,
        disableAudio: Optional[bool] = None,
        resizeMethod: Optional[str] = None,
        enableWallpaper: Optional[bool] = None,
        enableFullWindowDrag: Optional[bool] = None
    ):
        BaseConnectionSettings.__init__(self, port, allowSupplyUser, userRecords, recordingIncludeKeys)
        ClipboardConnectionSettings.__init__(self, disableCopy, disablePaste)
        self.security = security if isinstance(security, RDPSecurity) else None
        self.disableAuth = disableAuth
        self.ignoreCert = ignoreCert
        self.loadBalanceInfo = loadBalanceInfo
        self.preconnectionId = preconnectionId
        self.preconnectionBlob = preconnectionBlob
        self.sftp = sftp if isinstance(sftp, SFTPConnectionSettings) else None
        self.disableAudio = disableAudio
        self.resizeMethod = resizeMethod # disable_dynamic_resizing ? "" : "display-update"
        # Performance Properties
        self.enableWallpaper = enableWallpaper
        self.enableFullWindowDrag = enableFullWindowDrag

    @classmethod
    def load(cls, data: Union[str, dict]):
        obj = cls()
        try: data = json.loads(data) if isinstance(data, str) else data
        except: logging.error(f"Connection Settings RDP failed to load from: {str(data)[:80]}")
        if not isinstance(data, dict): return obj

        bcs = BaseConnectionSettings.load(data)
        if bcs:
            obj.port = bcs.port
            obj.allowSupplyUser = bcs.allowSupplyUser
            obj.userRecords = bcs.userRecords
            obj.recordingIncludeKeys = bcs.recordingIncludeKeys

        ccs = ClipboardConnectionSettings.load(data)
        if ccs:
            obj.disableCopy = ccs.disableCopy
            obj.disablePaste = ccs.disablePaste

        val = data.get("security", None)
        if isinstance(val, str): obj.security = RDPSecurity.map(val)
        obj.disableAuth = utils.value_to_boolean(data.get("disable_authentication", None))
        obj.ignoreCert = utils.value_to_boolean(data.get("ignore_server_cert", None))

        val = data.get("load_balance_info", None)
        if isinstance(val, str): obj.loadBalanceInfo = val # LoadBalance Info/Cookie
        val = data.get("preconnection_id", None)
        if isinstance(val, str): obj.preconnectionId = val
        val = data.get("preconnection_blob", None)
        if isinstance(val, str): obj.preconnectionBlob = val
        sftp = data.get("sftp", None)
        if isinstance(sftp, dict): obj.sftp = SFTPConnectionSettings.load(sftp)

        obj.disableAudio = utils.value_to_boolean(data.get("disable_audio", None))
        obj.enableWallpaper = utils.value_to_boolean(data.get("enable_wallpaper", None))
        obj.enableFullWindowDrag = utils.value_to_boolean(data.get("enable_full_window_drag", None))

        # disable_dynamic_resizing ? "" : "display-update"
        val = utils.value_to_boolean(data.get("disable_dynamic_resizing", None))
        if val is not True: obj.resizeMethod = "display-update"

        return obj

    def to_record_dict(self):
        kvp: Dict[str, Any] = { "protocol": ConnectionProtocol.RDP.value }  # pylint: disable=E1101

        # if resolved (userRecords->userRecordUid) from administrative_credentials (usually after user create)
        recs: list = self.userRecordUid if self.userRecordUid and isinstance(self.userRecordUid, list) else []
        uids = [x.strip() for x in recs if isinstance(x, str) and x.strip() != ""]
        if uids:
            kvp["userRecords"] = uids

        if self.port and isinstance(self.port, str) and self.port.strip():
            kvp["port"] = self.port.strip()
        if self.allowSupplyUser is not None and isinstance(self.allowSupplyUser, bool):
            kvp["allowSupplyUser"] = self.allowSupplyUser
        if self.recordingIncludeKeys is not None and isinstance(self.recordingIncludeKeys, bool):
            kvp["recordingIncludeKeys"] = self.recordingIncludeKeys
        if self.disableCopy is not None and isinstance(self.disableCopy, bool):
            kvp["disableCopy"] = self.disableCopy
        if self.disablePaste is not None and isinstance(self.disablePaste, bool):
            kvp["disablePaste"] = self.disablePaste
        if isinstance(self.security, RDPSecurity):
            kvp["security"] = self.security.value.lower()

        if self.disableAuth is not None and isinstance(self.disableAuth, bool):
            kvp["disableAuth"] = self.disableAuth
        if self.ignoreCert is not None and isinstance(self.ignoreCert, bool):
            kvp["ignoreCert"] = self.ignoreCert

        if self.loadBalanceInfo and isinstance(self.loadBalanceInfo, str) and self.loadBalanceInfo.strip():
            kvp["loadBalanceInfo"] = self.loadBalanceInfo.strip()
        if self.preconnectionId and isinstance(self.preconnectionId, str) and self.preconnectionId.strip():
            kvp["preconnectionId"] = self.preconnectionId.strip()
        if self.preconnectionBlob and isinstance(self.preconnectionBlob, str) and self.preconnectionBlob.strip():
            kvp["preconnectionBlob"] = self.preconnectionBlob.strip()

        if self.disableAudio is not None and isinstance(self.disableAudio, bool):
            kvp["disableAudio"] = self.disableAudio
        if self.enableFullWindowDrag is not None and isinstance(self.enableFullWindowDrag, bool):
            kvp["enableFullWindowDrag"] = self.enableFullWindowDrag
        if self.enableWallpaper is not None and isinstance(self.enableWallpaper, bool):
            kvp["enableWallpaper"] = self.enableWallpaper

        # populated on load - "resizeMethod": disable_dynamic_resizing ? "" : "display-update"
        if str(self.resizeMethod) == "display-update":
            kvp["resizeMethod"] = self.resizeMethod

        if isinstance(self.sftp, SFTPConnectionSettings):
            sftp = self.sftp.to_dict()
            if sftp:
                kvp["sftp"] = sftp

        return kvp

    def to_record_json(self):
        dict = self.to_record_dict() or {}
        rec_json = json.dumps(dict)
        return rec_json

# field type: pamRemoteBrowserSettings
class ConnectionSettingsHTTP(BaseConnectionSettings, ClipboardConnectionSettings):
    protocol = ConnectionProtocol.HTTP
    def __init__(
        self,
        port: Optional[str] = None,  # Override port from host
        allowSupplyUser: Optional[bool] = None,
        userRecords: Optional[List[str]] = None,
        recordingIncludeKeys: Optional[bool] = None,
        disableCopy: Optional[bool] = None,
        disablePaste: Optional[bool] = None,
        allowUrlManipulation: Optional[bool] = None,
        allowedUrlPatterns: Optional[str] = None,
        allowedResourceUrlPatterns: Optional[str] = None,
        httpCredentials: Optional[List[str]] = None, # autofill_credentials: login|pamUser
        autofillConfiguration: Optional[str] = None,
        ignoreInitialSslCert: Optional[bool] = None
    ):
        BaseConnectionSettings.__init__(self, port, allowSupplyUser, userRecords, recordingIncludeKeys)
        ClipboardConnectionSettings.__init__(self, disableCopy, disablePaste)
        self.allowUrlManipulation = allowUrlManipulation
        self.allowedUrlPatterns = allowedUrlPatterns
        self.allowedResourceUrlPatterns = allowedResourceUrlPatterns
        self.httpCredentials = httpCredentials # autofill_credentials: login|pamUser
        self.autofillConfiguration = autofillConfiguration
        self.ignoreInitialSslCert = ignoreInitialSslCert
        self.httpCredentialsUid = None # resolved from httpCredentials

    @classmethod
    def load(cls, data: Union[str, dict]):
        obj = cls()
        try: data = json.loads(data) if isinstance(data, str) else data
        except: logging.error(f"Connection Settings HTTP failed to load from: {str(data)[:80]}")
        if not isinstance(data, dict): return obj

        bcs = BaseConnectionSettings.load(data)
        if bcs:
            # obj.port = bcs.port # not yet in web UI of RBI
            obj.allowSupplyUser = bcs.allowSupplyUser
            obj.userRecords = bcs.userRecords
            obj.recordingIncludeKeys = bcs.recordingIncludeKeys

        ccs = ClipboardConnectionSettings.load(data)
        if ccs:
            obj.disableCopy = ccs.disableCopy
            obj.disablePaste = ccs.disablePaste

        obj.allowUrlManipulation = utils.value_to_boolean(data.get("allow_url_manipulation", None))
        obj.allowedUrlPatterns = multiline_to_str(parse_multiline(data, "allowed_url_patterns", "Error parsing allowed_url_patterns"))
        obj.allowedResourceUrlPatterns = multiline_to_str(parse_multiline(data, "allowed_resource_url_patterns", "Error parsing allowed_resource_url_patterns"))
        obj.httpCredentials = parse_multiline(data, "autofill_credentials", "Error parsing autofill_credentials")
        obj.autofillConfiguration = multiline_to_str(parse_multiline(data, "autofill_targets", "Error parsing autofill_targets"))
        obj.ignoreInitialSslCert = utils.value_to_boolean(data.get("ignore_server_cert", None))

        return obj

    def to_record_dict(self):
        kvp: Dict[str, Any] = { "protocol": ConnectionProtocol.HTTP.value }  # pylint: disable=E1101

        # if resolved (autofill_credentials->httpCredentialsUid) login|pamUser
        recs: list = self.httpCredentialsUid if self.httpCredentialsUid and isinstance(self.httpCredentialsUid, list) else []
        uids = [x.strip() for x in recs if isinstance(x, str) and x.strip() != ""]
        if uids:
            kvp["httpCredentialsUid"] = uids[0] # single credential

        # port - unused for RBI
        # if self.port and isinstance(self.port, str) and self.port.strip():
        #     kvp["port"] = self.port.strip()
        if self.allowSupplyUser is not None and isinstance(self.allowSupplyUser, bool):
            kvp["allowSupplyUser"] = self.allowSupplyUser
        if self.recordingIncludeKeys is not None and isinstance(self.recordingIncludeKeys, bool):
            kvp["recordingIncludeKeys"] = self.recordingIncludeKeys
        if self.disableCopy is not None and isinstance(self.disableCopy, bool):
            kvp["disableCopy"] = self.disableCopy
        if self.disablePaste is not None and isinstance(self.disablePaste, bool):
            kvp["disablePaste"] = self.disablePaste

        if self.allowUrlManipulation is not None and isinstance(self.allowUrlManipulation, bool):
            kvp["allowUrlManipulation"] = self.allowUrlManipulation
        if self.allowedUrlPatterns and isinstance(self.allowedUrlPatterns, str) and self.allowedUrlPatterns.strip():
            kvp["allowedUrlPatterns"] = self.allowedUrlPatterns.strip()
        if self.allowedResourceUrlPatterns and isinstance(self.allowedResourceUrlPatterns, str) and self.allowedResourceUrlPatterns.strip():
            kvp["allowedResourceUrlPatterns"] = self.allowedResourceUrlPatterns.strip()
        if self.autofillConfiguration and isinstance(self.autofillConfiguration, str) and self.autofillConfiguration.strip():
            kvp["autofillConfiguration"] = self.autofillConfiguration.strip()
        if self.ignoreInitialSslCert is not None and isinstance(self.ignoreInitialSslCert, bool):
            kvp["ignoreInitialSslCert"] = self.ignoreInitialSslCert

        return kvp

    def to_record_json(self):
        dict = self.to_record_dict() or {}
        rec_json = json.dumps(dict)
        return rec_json

class ConnectionSettingsVNC(BaseConnectionSettings, ClipboardConnectionSettings):
    protocol = ConnectionProtocol.VNC
    def __init__( # pylint: disable=R0917
        self,
        port: Optional[str] = None,  # Override port from host
        allowSupplyUser: Optional[bool] = None,
        userRecords: Optional[List[str]] = None,
        recordingIncludeKeys: Optional[bool] = None,
        disableCopy: Optional[bool] = None,
        disablePaste: Optional[bool] = None,
        destHost: Optional[str] = None,
        destPort: Optional[str] = None,
        sftp: Optional[SFTPConnectionSettings] = None
    ):
        BaseConnectionSettings.__init__(self, port, allowSupplyUser, userRecords, recordingIncludeKeys)
        ClipboardConnectionSettings.__init__(self, disableCopy, disablePaste)
        self.destHost = destHost
        self.destPort = destPort
        self.sftp = sftp if isinstance(sftp, SFTPConnectionSettings) else None

    @classmethod
    def load(cls, data: Union[str, dict]):
        obj = cls()
        try: data = json.loads(data) if isinstance(data, str) else data
        except: logging.error(f"Connection Settings VNC failed to load from: {str(data)[:80]}")
        if not isinstance(data, dict): return obj

        bcs = BaseConnectionSettings.load(data)
        if bcs:
            obj.port = bcs.port
            obj.allowSupplyUser = bcs.allowSupplyUser
            obj.userRecords = bcs.userRecords
            obj.recordingIncludeKeys = bcs.recordingIncludeKeys

        ccs = ClipboardConnectionSettings.load(data)
        if ccs:
            obj.disableCopy = ccs.disableCopy
            obj.disablePaste = ccs.disablePaste

        val = data.get("destination_host", None)
        if isinstance(val, str): obj.destHost = val
        val = data.get("destination_port", None)
        if isinstance(val, str): obj.destPort = val

        sftp = data.get("sftp", None)
        if isinstance(sftp, dict): obj.sftp = SFTPConnectionSettings.load(sftp)

        return obj

    def to_record_dict(self):
        kvp: Dict[str, Any] = { "protocol": ConnectionProtocol.VNC.value }  # pylint: disable=E1101

        # if resolved (userRecords->userRecordUid) from administrative_credentials (usually after user create)
        recs: list = self.userRecordUid if self.userRecordUid and isinstance(self.userRecordUid, list) else []
        uids = [x.strip() for x in recs if isinstance(x, str) and x.strip() != ""]
        if uids:
            kvp["userRecords"] = uids

        if self.port and isinstance(self.port, str) and self.port.strip():
            kvp["port"] = self.port.strip()
        if self.allowSupplyUser is not None and isinstance(self.allowSupplyUser, bool):
            kvp["allowSupplyUser"] = self.allowSupplyUser
        if self.recordingIncludeKeys is not None and isinstance(self.recordingIncludeKeys, bool):
            kvp["recordingIncludeKeys"] = self.recordingIncludeKeys
        if self.disableCopy is not None and isinstance(self.disableCopy, bool):
            kvp["disableCopy"] = self.disableCopy
        if self.disablePaste is not None and isinstance(self.disablePaste, bool):
            kvp["disablePaste"] = self.disablePaste

        if self.destHost and isinstance(self.destHost, str) and self.destHost.strip():
            kvp["destHost"] = self.destHost.strip()
        if self.destPort and isinstance(self.destPort, str) and self.destPort.strip():
            kvp["destPort"] = self.destPort.strip()

        if isinstance(self.sftp, SFTPConnectionSettings):
            sftp = self.sftp.to_dict()
            if sftp:
                kvp["sftp"] = sftp

        return kvp

    def to_record_json(self):
        dict = self.to_record_dict() or {}
        rec_json = json.dumps(dict)
        return rec_json

class ConnectionSettingsTelnet(BaseConnectionSettings, ClipboardConnectionSettings, TerminalDisplayConnectionSettings):
    protocol = ConnectionProtocol.TELNET
    def __init__( # pylint: disable=R0917
        self,
        port: Optional[str] = None,  # Override port from host
        allowSupplyUser: Optional[bool] = None,
        userRecords: Optional[List[str]] = None,
        recordingIncludeKeys: Optional[bool] = None,
        disableCopy: Optional[bool] = None,
        disablePaste: Optional[bool] = None,
        colorScheme: Optional[str] = None,
        fontSize: Optional[int] = None,
        usernameRegex: Optional[str] = None,
        passwordRegex: Optional[str] = None,
        loginSuccessRegex: Optional[str] = None,
        loginFailureRegex: Optional[str] = None
    ):
        BaseConnectionSettings.__init__(self, port, allowSupplyUser, userRecords, recordingIncludeKeys)
        ClipboardConnectionSettings.__init__(self, disableCopy, disablePaste)
        TerminalDisplayConnectionSettings.__init__(self, colorScheme, fontSize)
        self.usernameRegex = usernameRegex
        self.passwordRegex = passwordRegex
        self.loginSuccessRegex = loginSuccessRegex
        self.loginFailureRegex = loginFailureRegex

    @classmethod
    def load(cls, data: Union[str, dict]):
        obj = cls()
        try: data = json.loads(data) if isinstance(data, str) else data
        except: logging.error(f"Connection Settings Telnet failed to load from: {str(data)[:80]}")
        if not isinstance(data, dict): return obj

        bcs = BaseConnectionSettings.load(data)
        if bcs:
            obj.port = bcs.port
            obj.allowSupplyUser = bcs.allowSupplyUser
            obj.userRecords = bcs.userRecords
            obj.recordingIncludeKeys = bcs.recordingIncludeKeys

        ccs = ClipboardConnectionSettings.load(data)
        if ccs:
            obj.disableCopy = ccs.disableCopy
            obj.disablePaste = ccs.disablePaste

        tcs = TerminalDisplayConnectionSettings.load(data)
        if tcs:
            obj.colorScheme = tcs.colorScheme
            obj.fontSize = tcs.fontSize

        val = data.get("username_regex", None)
        if isinstance(val, str): obj.usernameRegex = val
        val = data.get("password_regex", None)
        if isinstance(val, str): obj.passwordRegex = val
        val = data.get("login_success_regex", None)
        if isinstance(val, str): obj.loginSuccessRegex = val
        val = data.get("login_failure_regex", None)
        if isinstance(val, str): obj.loginFailureRegex = val

        return obj

    def to_record_dict(self):
        kvp: Dict[str, Any] = { "protocol": ConnectionProtocol.TELNET.value }  # pylint: disable=E1101

        # if resolved (userRecords->userRecordUid) from administrative_credentials (usually after user create)
        recs: list = self.userRecordUid if self.userRecordUid and isinstance(self.userRecordUid, list) else []
        uids = [x.strip() for x in recs if isinstance(x, str) and x.strip() != ""]
        if uids:
            kvp["userRecords"] = uids

        if self.port and isinstance(self.port, str) and self.port.strip():
            kvp["port"] = self.port.strip()
        if self.allowSupplyUser is not None and isinstance(self.allowSupplyUser, bool):
            kvp["allowSupplyUser"] = self.allowSupplyUser
        if self.recordingIncludeKeys is not None and isinstance(self.recordingIncludeKeys, bool):
            kvp["recordingIncludeKeys"] = self.recordingIncludeKeys
        if self.disableCopy is not None and isinstance(self.disableCopy, bool):
            kvp["disableCopy"] = self.disableCopy
        if self.disablePaste is not None and isinstance(self.disablePaste, bool):
            kvp["disablePaste"] = self.disablePaste

        if self.colorScheme and isinstance(self.colorScheme, str) and self.colorScheme.strip():
            kvp["colorScheme"] = self.colorScheme.strip()
        if self.fontSize and type(self.fontSize) is int and self.fontSize > 4:
            kvp["fontSize"] = str(self.fontSize)
        if self.usernameRegex and isinstance(self.usernameRegex, str) and self.usernameRegex.strip():
            kvp["usernameRegex"] = self.usernameRegex.strip()
        if self.passwordRegex and isinstance(self.passwordRegex, str) and self.passwordRegex.strip():
            kvp["passwordRegex"] = self.passwordRegex.strip()
        if self.loginSuccessRegex and isinstance(self.loginSuccessRegex, str) and self.loginSuccessRegex.strip():
            kvp["loginSuccessRegex"] = self.loginSuccessRegex.strip()
        if self.loginFailureRegex and isinstance(self.loginFailureRegex, str) and self.loginFailureRegex.strip():
            kvp["loginFailureRegex"] = self.loginFailureRegex.strip()

        return kvp

    def to_record_json(self):
        dict = self.to_record_dict() or {}
        rec_json = json.dumps(dict)
        return rec_json

class ConnectionSettingsSSH(BaseConnectionSettings, ClipboardConnectionSettings, TerminalDisplayConnectionSettings):
    protocol = ConnectionProtocol.SSH
    def __init__( # pylint: disable=R0917
        self,
        port: Optional[str] = None,  # Override port from host
        allowSupplyUser: Optional[bool] = None,
        userRecords: Optional[List[str]] = None,
        recordingIncludeKeys: Optional[bool] = None,
        disableCopy: Optional[bool] = None,
        disablePaste: Optional[bool] = None,
        colorScheme: Optional[str] = None,
        fontSize: Optional[int] = None,
        hostKey: Optional[str] = None,
        command: Optional[str] = None,
        sftp: Optional[SFTPRootDirectorySettings] = None
    ):
        BaseConnectionSettings.__init__(self, port, allowSupplyUser, userRecords, recordingIncludeKeys)
        ClipboardConnectionSettings.__init__(self, disableCopy, disablePaste)
        TerminalDisplayConnectionSettings.__init__(self, colorScheme, fontSize)
        self.hostKey = hostKey
        self.command = command
        self.sftp = sftp if isinstance(sftp, SFTPRootDirectorySettings) else None

    @classmethod
    def load(cls, data: Union[str, dict]):
        obj = cls()
        try: data = json.loads(data) if isinstance(data, str) else data
        except: logging.error(f"Connection Settings SSH failed to load from: {str(data)[:80]}")
        if not isinstance(data, dict): return obj

        bcs = BaseConnectionSettings.load(data)
        if bcs:
            obj.port = bcs.port
            obj.allowSupplyUser = bcs.allowSupplyUser
            obj.userRecords = bcs.userRecords
            obj.recordingIncludeKeys = bcs.recordingIncludeKeys

        ccs = ClipboardConnectionSettings.load(data)
        if ccs:
            obj.disableCopy = ccs.disableCopy
            obj.disablePaste = ccs.disablePaste

        tcs = TerminalDisplayConnectionSettings.load(data)
        if tcs:
            obj.colorScheme = tcs.colorScheme
            obj.fontSize = tcs.fontSize

        val = data.get("public_host_key", None)
        if isinstance(val, str): obj.hostKey = val
        val = data.get("command", None)
        if isinstance(val, str): obj.command = val
        sftp = data.get("sftp", None)
        if isinstance(sftp, dict): obj.sftp = SFTPRootDirectorySettings.load(sftp)

        return obj

    def to_record_dict(self):
        kvp: Dict[str, Any] = { "protocol": ConnectionProtocol.SSH.value }  # pylint: disable=E1101

        # if resolved (userRecords->userRecordUid) from administrative_credentials (usually after user create)
        recs: list = self.userRecordUid if self.userRecordUid and isinstance(self.userRecordUid, list) else []
        uids = [x.strip() for x in recs if isinstance(x, str) and x.strip() != ""]
        if uids:
            kvp["userRecords"] = uids

        if self.port and isinstance(self.port, str) and self.port.strip():
            kvp["port"] = self.port.strip()
        if self.allowSupplyUser is not None and isinstance(self.allowSupplyUser, bool):
            kvp["allowSupplyUser"] = self.allowSupplyUser
        if self.recordingIncludeKeys is not None and isinstance(self.recordingIncludeKeys, bool):
            kvp["recordingIncludeKeys"] = self.recordingIncludeKeys
        if self.disableCopy is not None and isinstance(self.disableCopy, bool):
            kvp["disableCopy"] = self.disableCopy
        if self.disablePaste is not None and isinstance(self.disablePaste, bool):
            kvp["disablePaste"] = self.disablePaste

        if self.colorScheme and isinstance(self.colorScheme, str) and self.colorScheme.strip():
            kvp["colorScheme"] = self.colorScheme.strip()
        if self.fontSize and type(self.fontSize) is int and self.fontSize > 4:
            kvp["fontSize"] = str(self.fontSize)
        if self.hostKey and isinstance(self.hostKey, str) and self.hostKey.strip():
            kvp["hostKey"] = self.hostKey.strip()
        if self.command and isinstance(self.command, str) and self.command.strip():
            kvp["command"] = self.command.strip()

        if isinstance(self.sftp, SFTPRootDirectorySettings):
            srds = self.sftp.to_dict()
            if srds:
                kvp["sftp"] = srds

        return kvp

    def to_record_json(self):
        dict = self.to_record_dict() or {}
        rec_json = json.dumps(dict)
        return rec_json

class ConnectionSettingsKubernetes(BaseConnectionSettings, TerminalDisplayConnectionSettings):
    protocol = ConnectionProtocol.KUBERNETES
    def __init__( # pylint: disable=R0917
        self,
        port: Optional[str] = None,  # Override port from host
        allowSupplyUser: Optional[bool] = None,
        userRecords: Optional[List[str]] = None,
        recordingIncludeKeys: Optional[bool] = None,
        colorScheme: Optional[str] = None,
        fontSize: Optional[int] = None,
        ignoreCert: Optional[bool] = None,
        caCert: Optional[str] = None,
        namespace: Optional[str] = None,
        pod: Optional[str] = None,
        container: Optional[str] = None,
        clientCert: Optional[str] = None,
        clientKey: Optional[str] = None
    ):
        BaseConnectionSettings.__init__(self, port, allowSupplyUser, userRecords, recordingIncludeKeys)
        TerminalDisplayConnectionSettings.__init__(self, colorScheme, fontSize)
        self.ignoreCert = ignoreCert
        self.caCert = caCert
        self.namespace = namespace
        self.pod = pod
        self.container = container
        self.clientCert = clientCert
        self.clientKey = clientKey

    @classmethod
    def load(cls, data: Union[str, dict]):
        obj = cls()
        try: data = json.loads(data) if isinstance(data, str) else data
        except: logging.error(f"Connection Settings K8S failed to load from: {str(data)[:80]}")
        if not isinstance(data, dict): return obj

        bcs = BaseConnectionSettings.load(data)
        if bcs:
            obj.port = bcs.port
            obj.allowSupplyUser = bcs.allowSupplyUser
            obj.userRecords = bcs.userRecords
            obj.recordingIncludeKeys = bcs.recordingIncludeKeys

        tcs = TerminalDisplayConnectionSettings.load(data)
        if tcs:
            obj.colorScheme = tcs.colorScheme
            obj.fontSize = tcs.fontSize

        val = data.get("namespace", None)
        if isinstance(val, str): obj.namespace = val
        val = data.get("pod_name", None)
        if isinstance(val, str): obj.pod = val
        val = data.get("container", None)
        if isinstance(val, str): obj.container = val
        obj.ignoreCert = utils.value_to_boolean(data.get("ignore_server_cert", None))
        obj.caCert = multiline_to_str(parse_multiline(data, "ca_certificate", "Error parsing ca_certificate"))
        obj.clientCert = multiline_to_str(parse_multiline(data, "client_certificate", "Error parsing client_certificate"))
        obj.clientKey = multiline_to_str(parse_multiline(data, "client_key", "Error parsing client_key"))

        return obj

    def to_record_dict(self):
        kvp: Dict[str, Any] = { "protocol": ConnectionProtocol.KUBERNETES.value }  # pylint: disable=E1101

        # if resolved (userRecords->userRecordUid) from administrative_credentials (usually after user create)
        recs: list = self.userRecordUid if self.userRecordUid and isinstance(self.userRecordUid, list) else []
        uids = [x.strip() for x in recs if isinstance(x, str) and x.strip() != ""]
        if uids:
            kvp["userRecords"] = uids

        if self.port and isinstance(self.port, str) and self.port.strip():
            kvp["port"] = self.port.strip()
        if self.allowSupplyUser is not None and isinstance(self.allowSupplyUser, bool):
            kvp["allowSupplyUser"] = self.allowSupplyUser
        if self.recordingIncludeKeys is not None and isinstance(self.recordingIncludeKeys, bool):
            kvp["recordingIncludeKeys"] = self.recordingIncludeKeys
        if self.colorScheme and isinstance(self.colorScheme, str) and self.colorScheme.strip():
            kvp["colorScheme"] = self.colorScheme.strip()
        if self.fontSize and type(self.fontSize) is int and self.fontSize > 4:
            kvp["fontSize"] = str(self.fontSize)
        if self.namespace and isinstance(self.namespace, str) and self.namespace.strip():
            kvp["namespace"] = self.namespace.strip()
        if self.pod and isinstance(self.pod, str) and self.pod.strip():
            kvp["pod"] = self.pod.strip()

        if self.container and isinstance(self.container, str) and self.container.strip():
            kvp["container"] = self.container.strip()
        if self.ignoreCert is not None and isinstance(self.ignoreCert, bool):
            kvp["ignoreCert"] = self.ignoreCert
        if self.caCert and isinstance(self.caCert, str) and self.caCert.strip():
            kvp["caCert"] = self.caCert.strip()
        if self.clientCert and isinstance(self.clientCert, str) and self.clientCert.strip():
            kvp["clientCert"] = self.clientCert.strip()
        if self.clientKey and isinstance(self.clientKey, str) and self.clientKey.strip():
            kvp["clientKey"] = self.clientKey.strip()

        return kvp

    def to_record_json(self):
        dict = self.to_record_dict() or {}
        rec_json = json.dumps(dict)
        return rec_json

class BaseDatabaseConnectionSettings(BaseConnectionSettings, ClipboardConnectionSettings):
    def __init__( # pylint: disable=R0917
        self,
        port: Optional[str] = None,  # Override port from host
        allowSupplyUser: Optional[bool] = None,
        userRecords: Optional[List[str]] = None,
        recordingIncludeKeys: Optional[bool] = None,
        disableCopy: Optional[bool] = None,
        disablePaste: Optional[bool] = None,
        database: Optional[str] = None,
        disableCsvExport: Optional[bool] = None,
        disableCsvImport: Optional[bool] = None
    ):
        BaseConnectionSettings.__init__(self, port, allowSupplyUser, userRecords, recordingIncludeKeys)
        ClipboardConnectionSettings.__init__(self, disableCopy, disablePaste)
        self.database = database
        self.disableCsvExport = disableCsvExport
        self.disableCsvImport = disableCsvImport

    @classmethod
    def load(cls, data: Union[str, dict]):
        obj = cls()
        try: data = json.loads(data) if isinstance(data, str) else data
        except: logging.error(f"Database Connection Settings failed to load from: {str(data)[:80]}")
        if not isinstance(data, dict): return obj

        bcs = BaseConnectionSettings.load(data)
        if bcs:
            obj.port = bcs.port
            obj.allowSupplyUser = bcs.allowSupplyUser
            obj.userRecords = bcs.userRecords
            obj.recordingIncludeKeys = bcs.recordingIncludeKeys

        ccs = ClipboardConnectionSettings.load(data)
        if ccs:
            obj.disableCopy = ccs.disableCopy
            obj.disablePaste = ccs.disablePaste

        val = data.get("default_database", None)
        if isinstance(val, str): obj.database = val
        obj.disableCsvExport = utils.value_to_boolean(data.get("disable_csv_export", None))
        obj.disableCsvImport = utils.value_to_boolean(data.get("disable_csv_import", None))

        return obj

    def to_record_dict(self):
        kvp: Dict[str, Any] = {}

        # if resolved (userRecords->userRecordUid) from administrative_credentials (usually after user create)
        recs: list = self.userRecordUid if self.userRecordUid and isinstance(self.userRecordUid, list) else []
        uids = [x.strip() for x in recs if isinstance(x, str) and x.strip() != ""]
        if uids:
            kvp["userRecords"] = uids

        if self.port and isinstance(self.port, str) and self.port.strip():
            kvp["port"] = self.port.strip()
        if self.allowSupplyUser is not None and isinstance(self.allowSupplyUser, bool):
            kvp["allowSupplyUser"] = self.allowSupplyUser
        if self.recordingIncludeKeys is not None and isinstance(self.recordingIncludeKeys, bool):
            kvp["recordingIncludeKeys"] = self.recordingIncludeKeys
        if self.disableCopy is not None and isinstance(self.disableCopy, bool):
            kvp["disableCopy"] = self.disableCopy
        if self.disablePaste is not None and isinstance(self.disablePaste, bool):
            kvp["disablePaste"] = self.disablePaste
        if self.disableCsvExport is not None and isinstance(self.disableCsvExport, bool):
            kvp["disableCsvExport"] = self.disableCsvExport
        if self.disableCsvImport is not None and isinstance(self.disableCsvImport, bool):
            kvp["disableCsvImport"] = self.disableCsvImport
        if self.database and isinstance(self.database, str) and self.database.strip():
            kvp["database"] = self.database.strip()

        return kvp

    def to_record_json(self):
        dict = self.to_record_dict() or {}
        rec_json = json.dumps(dict)
        return rec_json

class ConnectionSettingsSqlServer(BaseDatabaseConnectionSettings):
    protocol = ConnectionProtocol.SQLSERVER
    def __init__( # pylint: disable=W0246
        self,
        port: Optional[str] = None,  # Override port from host
        allowSupplyUser: Optional[bool] = None,
        userRecords: Optional[List[str]] = None,
        recordingIncludeKeys: Optional[bool] = None,
        disableCopy: Optional[bool] = None,
        disablePaste: Optional[bool] = None,
        database: Optional[str] = None,
        disableCsvExport: Optional[bool] = None,
        disableCsvImport: Optional[bool] = None
    ):
        super().__init__(port, allowSupplyUser, userRecords, recordingIncludeKeys,
                         disableCopy, disablePaste, database,
                         disableCsvExport, disableCsvImport)

    @classmethod
    def load(cls, data: Union[str, dict]):
        obj = cls()
        try: data = json.loads(data) if isinstance(data, str) else data
        except: logging.error(f"SQLServer Connection Settings failed to load from: {str(data)[:80]}")
        if not isinstance(data, dict): return obj

        bdcs = BaseDatabaseConnectionSettings.load(data)
        if bdcs:
            obj.port = bdcs.port
            obj.allowSupplyUser = bdcs.allowSupplyUser
            obj.userRecords = bdcs.userRecords
            obj.recordingIncludeKeys = bdcs.recordingIncludeKeys
            obj.disableCopy = bdcs.disableCopy
            obj.disablePaste = bdcs.disablePaste
            obj.database = bdcs.database
            obj.disableCsvExport = bdcs.disableCsvExport
            obj.disableCsvImport = bdcs.disableCsvImport

        return obj

    def to_record_dict(self):
        dict = super().to_record_dict()
        dict["protocol"] = ConnectionProtocol.SQLSERVER.value  # pylint: disable=E1101
        return dict

class ConnectionSettingsPostgreSQL(BaseDatabaseConnectionSettings):
    protocol = ConnectionProtocol.POSTGRESQL
    def __init__( # pylint: disable=W0246,R0917
        self,
        port: Optional[str] = None,  # Override port from host
        allowSupplyUser: Optional[bool] = None,
        userRecords: Optional[List[str]] = None,
        recordingIncludeKeys: Optional[bool] = None,
        disableCopy: Optional[bool] = None,
        disablePaste: Optional[bool] = None,
        database: Optional[str] = None,
        disableCsvExport: Optional[bool] = None,
        disableCsvImport: Optional[bool] = None
    ):
        super().__init__(port, allowSupplyUser, userRecords, recordingIncludeKeys,
                         disableCopy, disablePaste, database,
                         disableCsvExport, disableCsvImport)

    @classmethod
    def load(cls, data: Union[str, dict]):
        obj = cls()
        try: data = json.loads(data) if isinstance(data, str) else data
        except: logging.error(f"PostgreSQL Connection Settings failed to load from: {str(data)[:80]}")
        if not isinstance(data, dict): return obj

        bdcs = BaseDatabaseConnectionSettings.load(data)
        if bdcs:
            obj.port = bdcs.port
            obj.allowSupplyUser = bdcs.allowSupplyUser
            obj.userRecords = bdcs.userRecords
            obj.recordingIncludeKeys = bdcs.recordingIncludeKeys
            obj.disableCopy = bdcs.disableCopy
            obj.disablePaste = bdcs.disablePaste
            obj.database = bdcs.database
            obj.disableCsvExport = bdcs.disableCsvExport
            obj.disableCsvImport = bdcs.disableCsvImport

        return obj

    def to_record_dict(self):
        dict = super().to_record_dict()
        dict["protocol"] = ConnectionProtocol.POSTGRESQL.value  # pylint: disable=E1101
        return dict

class ConnectionSettingsMySQL(BaseDatabaseConnectionSettings):
    protocol = ConnectionProtocol.MYSQL
    def __init__( # pylint: disable=W0246,R0917
        self,
        port: Optional[str] = None,  # Override port from host
        allowSupplyUser: Optional[bool] = None,
        userRecords: Optional[List[str]] = None,
        recordingIncludeKeys: Optional[bool] = None,
        disableCopy: Optional[bool] = None,
        disablePaste: Optional[bool] = None,
        database: Optional[str] = None,
        disableCsvExport: Optional[bool] = None,
        disableCsvImport: Optional[bool] = None
    ):
        super().__init__(port, allowSupplyUser, userRecords, recordingIncludeKeys,
                         disableCopy, disablePaste, database,
                         disableCsvExport, disableCsvImport)

    @classmethod
    def load(cls, data: Union[str, dict]):
        obj = cls()
        try: data = json.loads(data) if isinstance(data, str) else data
        except: logging.error(f"MySQL Connection Settings failed to load from: {str(data)[:80]}")
        if not isinstance(data, dict): return obj

        bdcs = BaseDatabaseConnectionSettings.load(data)
        if bdcs:
            obj.port = bdcs.port
            obj.allowSupplyUser = bdcs.allowSupplyUser
            obj.userRecords = bdcs.userRecords
            obj.recordingIncludeKeys = bdcs.recordingIncludeKeys
            obj.disableCopy = bdcs.disableCopy
            obj.disablePaste = bdcs.disablePaste
            obj.database = bdcs.database
            obj.disableCsvExport = bdcs.disableCsvExport
            obj.disableCsvImport = bdcs.disableCsvImport

        return obj

    def to_record_dict(self):
        dict = super().to_record_dict()
        dict["protocol"] = ConnectionProtocol.MYSQL.value  # pylint: disable=E1101
        return dict

PamConnectionSettings = Optional[
    Union[
        ConnectionSettingsRDP,
        ConnectionSettingsVNC,
        ConnectionSettingsTelnet,
        ConnectionSettingsSSH,
        ConnectionSettingsKubernetes,
        ConnectionSettingsSqlServer,
        ConnectionSettingsPostgreSQL,
        ConnectionSettingsMySQL
    ]
]

class PamPortForwardSettings:
    def __init__(self, port: Optional[str] = None, reusePort: Optional[bool] = None):
        self.port = port  # Override Port from host
        self.reusePort = reusePort  # Attempt to use the last connected port if available

    @classmethod
    def load(cls, data: Union[str, dict]):
        obj = cls()
        try: data = json.loads(data) if isinstance(data, str) else data
        except: logging.error(f"Port Forward Settings failed to load from: {str(data)[:80]}")
        if not isinstance(data, dict): return obj

        obj.port = data.get("port", None)
        obj.reusePort = utils.value_to_boolean(data.get("reuse_port", None))
        return obj

    def to_record_dict(self):
        dict = {}
        if self.port and isinstance(self.port, str) and self.port.strip():
            dict["port"] = self.port.strip()
        if self.reusePort is not None and isinstance(self.reusePort, bool):
            dict["reusePort"] = self.reusePort
        return dict

    def to_record_json(self):
        dict = self.to_record_dict() or {}
        rec_json = json.dumps(dict)
        return rec_json

class PamRemoteBrowserSettings:
    def __init__(
        self,
        options: Optional[DagSettingsObject] = None,
        connection: Optional[ConnectionSettingsHTTP] = None
    ):
        self.options = options
        self.connection = connection

    @classmethod
    def load(cls, data: Optional[Union[str, dict]]):
        obj = cls()
        try: data = json.loads(data) if isinstance(data, str) else data
        except: logging.error(f"PAM RBI Settings field failed to load from: {str(data)[:80]}...")
        if not isinstance(data, dict): return obj

        options = DagSettingsObject.load(data.get("options", {}))
        if not is_empty_instance(options):
            obj.options = options

        cdata = data.get("connection", {})
        # TO DO: if isinstance(cdata, str): lookup_by_name(pam_data.connections)
        if not isinstance(cdata, dict):
            logging.warning(f"""PAM RBI Settings: Connection must be a JSON object - skipping... "{str(cdata)[:24]}" """)
        if cdata and isinstance(cdata, dict):
            proto = cdata.get("protocol", "")
            if proto and isinstance(proto, str):
                if proto.lower() == "http":
                    conn = ConnectionSettingsHTTP.load(cdata)
                    if not is_empty_instance(conn):
                        obj.connection = conn
                else:
                    logging.warning(f"""Connection skipped: unknown protocol "{str(proto)[:24]}" """)

        if not obj.connection and cdata and isinstance(cdata, dict):
            logging.error(f"PAM RBI Settings failed to load from: {str(cdata)[:80]}...")

        return obj

class PamSettingsFieldData:
    def __init__(
        self,
        allowSupplyHost: Optional[bool] = None,
        connection: PamConnectionSettings = None,  # Optional[PamConnectionSettings]
        portForward: Optional[PamPortForwardSettings] = None,
        options: Optional[DagSettingsObject] = None,
    ):
        self.allowSupplyHost = allowSupplyHost
        self.connection = connection
        self.portForward = portForward
        self.options = options

    # PamConnectionSettings excludes ConnectionSettingsHTTP
    pam_connection_classes = [
        ConnectionSettingsRDP,
        ConnectionSettingsVNC,
        ConnectionSettingsTelnet,
        ConnectionSettingsSSH,
        ConnectionSettingsKubernetes,
        ConnectionSettingsSqlServer,
        ConnectionSettingsPostgreSQL,
        ConnectionSettingsMySQL
    ]

    @classmethod
    def get_connection_class(cls, cdata: dict):
        if cdata and isinstance(cdata, dict):
            proto = cdata.get("protocol", "")
            if proto and isinstance(proto, str):
                proto = proto.lower()
                for con in cls.pam_connection_classes:
                    pr = getattr(con, "protocol", "")
                    if isinstance(pr, ConnectionProtocol) and pr.value.lower() == proto:  # pylint: disable=E1101
                        return con.load(cdata)
            logging.warning(f"""Connection skipped: unknown protocol "{str(proto)[:24]}" """)
        return None

    def is_empty(self):
        empty = is_empty_instance(self.options)
        empty = empty and is_empty_instance(self.portForward)
        empty = empty and is_empty_instance(self.connection, ["protocol"])
        return empty

    @classmethod
    def load(cls, data: Union[str, dict]):
        obj = cls()
        try: data = json.loads(data) if isinstance(data, str) else data
        except: logging.error(f"PAM Settings Field failed to load from: {str(data)[:80]}...")
        if not isinstance(data, dict): return obj

        obj.allowSupplyHost = utils.value_to_boolean(data.get("allow_supply_host", None))
        options = DagSettingsObject.load(data.get("options", {}))
        if not is_empty_instance(options):
            obj.options = options

        portForward = PamPortForwardSettings.load(data.get("port_forward", {}))
        if not is_empty_instance(portForward):
            obj.portForward = portForward

        cdata = data.get("connection", {})
        # TO DO: if isinstance(cdata, str): lookup_by_name(pam_data.connections)
        if not isinstance(cdata, dict):
            logging.warning(f"""PAM Settings: Connection must be a JSON object - skipping... "{str(cdata)[:24]}" """)
        obj.connection = cls.get_connection_class(cdata)
        if not obj.connection and cdata and isinstance(cdata, dict):
            logging.error(f"PAM Settings failed to load from: {str(cdata)[:80]}...")

        return obj

def is_empty_instance(obj, skiplist: Optional[List[str]] = None):
    """ Checks if all attributes (not on skiplist) are None """
    if not obj: return True
    if not isinstance(skiplist, list): skiplist= []
    for attr, value in vars(obj).items():
        if not (attr in skiplist or value is None):
            return False
    return True

def is_blank_instance(obj, skiplist: Optional[List[str]] = None):
    """ Checks if all attributes (not on skiplist) are None or empty """
    if not obj: return True
    if not isinstance(skiplist, list): skiplist= []
    for attr, value in vars(obj).items():
        if not (attr in skiplist or not value):
            return False
    return True

def get_sftp_attribute(obj, name: str) -> str:
    # Get one of pam_settings.connection.sftp.{sftpResource,sftpResourceUid,sftpUser,sftpUserUid}
    value: str = ""
    if (name and obj and 
        hasattr(obj, "pam_settings") and
        hasattr(obj.pam_settings, "connection") and
        hasattr(obj.pam_settings.connection, "sftp")):
        if name == "sftpResource" and hasattr(obj.pam_settings.connection.sftp, "sftpResource"):
            value = obj.pam_settings.connection.sftp.sftpResource or ""
        elif name == "sftpResourceUid" and hasattr(obj.pam_settings.connection.sftp, "sftpResourceUid"):
            value = obj.pam_settings.connection.sftp.sftpResourceUid or ""
        elif name == "sftpUser" and hasattr(obj.pam_settings.connection.sftp, "sftpUser"):
            value = obj.pam_settings.connection.sftp.sftpUser or ""
        elif name == "sftpUserUid" and hasattr(obj.pam_settings.connection.sftp, "sftpUserUid"):
            value = obj.pam_settings.connection.sftp.sftpUserUid or ""
        else:
            logging.debug(f"""Unknown sftp attribute "{name}" (skipped)""")
    value = value[0] if isinstance(value, list) else value
    value = value if isinstance(value, str) else ""
    return value

def set_sftp_uid(obj, name: str, uid: str) -> bool:
    if not(obj and name):
        return False
    if not(uid and isinstance(uid, str) and RecordV3.is_valid_ref_uid(uid)):
        logging.debug(f"""Invalid sftp UID "{uid}" (skipped)""")
        return False
    if (hasattr(obj, "pam_settings") and
        hasattr(obj.pam_settings, "connection") and
        hasattr(obj.pam_settings.connection, "sftp")):
        if name == "sftpResourceUid" and hasattr(obj.pam_settings.connection.sftp, "sftpResourceUid"):
            obj.pam_settings.connection.sftp.sftpResourceUid = uid
            return True
        elif name == "sftpUserUid" and hasattr(obj.pam_settings.connection.sftp, "sftpUserUid"):
            obj.pam_settings.connection.sftp.sftpUserUid = uid
            return True
        else:
            logging.debug(f"""Unknown sftp UID attribute "{name}" (skipped)""")
    return False

def is_admin_external(mach) -> bool:
    res = False
    if (mach and hasattr(mach, "is_admin_external") and mach.is_admin_external is True):
        res = True
    return res

def get_admin_credential(obj, uid:bool=False) -> str:
    # Get one of pam_settings.connection.{userRecords,userRecordUid}
    value: str = ""
    if (obj and hasattr(obj, "pam_settings") and
        hasattr(obj.pam_settings, "connection") and
        ((uid and hasattr(obj.pam_settings.connection, "userRecordUid")) or
        (not uid and hasattr(obj.pam_settings.connection, "userRecords")))):
            if uid and obj.pam_settings.connection.userRecordUid:
                value = obj.pam_settings.connection.userRecordUid
            elif not uid and obj.pam_settings.connection.userRecords:
                value = obj.pam_settings.connection.userRecords
            value = value[0] if isinstance(value, list) else value
            value = value if isinstance(value, str) else ""
    return value

def set_user_record_uid(obj, uid: str, is_external: bool = False) -> bool:
    if not(uid and isinstance(uid, str) and RecordV3.is_valid_ref_uid(uid)):
        logging.debug(f"""Invalid userRecordUid "{uid}" (skipped)""")
        return False

    if (uid and obj and hasattr(obj, "pam_settings") and
        hasattr(obj.pam_settings, "connection") and
        hasattr(obj.pam_settings.connection, "userRecordUid")):
            obj.pam_settings.connection.userRecordUid = uid
            if is_external is True:
                if hasattr(obj, "is_admin_external"):
                    obj.is_admin_external = True
                if hasattr(obj, "administrative_credentials_uid"):
                    obj.administrative_credentials_uid = uid
            return True
    else:
        logging.debug("""Object has no attribute "userRecordUid" (skipped)""")
    return False

def find_external_user(mach, machines, title: str) -> list:
    # Local pamMachine could reference pamDirectory AD user as its admin
    res = []
    if title and machines and mach.type == "pamMachine":
        mu = title.split(".", 1)  # machine/user titles
        mname = mu[0] if len(mu) > 1 else ""
        uname = mu[1] if len(mu) > 1 else mu[0]
        for m in machines:
            if m.type == "pamDirectory" and (not mname or mname == m.title):
                res.extend(search_machine(m, uname) or [])
    return res

def find_user(mach, users, title: str) -> list:
    if not isinstance(mach, list):
        res = search_machine(mach, title) or search_users(users, title)
    else:
        res = search_users(users, title)
        for m in mach:
            res = res or search_machine(m, title)
            if res: break
    return res or []

def search_users(users, user: str) -> list:
    res = []
    if isinstance(users, list):
        res = [x for x in users if getattr(x, "title", None) == user]
        res = res or [x for x in users if getattr(x, "login", None) == user]
    return res

def search_machine(mach, user: str) -> list:
    if mach and hasattr(mach, "users") and isinstance(mach.users, list):
        return search_users(mach.users, user)
    return []

def parse_command_options(obj, enable:bool) -> dict:
    # Parse command options from DagSettingsObject (pam_resource - skiped/external)
    args = {}
    if not obj: return args
    choices = {"on": True, "off": False}
    record_key = "record" if enable else "resource_uid"
    args[record_key] = obj.uid
    opts = None
    if isinstance(obj, PamRemoteBrowserObject):
        opts = obj.rbi_settings.options if obj.rbi_settings and obj.rbi_settings.options else None
    elif isinstance(obj, PamUserObject):
        logging.warning("Trying to parse DAG settings from PAM User (skipped)")  # PamUserObject.rotation_settings are different
    elif not isinstance(obj, LoginUserObject):
        opts = obj.pam_settings.options if obj.pam_settings and obj.pam_settings.options else None
    if opts:
        if enable:  # PAMTunnelEditCommand.execute format enable_rotation=True/disable_rotation=True
            val = opts.rotation.value if opts.rotation else ""
            key = "enable_rotation" if val == "on" else "disable_rotation" if val == "off" else None
            if key is not None: args[key] = True
            val = opts.connections.value if opts.connections else ""
            key = "enable_connections" if val == "on" else "disable_connections" if val == "off" else None
            if key is not None: args[key] = True
            val = opts.tunneling.value if opts.tunneling else ""
            key = "enable_tunneling" if val == "on" else "disable_tunneling" if val == "off" else None
            if key is not None: args[key] = True
            val = opts.text_session_recording.value if opts.text_session_recording else ""
            key = "enable_typescript_recording" if val == "on" else "disable_typescript_recording" if val == "off" else None
            if key is not None:
                args[key] = True
                args[key.replace("_typescript_", "_typescripts_")] = True  # legacy compat.
            val = opts.graphical_session_recording.value if opts.graphical_session_recording else ""
            key = "enable_connections_recording" if val == "on" else "disable_connections_recording" if val == "off" else None
            if key is not None: args[key] = True
            val = opts.remote_browser_isolation.value if opts.remote_browser_isolation else ""
            key = "enable_remote_browser_isolation" if val == "on" else "disable_remote_browser_isolation" if val == "off" else None
            if key is not None: args[key] = True
        else:  # TunnelDAG.set_resource_allowed format rotation=True/False
            if opts.rotation and opts.rotation.value in ("on", "off"):
                args["rotation"] = choices[opts.rotation.value]
            if opts.connections and opts.connections.value in ("on", "off"):
                args["connections"] = choices[opts.connections.value]
            if opts.tunneling and opts.tunneling.value in ("on", "off"):
                args["tunneling"] = choices[opts.tunneling.value]
            if opts.text_session_recording and opts.text_session_recording.value in ("on", "off"):
                # args["typescriptrecording"] = choices[opts.text_session_recording.value]
                args["typescript_recording"] = choices[opts.text_session_recording.value]
            if opts.graphical_session_recording and opts.graphical_session_recording.value in ("on", "off"):
                # args["recording"] = choices[opts.graphical_session_recording.value]
                args["session_recording"] = choices[opts.graphical_session_recording.value]
            if opts.remote_browser_isolation and opts.remote_browser_isolation.value in ("on", "off"):
                args["remote_browser_isolation"] = choices[opts.remote_browser_isolation.value]

    return args

def resolve_domain_admin(pce, users):
    if not(users and isinstance(users, list)):
        return
    if (pce and hasattr(pce, "dom_administrative_credential") and pce.dom_administrative_credential and
        hasattr(pce, "admin_credential_ref")):
        dac = pce.dom_administrative_credential
        res = {"titles": set(), "logins": set()}
        for obj in users:
            uid = getattr(obj, "uid", "") or ""
            title = getattr(obj, "title", "") or ""
            login = getattr(obj, "login", "") or ""
            if not uid:  # cannot resolve script credential to an empty UID
                logging.debug(f"""Unable to resolve domain admin creds from rec without UID - "{title}:{login}" (skipped)""")
                continue
            if title and title == dac:
                res["titles"].add(uid)
            elif login and login == dac:
                res["logins"].add(uid)
        num_unique_uids = len(res["titles"] | res["logins"])
        if num_unique_uids != 1:
            logging.debug(f"{num_unique_uids} matches while resolving domain admin creds for '{dac}' ")
        if res["titles"]:
            pce.admin_credential_ref = next(iter(res["titles"]))
        elif res["logins"]:
            pce.admin_credential_ref = next(iter(res["logins"]))
        if pce.admin_credential_ref:
            logging.debug(f"Domain admin credential '{dac}' resolved to '{pce.admin_credential_ref}' ")

def resolve_script_creds(rec, users, resources):
    creds = set()
    if (rec and hasattr(rec, "scripts") and rec.scripts and
        hasattr(rec.scripts, "scripts") and rec.scripts.scripts):
        creds = set(chain.from_iterable(
            (x.additional_credentials for x in rec.scripts.scripts if x.additional_credentials))
        )
    if not creds:  # nothing to resolve
        return
    res = {x: {"titles":[], "logins":[]} for x in creds}
    for obj in chain(users, resources):
        uid = getattr(obj, "uid", "") or ""
        title = getattr(obj, "title", "") or ""
        login = getattr(obj, "login", "") or ""
        if not uid:  # cannot resolve script credential to an empty UID
            logging.debug(f"""Unable to resolve script creds from rec without UID - "{title}:{login}" (skipped)""")
            continue
        if title and title in creds:
            res[title]["titles"].append(uid)
        elif login and login in creds:
            res[login]["logins"].append(login)

        # recursive search in machine users
        if hasattr(obj, "users") and obj.users and isinstance(obj.users, list):
            for usr in obj.users:
                uid = getattr(usr, "uid", "") or ""
                title = getattr(usr, "title", "") or ""
                login = getattr(usr, "login", "") or ""
                if not uid:  # cannot resolve script credential to an empty UID
                    logging.debug(f"""Unable to resolve script creds from rec without UID - "{title}:{login}" (skipped)""")
                    continue
                if title and title in creds:
                    res[title]["titles"].append(uid)
                elif login and login in creds:
                    res[login]["logins"].append(login)

    if logging.getLogger().getEffectiveLevel() <= logging.DEBUG:
        for k, v in res.items():
            tlen = len(v.get("titles", []))
            llen = len(v.get("login", []))
            if tlen+llen != 1:
                logging.debug(f"{tlen+llen} matches while resolving script creds for {k}")

    for script in (x for x in rec.scripts.scripts if x.additional_credentials):
        for cred in script.additional_credentials:
            matches = res.get(cred) or {}
            match = next(chain(matches.get("titles") or [], matches.get("logins") or []), None)
            if match:
                script.record_refs.append(match)
            else:
                title = getattr(rec, "title", "") or ""
                login = getattr(rec, "login", "") or ""
                logging.warning(f"""Unable to resolve script creds "{cred}" from "{title}:{login}" """)
        if script.record_refs:
            script.record_refs = list(set(script.record_refs))

def add_pam_scripts(params, record, scripts):
    """Add post-rotation script(s) to a rotation record"""
    if not (isinstance(record, str) and record != ""
            and isinstance(scripts, list) and len(scripts) > 0):
        return  # nothing to do - no record or no script(s)

    ruid = record if record in params.record_cache else ""
    if not ruid:
        records = list(vault_extensions.find_records(
            params, search_str=record, record_version=(3, 6),
            record_type=PAM_ROTATION_TYPES + PAM_CONFIG_TYPES))
        if len(records) == 0:
            logging.warning(f"""{bcolors.WARNING}Warning: {bcolors.ENDC} Add rotation script - Record "{record}" not found!""")
        elif len(records) > 1:
            logging.warning(f"""{bcolors.WARNING}Warning: {bcolors.ENDC} Add rotation script - Record "{record}" is not unique. Use record UID!""")
        else:
            ruid = records[0].record_uid
    rec = vault.KeeperRecord.load(params, ruid) if ruid else None
    if rec and isinstance(rec, vault.TypedRecord):
        if rec.version not in (3, 6):
            logging.warning(f"""{bcolors.WARNING}Warning: {bcolors.ENDC} Add rotation script - Record "{rec.record_uid}" is not a rotation record (skipped).""")
            return

        script_field = next((x for x in rec.fields if x.type == "script"), None)
        if not script_field:
            script_field = vault.TypedField.new_field("script", [], "rotationScripts")
            rec.fields.append(script_field)
        for script in scripts:
            file_name = script.file
            full_name = os.path.abspath(os.path.expanduser(file_name))
            if not os.path.isfile(full_name):
                logging.warning(f"""{bcolors.WARNING}Warning: {bcolors.ENDC} Add rotation script - File "{file_name}" not found (skipped).""")
                continue
            facade = record_facades.FileRefRecordFacade()
            facade.record = rec
            pre = set(facade.file_ref)
            upload_task = attachment.FileUploadTask(full_name)
            attachment.upload_attachments(params, rec, [upload_task])
            post = set(facade.file_ref)
            df = post.difference(pre)
            if len(df) == 1:
                file_uid = df.pop()
                facade.file_ref.remove(file_uid)
                script_value = {
                    "fileRef": file_uid,
                    "recordRef": [],
                    "command": "",
                }
                # command and recordRef are optional
                if script.script_command:
                    script_value["command"] = script.script_command
                if script.record_refs:
                    for ref in script.record_refs:
                        script_value["recordRef"].append(ref)
                        if ref not in params.record_cache:
                            logging.debug(f"{bcolors.WARNING}Warning: {bcolors.ENDC} "
                                            "Add rotation script - Additional Credentials Record "
                                            f""" "{ref}" not found (recordRef added)!""")
                script_field.value.append(script_value) # type: ignore

        record_management.update_record(params, rec)
        api.sync_down(params)
        params.sync_data = True
