<?php
/*
 * Jetpack CRM
 * https://jetpackcrm.com
 * V1.20
 *
 * Copyright 2020 Automattic
 *
 * Date: 01/11/16
 */

defined( 'ZEROBSCRM_PATH' ) || exit( 0 );

	/*
	DAL3.0 Notes:

		THIS FILE replaces previous DAL2.php, with the following changes:

		- Originally this was DAL2 1 x class containting methods like:
			- DAL->getContacts
		- ... in DAL3 these have moved to:
			- DAL->contacts->getContacts()
		- ... for better organisation.
		- This file, then, basically shuffles original DAL2 out into 4 classes:
			- DAL, contacts, segments, logs
			- As DAL, DAL->contacts, DAL->segments, DAL->logs
		- ... it also adds splat/catcher funcs for OLD references e.g. getContacts,
		- ... so that they will still function (but log need for replacement)

		... so it's a kind of chimera DAL2 to bridge the gap between DAL2 + DAL3.
		... should be fine to cut the chimera splats after a while though (or even pre v3.0 proper)


	*/

/*
======================================================
	DB GENERIC/OBJ Helpers (not DAL GET etc.)
	====================================================== */

	// ===============================================================================
	// ===========  PERMISSIONS HELPERS  =============================================

	// in time this'll allow multiple 'sites' per install (E.g. site 1 = epic.zbs.com site 2 = zbs.zbs.com)
	// for now, this is hard-coded to 1
	// replaces "zeroBSCRM_installSite" from old DAL
function zeroBSCRM_site() {

	return 1;
}
	// in time this'll allow multiple 'team' per site (E.g. branch1,branch2 etc.)
	// for now, this is hard-coded to 1
	// replaces "zeroBSCRM_installTeam" from old DAL
function zeroBSCRM_team() {

	return 1;
}
	// active user id - helper func
	// replaces "zeroBSCRM_currentUserID" from old DAL
	// can alternatively user $zbs->user()
function zeroBSCRM_user() {

	return get_current_user_id();
}

	// =========== /  PERMISSIONS HELPERS  ===========================================
	// ===============================================================================

	// ===============================================================================
	// ===========  TYPES ============================================================

define( 'ZBS_TYPE_CONTACT', 1 );
define( 'ZBS_TYPE_COMPANY', 2 );
define( 'ZBS_TYPE_QUOTE', 3 );
define( 'ZBS_TYPE_INVOICE', 4 );
define( 'ZBS_TYPE_TRANSACTION', 5 );
define( 'ZBS_TYPE_EVENT', 6 ); // legacy, use ZBS_TYPE_TASK instead
define( 'ZBS_TYPE_TASK', 6 );
define( 'ZBS_TYPE_FORM', 7 );
define( 'ZBS_TYPE_LOG', 8 );
define( 'ZBS_TYPE_SEGMENT', 9 );
define( 'ZBS_TYPE_LINEITEM', 10 );
define( 'ZBS_TYPE_EVENTREMINDER', 11 ); // legacy, use ZBS_TYPE_TASK_REMINDER instead
define( 'ZBS_TYPE_TASK_REMINDER', 11 );
define( 'ZBS_TYPE_QUOTETEMPLATE', 12 );
define( 'ZBS_TYPE_ADDRESS', 13 ); // this is a precursor to v4 where we likely need to split out addresses from current in-object model (included here as custom fields now managed as if obj)

	// =========== /  TYPES  =========================================================
	// ===============================================================================

/*
======================================================
	/ DB GENERIC/OBJ Helpers (not DAL GET etc.)
	====================================================== */

/**
 * zbsDAL is the Data Access Layer for ZBS v2.5+
 *
 * zbsDAL provides expanded CRUD actions to the
 * Jetpack CRM generally, and will be initiated globally
 * like the WordPress $wpdb.
 *
 * @author   Woody Hayday <hello@jetpackcrm.com>
 * @version  2.0
 * @access   public
 * @see      https://jetpackcrm.com/kb
 */
class zbsDAL {

	public $version = 3.0;

	/*
	* A general key-value pair store
	*/
	private $cache = array();

	/**
	 * This is a temporary fix for issue #3504, until we don't change how we load settings this should help to avoid slowing down websites.
	 *
	 * @var array Associative array used for caching settings (key: 'settings') and a flag to indicate if all settings were loaded (key: 'contains_all').
	 */
	private static $mitigation_cache_for_issue_3504;

	/**
	 * Resets the mitigation cache for issue #3504.
	 *
	 * Initializes the static cache property to its default state, clearing cached settings
	 * and resetting the "contains all" flag to false.
	 *
	 * @return void
	 */
	public static function reset_mitigation_cache_for_issue_3504() {
		static::$mitigation_cache_for_issue_3504 = array(
			'settings'     => array(),
			'contains_all' => false,
		);
	}

	/**
	 * Retrieves a specific setting from the cache.
	 *
	 * @param string $key The setting key.
	 * @return mixed|null The value of the setting or null if not found.
	 */
	private static function mitigation_cache_for_issue_3504_single_setting( $key ) {
		return static::$mitigation_cache_for_issue_3504['settings'][ $key ] ?? null;
	}

	/**
	 * Checks if a specific setting exists in the cache.
	 *
	 * @param string $key The setting key.
	 * @return bool True if the setting exists, false otherwise.
	 */
	private static function mitigation_cache_for_issue_3504_contains_key( $key ) {
		return isset( static::$mitigation_cache_for_issue_3504['settings'][ $key ] );
	}

	/**
	 * Checks if all settings are loaded.
	 *
	 * @return bool True if all settings are loaded, false otherwise.
	 */
	private static function mitigation_cache_for_issue_3504_contains_all_settings() {
		return static::$mitigation_cache_for_issue_3504['contains_all'];
	}

	/**
	 * Sets a specific setting in the cache.
	 *
	 * @param string $key   The setting key.
	 * @param mixed  $value The setting value.
	 * @return void
	 */
	private static function mitigation_cache_for_issue_3504_set_single_setting( $key, $value ) {
		static::$mitigation_cache_for_issue_3504['settings'][ $key ] = $value;
	}

	/**
	 * Sets the "contains all" flag in the cache.
	 *
	 * @param bool $contains_all Whether all settings are loaded.
	 * @return void
	 */
	private static function mitigation_cache_for_issue_3504_set_contains_all( $contains_all ) {
		static::$mitigation_cache_for_issue_3504['contains_all'] = $contains_all;
	}

	/**
	 * Retrieves all cached settings.
	 *
	 * @return array An array of all cached settings.
	 */
	private static function mitigation_cache_for_issue_3504_all_settings() {
		return static::$mitigation_cache_for_issue_3504['settings'];
	}

	// ===============================================================================
	// ===========  SUB DAL LAYERS  ==================================================
	// These hold sub-objects, e.g. contact
	public $contacts           = false;
	public $segments           = false;
	public $companies          = false;
	public $quotes             = false;
	public $invoices           = false;
	public $transactions       = false;
	public $forms              = false;
	public $events             = false;
		public $eventreminders = false; // phpcs:ignore Squiz.Commenting.VariableComment.Missing
	public $logs               = false;
	public $lineitems          = false;
	public $quotetemplates     = false;
	public $addresses          = false;

	// ===========  / SUB DAL LAYERS  ================================================
	// ===============================================================================

	// ===============================================================================
	// ===========  OBJECT TYPE & GLOBAL DEFINITIONS    ==============================

	private $typesByID = array(

		ZBS_TYPE_CONTACT       => 'contact',
		ZBS_TYPE_COMPANY       => 'company',
		ZBS_TYPE_QUOTE         => 'quote',
		ZBS_TYPE_INVOICE       => 'invoice',
		ZBS_TYPE_TRANSACTION   => 'transaction',
		ZBS_TYPE_TASK          => 'event',
		ZBS_TYPE_FORM          => 'form',
		ZBS_TYPE_SEGMENT       => 'segment',
		ZBS_TYPE_LOG           => 'log',
		ZBS_TYPE_LINEITEM      => 'lineitem',
		ZBS_TYPE_TASK_REMINDER => 'eventreminder',
		ZBS_TYPE_QUOTETEMPLATE => 'quotetemplate',
		ZBS_TYPE_ADDRESS       => 'address',

	);

	// retrieve via DAL->oldCPT(1)
	private $typeCPT = array(

		ZBS_TYPE_CONTACT       => 'zerobs_customer',
		ZBS_TYPE_COMPANY       => 'zerobs_company',
		ZBS_TYPE_QUOTE         => 'zerobs_quote',
		ZBS_TYPE_INVOICE       => 'zerobs_invoice',
		ZBS_TYPE_TRANSACTION   => 'zerobs_transaction',
		ZBS_TYPE_TASK          => 'zerobs_event',
		ZBS_TYPE_FORM          => 'zerobs_form',
		// these never existed:
		// ZBS_TYPE_SEGMENT => 'zerobs_segment',
		// ZBS_TYPE_LOG => 'zerobs_log',
		// ZBS_TYPE_LINEITEM => 'lineitem'
		// ZBS_TYPE_TASK_REMINDER => 'eventreminder'
		ZBS_TYPE_QUOTETEMPLATE => 'zerobs_quo_template',
	);

	/**
	 * Retrieve via DAL->typeStr(1).
	 *
	 * @var array
	 */
	private $typeNames = array( // phpcs:ignore WordPress.NamingConventions.ValidVariableName.PropertyNotSnakeCase
		ZBS_TYPE_CONTACT       => array( 'Contact', 'Contacts' ),
		ZBS_TYPE_COMPANY       => array( 'Company', 'Companies' ),
		ZBS_TYPE_QUOTE         => array( 'Quote', 'Quotes' ),
		ZBS_TYPE_INVOICE       => array( 'Invoice', 'Invoices' ),
		ZBS_TYPE_TRANSACTION   => array( 'Transaction', 'Transactions' ),
		ZBS_TYPE_TASK          => array( 'Task', 'Tasks' ),
		ZBS_TYPE_FORM          => array( 'Form', 'Forms' ),
		ZBS_TYPE_SEGMENT       => array( 'Segment', 'Segments' ),
		ZBS_TYPE_LOG           => array( 'Log', 'Logs' ),
		ZBS_TYPE_LINEITEM      => array( 'Line Item', 'Line Items' ),
		ZBS_TYPE_TASK_REMINDER => array( 'Task Reminder', 'Task Reminders' ),
		ZBS_TYPE_QUOTETEMPLATE => array( 'Quote Template', 'Quote Templates' ),
		ZBS_TYPE_ADDRESS       => array( 'Address', 'Addresses' ),
	);

	// List View refs
	private $listViewRefs = array(

		// each of these is a slug for $zbs->slugs e.g. $zbs->slugs['managecontacts']
		ZBS_TYPE_CONTACT       => 'managecontacts',
		ZBS_TYPE_COMPANY       => 'managecompanies',
		ZBS_TYPE_QUOTE         => 'managequotes',
		ZBS_TYPE_INVOICE       => 'manageinvoices',
		ZBS_TYPE_TRANSACTION   => 'managetransactions',
		ZBS_TYPE_TASK          => 'manage-tasks',
		ZBS_TYPE_FORM          => 'manageformscrm',
		ZBS_TYPE_SEGMENT       => 'segments',
		// no list page ZBS_TYPE_LOG =>             'managecontacts',
		// no list page ZBS_TYPE_LINEITEM =>        'managecontacts',
		// no list page ZBS_TYPE_TASK_REMINDER =>   'managecontacts',
		ZBS_TYPE_QUOTETEMPLATE => 'quote-templates',
	);

	// field obj models
	// these match the $globals in fields.php - bit of a legacy chunk tbh, but used throughout
	private $fieldModelsByID = array(

		ZBS_TYPE_CONTACT     => 'zbsCustomerFields',
		ZBS_TYPE_COMPANY     => 'zbsCompanyFields',
		ZBS_TYPE_QUOTE       => 'zbsCustomerQuoteFields',
		ZBS_TYPE_INVOICE     => 'zbsCustomerInvoiceFields',
		ZBS_TYPE_TRANSACTION => 'zbsTransactionFields',
		// ZBS_TYPE_TASK =>           'zbsFormFields',
		ZBS_TYPE_FORM        => 'zbsFormFields',
		ZBS_TYPE_ADDRESS     => 'zbsAddressFields',

	);

	// legacy support.
	private $oldTaxonomies = array(

		'zerobscrm_customertag'    => ZBS_TYPE_CONTACT,
		'zerobscrm_companytag'     => ZBS_TYPE_COMPANY,
		'zerobscrm_transactiontag' => ZBS_TYPE_TRANSACTION,
		'zerobscrm_logtag'         => ZBS_TYPE_LOG,

	);

	// this is a shorthand for grabbing all addr fields
	private $field_list_address = array(

		'zbsc_addr1',
		'zbsc_addr2',
		'zbsc_city',
		'zbsc_postcode',
		'zbsc_county',
		'zbsc_country',

	);
	private $field_list_address2 = array(

		'zbsc_secaddr1',
		'zbsc_secaddr2',
		'zbsc_seccity',
		'zbsc_secpostcode',
		'zbsc_seccounty',
		'zbsc_seccountry',

	);
	private $field_list_address_full = array(

		'zbsc_addr1',
		'zbsc_addr2',
		'zbsc_city',
		'zbsc_postcode',
		'zbsc_county',
		'zbsc_country',
		'zbsc_secaddr1',
		'zbsc_secaddr2',
		'zbsc_seccity',
		'zbsc_secpostcode',
		'zbsc_seccounty',
		'zbsc_seccountry',

	);

	// this stores any insert errors
	private $errorStack = array();

	/**
	 * Prefix for origin strings which are domains (should mean querying easier later)
	 * Used throughout to specify domain
	 */
	private $prefix_domain = 'd:';

	// =========== /  OBJECT TYPE & GLOBAL DEFINITIONS   =============================
	// ===============================================================================

	// ===============================================================================
	// ===========  INIT =============================================================
	function __construct( $args = array() ) {

		// init sub-layers:
		$this->contacts = new zbsDAL_contacts();
		$this->segments = new zbsDAL_segments();

			$this->companies      = new zbsDAL_companies();
			$this->quotes         = new zbsDAL_quotes();
			$this->invoices       = new zbsDAL_invoices();
			$this->transactions   = new zbsDAL_transactions();
			$this->forms          = new zbsDAL_forms();
			$this->events         = new zbsDAL_events();
			$this->eventreminders = new zbsDAL_eventreminders();
			$this->logs           = new zbsDAL_logs();
			$this->lineitems      = new zbsDAL_lineitems();
			$this->quotetemplates = new zbsDAL_quotetemplates();
			// Not yet implemented:
			// $this->addresses = new zbsDAL_addresses;

		// any post-settings-loaded actions
		add_action( 'after_zerobscrm_settings_preinit', array( $this, 'postSettingsInit' ) );
	}
	// ===========  / INIT ===========================================================
	// ===============================================================================

	/**
	 * Corrects label for 'Company' (could be Organisation) after the settings have loaded.
	 * Clunky workaround for now
	 */
	public function postSettingsInit() {

		// Correct any labels
		$this->typeNames[ ZBS_TYPE_COMPANY ] = array( jpcrm_label_company(), jpcrm_label_company( true ) );
	}

	// ===============================================================================
	// ===========  HELPER/GET FUNCS =================================================

	public function field_list_address() {
		return $this->field_list_address; }
	public function field_list_address2() {
		return $this->field_list_address2; }
	public function field_list_address_full() {
		return $this->field_list_address_full; }

	// returns object types indexed by their global (e.g. ZBS_TYPE_CONTACT = 1)
	public function get_object_types_by_index() {

		return $this->typesByID;
	}

	// returns object types indexed by their key (e.g. 'contact' => ZBS_TYPE_CONTACT = 1)
	public function getObjectTypesByKey() {

		return array_flip( $this->typesByID );
	}

	public function getCPTObjectTypesByKey() {

		return array_flip( $this->typeCPT );
	}

	// returns $zbs->DAL->contacts by '1'
	// for now brutal switch, avoid using anywhere but internally, use proper refs elsewhere
	// ... avoid use where not essential as does not produce highly readable code.
	public function getObjectLayerByType( $objTypeID = -1 ) {

		switch ( $objTypeID ) {
			case ZBS_TYPE_CONTACT:
				return $this->contacts;
			break;
			case ZBS_TYPE_COMPANY:
				return $this->companies;
			break;
			case ZBS_TYPE_QUOTE:
				return $this->quotes;
			break;
			case ZBS_TYPE_INVOICE:
				return $this->invoices;
			break;
			case ZBS_TYPE_TRANSACTION:
				return $this->transactions;
			break;
			case ZBS_TYPE_FORM:
				return $this->forms;
			break;
			case ZBS_TYPE_TASK:
				return $this->events;
			break;
			case ZBS_TYPE_TASK_REMINDER:
				return $this->eventreminders;
			break;
			case ZBS_TYPE_LOG:
				return $this->logs;
			break;
			case ZBS_TYPE_LINEITEM:
				return $this->lineitems;
			break;
			case ZBS_TYPE_QUOTETEMPLATE:
				return $this->quotetemplates;
			break;
			// case ZBS_TYPE_ADDRESS:
			// return $this->addresses; break;
		}

		return false;
	}

	/**
	 * Returns $zbsCustomerFields global from object_type_id
	 * designed as legacy support until we can refactor away from globals (gh-253)
	 *
	 * @param CRM_TYPE int object_type_id - object type ID\
	 *
	 * @return bool|array - object type field global array, or false
	 */
	public function get_object_field_global( $object_type_id = -1 ) {

		// attempt to retrieve var
		$global_var_name = $this->objFieldVarName( $object_type_id );

		if ( $global_var_name !== -1 ) {

			return isset( $GLOBALS[ $global_var_name ] ) ? $GLOBALS[ $global_var_name ] : null;

		}

		return false;
	}

	public function isValidObjTypeID( $objTypeIn = false ) {

		// if it's not an int type, cast it...?
		if ( ! is_int( $objTypeIn ) ) {
			$objTypeIn = (int) $objTypeIn;
		}

		// if bigger than 0 and in our arr as a key, basic check
		if ( $objTypeIn > 0 && isset( $this->typesByID[ $objTypeIn ] ) ) {
			return true;
		}

		return false;
	}

	/**
	 * Check if a given status is valid for the given object
	 *
	 * @param int    $obj_type_id Object type ID.
	 * @param string $obj_status  Object status string.
	 */
	public function is_valid_obj_status( $obj_type_id, $obj_status ) {
		switch ( $obj_type_id ) {
			case ZBS_TYPE_CONTACT:
				$valid_statuses = zeroBSCRM_getCustomerStatuses( true );
				break;
			case ZBS_TYPE_COMPANY:
				$valid_statuses = zeroBSCRM_getCompanyStatuses();
				break;
			case ZBS_TYPE_INVOICE:
				$valid_statuses = zeroBSCRM_getInvoicesStatuses();
				break;
			case ZBS_TYPE_TRANSACTION:
				$valid_statuses = zeroBSCRM_getTransactionsStatuses( true );
				break;
			default:
				return false;
		}

		// if required, check if default status is a valid one
		return in_array( $obj_status, $valid_statuses, true );
	}

	// takes in an obj type str (e.g. 'contact') and returns DEFINED KEY ID = 1
	public function objTypeID( $objTypeStr = '' ) {

		// catch some legacy translations
		if ( $objTypeStr == 'customer' ) {
			$objTypeStr = 'contact';
		}

		$byStr = $this->getObjectTypesByKey();
		if ( isset( $byStr[ $objTypeStr ] ) ) {
			return $byStr[ $objTypeStr ];
		}

		// if not, fall back to old obj cpt types
		$byStr = $this->getCPTObjectTypesByKey();
		if ( isset( $byStr[ $objTypeStr ] ) ) {
			return $byStr[ $objTypeStr ];
		}

		return -1;
	}

	// takes in an obj type int (e.g. 1) and returns key (e.g. 'contact')
	public function objTypeKey( $objTypeID = -1 ) {

		if ( isset( $this->typesByID[ $objTypeID ] ) ) {
			return $this->typesByID[ $objTypeID ];
		}

		return -1;
	}

	// takes in an obj type int (e.g. 1) and returns field global var name (e.g. 'zbsCustomerFields')
	public function objFieldVarName( $objTypeID = -1 ) {

		if ( isset( $this->fieldModelsByID[ $objTypeID ] ) ) {
			return $this->fieldModelsByID[ $objTypeID ];
		}

		return -1;
	}

	// takes in an obj type (e.g. 1) and returns obj model for that type (as per sub layer)
	// uses getObjectLayerByType to keep generic. Use only in fairly high level generic funcs.
	public function objModel( $objTypeID = -1 ) {

		if ( $objTypeID > 0 ) {

			$objLayer = $this->getObjectLayerByType( $objTypeID );

			// if set, $objLayer will effectively be $zbs->DAL->contacts obj
			if ( method_exists( $objLayer, 'objModel' ) ) {

				// all good
				return $objLayer->objModel();
			}
		}

		return false;
	}

	// takes in an old taxonomy str (e.g. zerobscrm_customertag) and returns new obj key (e.g. 1)
	public function cptTaxonomyToObjID( $taxonomyStr = -1 ) {

		if ( isset( $this->oldTaxonomies[ $taxonomyStr ] ) ) {
			return $this->oldTaxonomies[ $taxonomyStr ];
		}

		return -1;
	}

	// takes in an obj ID and gives back the list view slug
	public function listViewSlugFromObjID( $objTypeID = -1 ) {

		global $zbs;

		if ( isset( $this->listViewRefs[ $objTypeID ] ) && isset( $zbs->slugs[ $this->listViewRefs[ $objTypeID ] ] ) ) {
			return $zbs->slugs[ $this->listViewRefs[ $objTypeID ] ];
		}

		return '';
	}

	// retrieves single/plural 'str' for obj type id (e.g. 1 = Contact/Contacts)
	public function typeStr( $typeInt = -1, $plural = false ) {

		$typeInt = (int) $typeInt;
		if ( $typeInt > 0 ) {

			if ( isset( $this->typeNames[ $typeInt ] ) ) {

				// plural
				if ( $plural ) {
					return __( $this->typeNames[ $typeInt ][1], 'zero-bs-crm' );
				}
				// single
				return __( $this->typeNames[ $typeInt ][0], 'zero-bs-crm' );

			}
		}
		return '';
	}

	// retrieves old CPT for that type
	public function typeCPT( $typeInt = -1 ) {

		$typeInt = (int) $typeInt;
		if ( $typeInt > 0 ) {

			if ( isset( $this->typeCPT[ $typeInt ] ) ) {
				return $this->typeCPT[ $typeInt ];
			}
		}
		return false;
	}

	/*
	 * This function provides a general list of field slugs which should
	 * be hidden on front end aspects (e.g. Portal, WooSync->WooCommerce My Account)
	 *
	 * @param int $obj_type_int - optionally specifies whether 'globally' non inclusive, or 'globally+objmodel specific'
	 */
	public function fields_to_hide_on_frontend( $obj_type_int = false ) {

		// Globals
		$fields_to_hide_on_frontend = array( 'status', 'email', 'zbs_site', 'zbs_team', 'zbs_owner' );

		// Object type specific
		if ( $this->isValidObjTypeID( $obj_type_int ) ) {

			$obj_model = $this->objModel( $obj_type_int );

			foreach ( $obj_model as $field_key => $field_array ) {

				if ( is_array( $field_array ) && isset( $field_array['do_not_show_on_portal'] ) && $field_array['do_not_show_on_portal'] ) {

					if ( ! in_array( $field_key, $fields_to_hide_on_frontend ) ) {

						$fields_to_hide_on_frontend[] = $field_key;

					}
				}
			}
		}

		return $fields_to_hide_on_frontend;
	}

	/**
	 * Returns a count of objects of a type which are associated with an assignee (eg. company or contact)
	 * Supports Quotes, Invoices, Transactions, Events currently
	 *
	 * @param int $assignee_id The assignee ID (for example company / contact ID).
	 * @param int $obj_type_id The type constant being checked (eg ZBS_TYPE_QUOTE).
	 * @param int $zbs_type The assigne type, for example ZBS_TYPE_COMPANY, ZBS_TYPE_CONTACT (default contact).
	 *
	 * @return int The count of relevant objects of the given type.
	 */
	public function specific_obj_type_count_for_assignee( $assignee_id, $obj_type_id, $zbs_type = ZBS_TYPE_CONTACT ) {
		// phpcs:disable WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
		global $ZBSCRM_t;
		global $wpdb;

		switch ( $obj_type_id ) {
			case ZBS_TYPE_QUOTE:
				$table_name = $ZBSCRM_t['quotes'];
				break;

			case ZBS_TYPE_INVOICE:
				$table_name = $ZBSCRM_t['invoices'];
				break;

			case ZBS_TYPE_TRANSACTION:
				$table_name = $ZBSCRM_t['transactions'];
				break;

			case ZBS_TYPE_TASK:
				$table_name = $ZBSCRM_t['events'];
				break;

			default:
				// For any unsupported objtype.
				return -1;
		}

		$obj_query = 'SELECT COUNT(obj_table.id) FROM ' . $table_name . ' obj_table'
			. ' INNER JOIN ' . $ZBSCRM_t['objlinks'] . ' obj_links'
			. ' ON obj_table.id = obj_links.zbsol_objid_from'
			. ' WHERE obj_links.zbsol_objtype_from = ' . $obj_type_id
			. ' AND obj_links.zbsol_objtype_to = ' . $zbs_type
			. ' AND obj_links.zbsol_objid_to = %d';

		// Counting objs with objlinks to this assignee, ignoring ownership.
		$query = $this->prepare( $obj_query, $assignee_id );
		$count = (int) $wpdb->get_var( $query ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.NoCaching -- To be refactored.

		return $count;
		// phpcs:enable WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
	}

	// =========== / HELPER/GET FUNCS ================================================
	// ===============================================================================

	// ===============================================================================
	// ===========  OWNERSHIP HELPERS  ===============================================

	// This func is a side-switch alternative to now-removed zeroBS_checkOwner
	public function checkObjectOwner( $args = array() ) {

		#} =========== LOAD ARGS ==============
		$defaultArgs = array(

			'objID'              => -1,
			'objTypeID'          => -1,

			// id to compare to
			'potentialOwnerID'   => -1,

			// if not owned, return true?
			'allowNoOwnerAccess' => -1,

		);
		foreach ( $defaultArgs as $argK => $argV ) {
			$$argK = $argV;
			if ( is_array( $args ) && isset( $args[ $argK ] ) ) {
				if ( is_array( $args[ $argK ] ) ) {
					$newData = $$argK;
					if ( ! is_array( $newData ) ) {
						$newData = array();
					} foreach ( $args[ $argK ] as $subK => $subV ) {
						$newData[ $subK ] = $subV;
					}$$argK = $newData;
				} else {
					$$argK = $args[ $argK ]; }
			}
		}
		#} =========== / LOAD ARGS =============)

		if ( $objID !== -1 && $objTypeID > 0 ) {

			$ownerID = $this->getObjectOwner(
				array(
					'objID'     => $objID,
					'objTypeID' => $objTypeID,
				)
			);

			if ( isset( $ownerID ) && $ownerID == $potentialOwnerID ) {
				return true;
			}
			// no owner owns this!
			elseif ( $allowNoOwnerAccess && $potentialOwner === -1 ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable,WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
				return true;
			}
		}

		return false;
	}

	// This func is a side-switch alternative to zeroBS_getOwner
	public function getObjectOwner( $args = array() ) {

		#} =========== LOAD ARGS ==============
		$defaultArgs = array(

			'objID'     => -1,
			'objTypeID' => -1,

		);
		foreach ( $defaultArgs as $argK => $argV ) {
			$$argK = $argV;
			if ( is_array( $args ) && isset( $args[ $argK ] ) ) {
				if ( is_array( $args[ $argK ] ) ) {
					$newData = $$argK;
					if ( ! is_array( $newData ) ) {
						$newData = array();
					} foreach ( $args[ $argK ] as $subK => $subV ) {
						$newData[ $subK ] = $subV;
					}$$argK = $newData;
				} else {
					$$argK = $args[ $argK ]; }
			}
		}
		#} =========== / LOAD ARGS =============)

		if ( $objID !== -1 && $objTypeID > 0 ) {

			return $this->getFieldByID(
				array(
					'id'          => $objID,
					'objtype'     => $objTypeID,
					'colname'     => 'zbs_owner',
					'ignoreowner' => true,
				)
			);

		}

		return false;
	}

	// This func is a side-switch alternative to zeroBS_setOwner
	public function setObjectOwner( $args = array() ) {

		#} =========== LOAD ARGS ==============
		$defaultArgs = array(

			'objID'     => -1,
			'objTypeID' => -1,

			'ownerID'   => -1,

		);
		foreach ( $defaultArgs as $argK => $argV ) {
			$$argK = $argV;
			if ( is_array( $args ) && isset( $args[ $argK ] ) ) {
				if ( is_array( $args[ $argK ] ) ) {
					$newData = $$argK;
					if ( ! is_array( $newData ) ) {
						$newData = array();
					} foreach ( $args[ $argK ] as $subK => $subV ) {
						$newData[ $subK ] = $subV;
					}$$argK = $newData;
				} else {
					$$argK = $args[ $argK ]; }
			}
		}
		#} =========== / LOAD ARGS =============)

		if ( $objID !== -1 && $objTypeID > 0 ) {

			return $this->setFieldByID(
				array(

					'objID'       => $objID,
					'objTypeID'   => $objTypeID,

					'colname'     => 'zbs_owner',
					'coldatatype' => '%d', // %d/s
					'newValue'    => $ownerID,

				)
			);

		}

		return false;
	}

	// this is used to get specific user's settings via userSetting
	private $userSettingPrefix = 'usrset_'; // completes via usrset_*ID*_key

	/*
	old way of doing it: (we now use zbs_owner :))
	private function getUserSettingPrefix($userID=-1){

		// completes usrset_*ID*_key
		return $this->userSettingPrefix.$userID.'_';
	} */

	// Note: Following MUST be used together.

		// this makes query vars (as appropriate) team + site (and owner if  $ignoreOwner not false)
	public function ownershipQueryVars( $ignoreOwner = false ) {

		$queryVars = array();

		// add site
		// FOR V3.0 SITE + TEAM NOT YET USED, (BUT THIS'll WORK)
		// $queryVars[] = zeroBSCRM_site();

		// add team
		// FOR V3.0 SITE + TEAM NOT YET USED, (BUT THIS'll WORK)
		// $queryVars[] = zeroBSCRM_team();

		// add owner
		if ( ! $ignoreOwner ) {
			$queryVars[] = zeroBSCRM_user();
		}

		return $queryVars;
	}
		// this makes query str (as appropriate) team + site (and owner if  $ignoreOwner not false)
		// $table ONLY needed when is a LEFT JOIN or similar.
	public function ownershipSQL( $ignoreOwner = false, $table = '' ) {

		// build
		$q        = '';
		$tableStr = '';
		if ( ! empty( $table ) ) {
			$tableStr = $table . '.';
		}

		// add site
		// FOR V3.0 SITE + TEAM NOT YET USED, (BUT THIS'll WORK)
		// $q = $this->spaceAnd($q).$tableStr.'zbs_site = %d';

		// add team
		// FOR V3.0 SITE + TEAM NOT YET USED, (BUT THIS'll WORK)
		// $q = $this->spaceAnd($q).$tableStr.'zbs_team = %d';

		// add owner
		if ( ! $ignoreOwner ) {
			$q = $this->spaceAnd( $q ) . $tableStr . 'zbs_owner = %d';
		}

		return $q;
	}

	// ===========  / OWNERSHIP HELPERS  =============================================
	// ===============================================================================

	// ===============================================================================
	// ===========  ERROR HELPER FUNCS ===============================================
	/* These are shared between DAL2 + DAL3, though are only included from v3.0 +   */

		// retrieve errors from dal error stack
	public function getErrors( $objTypeID = -1 ) {

		// all:
		if ( $objTypeID < 0 ) {
			return $this->errorStack;
		}

		// specific
		if ( is_array( $this->errorStack ) && isset( $this->errorStack[ $objTypeID ] ) ) {
			return $this->errorStack[ $objTypeID ];
		}

		// ??
		return array();
	}

		// add error to dal error stack
	public function addError( $errorCode = -1, $objTypeID = -1, $errStr = '', $extraParam = false ) {

		if ( $objTypeID > 0 && ! empty( $errStr ) ) {

			// init
			if ( ! isset( $this->errorStack ) || ! is_array( $this->errorStack ) ) {
				$this->errorStack = array();
			}
			if ( ! isset( $this->errorStack[ $objTypeID ] ) || ! is_array( $this->errorStack[ $objTypeID ] ) ) {
				$this->errorStack[ $objTypeID ] = array();
			}

			// if $errorCode, add to string
			if ( $errorCode > 0 ) {
				$errStr .= ' (' . __( 'Error #', 'zero-bs-crm' ) . $errorCode . ')';
			}

			// add
			$this->errorStack[ $objTypeID ][] = array(
				'code'  => $errorCode,
				'str'   => $errStr,
				'param' => $extraParam,
			);
		}
	}

	// =========== / ERROR HELPER FUNCS ==============================================
	// ===============================================================================

	/*
	======================================================
	DAL CRUD
	======================================================
	*/

	// ===============================================================================
	// ===========   OBJ LINKS   =======================================================

	/**
	 * returns objects against an obj (e.g. company's against contact id 101)
	 * This is like getObjsLinksLinkedToObj, only it returns actual objs :)
	 *
	 * @param array $args   Associative array of arguments
	 *                      obj array
	 *
	 * @return array result
	 */
	public function getObjsLinkedToObj( $args = array() ) {

		#} =========== LOAD ARGS ==============
		$defaultArgs = array(

			'objtypefrom'            => -1,
			'objtypeto'              => -1,

			// either or here, to specify direction of relationship
			'objfromid'              => -1,
			'objtoid'                => -1,

			// this will be passed to the getCompanies(array()) func, if given
			'objRetrievePassthrough' => array(),

			'count'                  => false, // only return count

			// permissions
			// 'ignoreowner'     => false // this'll let you not-check the owner of obj
			// NOTE 'owner' will ALWAYS be ignored by this, but allows for team/site
			// settings don't need owners yet :)

		);
		foreach ( $defaultArgs as $argK => $argV ) {
			$$argK = $argV;
			if ( is_array( $args ) && isset( $args[ $argK ] ) ) {
				if ( is_array( $args[ $argK ] ) ) {
					$newData = $$argK;
					if ( ! is_array( $newData ) ) {
						$newData = array();
					} foreach ( $args[ $argK ] as $subK => $subV ) {
						$newData[ $subK ] = $subV;
					}$$argK = $newData;
				} else {
					$$argK = $args[ $argK ]; }
			}
		}
		#} =========== / LOAD ARGS =============

		// hard ignored for now :)
		$ignoreowner = true;

		if ( empty( $objtypefrom ) ) {
			return false;
		}
		if ( $this->objTypeKey( $objtypefrom ) === -1 ) {
			return false;
		}
		if ( empty( $objtypeto ) ) {
			return false;
		}
		if ( $this->objTypeKey( $objtypeto ) === -1 ) {
			return false;
		}

		#} Check ID
		$direction = 'from';
		$objfromid = (int) $objfromid;
		$objtoid   = (int) $objtoid;
		if ( $objtoid > 0 ) {
			$direction = 'to';
		}

		if (
				( ! empty( $objfromid ) && $objfromid > 0 )
				||
				( ! empty( $objtoid ) && $objtoid > 0 )

			) {

			$res = array();

			// get links - this could all be one query... optimise once other db objects moved over
			$objLinks = $this->getObjsLinksLinkedToObj(
				array(
					'objtypefrom' => $objtypefrom, // contact
					'objtypeto'   => $objtypeto, // company
					'objfromid'   => $objfromid, // -1 or id
					'objtoid'     => $objtoid,
				)
			);

			if ( $count ) {
				if ( is_array( $objLinks ) ) {
					return count( $objLinks );
				} else {
					return 0;
				}
			}

			if ( is_array( $objLinks ) && count( $objLinks ) > 0 ) {

				// make an id array (useful)
				$idArray = array(); foreach ( $objLinks as $l ) {

					// switched direction
					$xid = $l['objidto'];
					if ( $direction == 'to' ) {
						$xid = $l['objidfrom'];
					}

					if ( ! in_array( $xid, $idArray ) ) {
						$idArray[] = $xid;
					}
				}

				// load them all (type dependent)
				switch ( $objtypeto ) {

					// not yet used, but will work :)
					case ZBS_TYPE_CONTACT:
						return $this->contacts->getContacts( array( 'inArr' => $idArray ) );
						break;

					case ZBS_TYPE_COMPANY:
						return $this->companies->getCompanies( array( 'inArr' => $idArray ) );
						break;

					case ZBS_TYPE_QUOTE:
						return $this->quotes->getQuotes( array( 'inArr' => $idArray ) );
						break;

					case ZBS_TYPE_INVOICE:
						return $this->invoices->getInvoices( array( 'inArr' => $idArray ) );
						break;

					case ZBS_TYPE_TRANSACTION:
						return $this->transactions->getTransactions( array( 'inArr' => $idArray ) );
						break;

					case ZBS_TYPE_TASK:
						return $this->events->getEvents( array( 'inArr' => $idArray ) );
						break;

					case ZBS_TYPE_QUOTETEMPLATE:
						return $this->quotetemplates->getQuotetemplate( array( 'inArr' => $idArray ) );
						break;

					/*
					not used
					case ZBS_TYPE_LOG:
						return $this->logs->getLogs(array('inArr'=>$idArray));
						break;

					case ZBS_TYPE_LINEITEM:
						return $this->events->getEvents(array('inArr'=>$idArray));
						break;

					case ZBS_TYPE_TASK_REMINDER:
						return $this->events->getEvents(array('inArr'=>$idArray));
						break;
					*/

				}
			}

			return $res;

		} // / if ID

		return false;
	}

	/**
	 * returns object link lines against an obj (e.g. link id, company id's against contact id 101)
	 *
	 * @param array $args   Associative array of arguments
	 *                      objtypeid, objid
	 *
	 * @return array result
	 */
	public function getObjsLinksLinkedToObj( $args = array() ) {

		#} =========== LOAD ARGS ==============
		$defaultArgs = array(

			'objtypefrom' => -1,
			'objtypeto'   => -1,

			// either or here, to specify direction of relationship
			// if 'direction' = 'both', it'll check both
			'objfromid'   => -1,
			'objtoid'     => -1,

			'direction'   => 'from', // from, to, both (both checks for both id's and is used to validate if links exist)

			'count'       => false, // only return count

			// permissions
			// 'ignoreowner'     => false // this'll let you not-check the owner of obj
			// NOTE 'owner' will ALWAYS be ignored by this, but allows for team/site
			// settings don't need owners yet :)

		);
		foreach ( $defaultArgs as $argK => $argV ) {
			$$argK = $argV;
			if ( is_array( $args ) && isset( $args[ $argK ] ) ) {
				if ( is_array( $args[ $argK ] ) ) {
					$newData = $$argK;
					if ( ! is_array( $newData ) ) {
						$newData = array();
					} foreach ( $args[ $argK ] as $subK => $subV ) {
						$newData[ $subK ] = $subV;
					}$$argK = $newData;
				} else {
					$$argK = $args[ $argK ]; }
			}
		}
		#} =========== / LOAD ARGS =============

		// hard ignored for now
		$ignoreowner = true;

		if ( empty( $objtypefrom ) ) {
			return false;
		}
		if ( $this->objTypeKey( $objtypefrom ) === -1 ) {
			return false;
		}
		if ( empty( $objtypeto ) ) {
			return false;
		}
		if ( $this->objTypeKey( $objtypeto ) === -1 ) {
			return false;
		}

		#} Check ID
		$objfromid = (int) $objfromid;
		$objtoid   = (int) $objtoid;
		if ( $objtoid > 0 && $direction != 'both' ) {
			$direction = 'to';
		}

		if (
				( ! empty( $objfromid ) && $objfromid > 0 )
				||
				( ! empty( $objtoid ) && $objtoid > 0 )

			) {

			global $ZBSCRM_t, $wpdb;
			$wheres          = array( 'direct' => array() );
			$whereStr        = '';
			$additionalWhere = '';
			$params          = array();
			$res             = array();

			#} Build query
			$query = 'SELECT * FROM ' . $ZBSCRM_t['objlinks'];

			#} ============= WHERE ================

				#} Add
				$wheres['zbsol_objtype_from'] = array( 'zbsol_objtype_from', '=', '%s', $objtypefrom );
				$wheres['zbsol_objtype_to']   = array( 'zbsol_objtype_to', '=', '%s', $objtypeto );

				// which direction?
			if ( $direction == 'from' || $direction == 'both' ) {
				$wheres['zbsol_objid_from'] = array( 'zbsol_objid_from', '=', '%s', $objfromid );
			}
			if ( $direction == 'to' || $direction == 'both' ) {
				$wheres['zbsol_objid_to'] = array( 'zbsol_objid_to', '=', '%s', $objtoid );
			}

			#} ============ / WHERE ==============

			#} Build out any WHERE clauses
			$wheresArr = $this->buildWheres( $wheres, $whereStr, $params );
			$whereStr  = $wheresArr['where'];
			$params    = $params + $wheresArr['params'];
			#} / Build WHERE

			#} Ownership v1.0 - the following adds SITE + TEAM checks, and (optionally), owner
			$params = array_merge( $params, $this->ownershipQueryVars( $ignoreowner ) ); // merges in any req.
			$ownQ   = $this->ownershipSQL( $ignoreowner );
			if ( ! empty( $ownQ ) ) {
				$additionalWhere = $this->spaceAnd( $additionalWhere ) . $ownQ; // adds str to query
			}
			#} / Ownership

			#} Append to sql (this also automatically deals with sortby and paging)
			$query .= $this->buildWhereStr( $whereStr, $additionalWhere ) . $this->buildSort( 'ID', 'DESC' ) . $this->buildPaging( 0, 10000 );

			try {

				#} Prep & run query
				$queryObj     = $this->prepare( $query, $params );
				$potentialRes = $wpdb->get_results( $queryObj, OBJECT );

			} catch ( Exception $e ) {

				#} General SQL Err
				$this->catchSQLError( $e );

			}

			#} Interpret results (Result Set - multi-row)
			if ( isset( $potentialRes ) && is_array( $potentialRes ) && count( $potentialRes ) > 0 ) {

				// if count?
				if ( $count ) {
					return count( $potentialRes );
				}

				#} Has results, tidy + return
				foreach ( $potentialRes as $resDataLine ) {

						// tidy
						$resArr = $this->tidy_objlink( $resDataLine );

						$res[] = $resArr;

				}
			}

			// if count?
			if ( $count ) {
				return 0;
			}

			return $res;

		} // / if ID

		return false;
	}

	/**
	 * returns ID of first obj of link type (to obj)
	 * e.g. ID first invoice linked to transaction ID (X)
	 * (useful where transactions:invoices are only ever linked 1:1)
	 *
	 * @param array $args   Associative array of arguments
	 *                      objtypeid, objid
	 *
	 * @return array result
	 */
	public function getFirstIDLinkedToObj( $args = array() ) {

		#} =========== LOAD ARGS ==============
		$defaultArgs = array(

			'objtypefrom' => -1,
			'objtypeto'   => -1,
			'objfromid'   => -1,

			// permissions
			// 'ignoreowner'     => false // this'll let you not-check the owner of obj
			// NOTE 'owner' will ALWAYS be ignored by this, but allows for team/site
			// settings don't need owners yet :)

		);
		foreach ( $defaultArgs as $argK => $argV ) {
			$$argK = $argV;
			if ( is_array( $args ) && isset( $args[ $argK ] ) ) {
				if ( is_array( $args[ $argK ] ) ) {
					$newData = $$argK;
					if ( ! is_array( $newData ) ) {
						$newData = array();
					} foreach ( $args[ $argK ] as $subK => $subV ) {
						$newData[ $subK ] = $subV;
					}$$argK = $newData;
				} else {
					$$argK = $args[ $argK ]; }
			}
		}
		#} =========== / LOAD ARGS =============

		// hard ignored for now
		$ignoreowner = true;

		if ( empty( $objtypefrom ) ) {
			return false;
		}
		if ( $this->objTypeKey( $objtypefrom ) === -1 ) {
			return false;
		}
		if ( empty( $objtypeto ) ) {
			return false;
		}
		if ( $this->objTypeKey( $objtypeto ) === -1 ) {
			return false;
		}

		#} Check ID
		$objfromid = (int) $objfromid;

		if ( ! empty( $objfromid ) && $objfromid > 0 ) {

			global $ZBSCRM_t, $wpdb;
			$wheres          = array( 'direct' => array() );
			$whereStr        = '';
			$additionalWhere = '';
			$params          = array();
			$res             = array();

			#} Build query
			$query = 'SELECT zbsol_objid_to FROM ' . $ZBSCRM_t['objlinks'];

			#} ============= WHERE ================

				#} Add
				$wheres['zbsol_objtype_from'] = array( 'zbsol_objtype_from', '=', '%s', $objtypefrom );
				$wheres['zbsol_objtype_to']   = array( 'zbsol_objtype_to', '=', '%s', $objtypeto );
				$wheres['zbsol_objid_from']   = array( 'zbsol_objid_from', '=', '%s', $objfromid );

			#} ============ / WHERE ==============

			#} Build out any WHERE clauses
			$wheresArr = $this->buildWheres( $wheres, $whereStr, $params );
			$whereStr  = $wheresArr['where'];
			$params    = $params + $wheresArr['params'];
			#} / Build WHERE

			#} Ownership v1.0 - the following adds SITE + TEAM checks, and (optionally), owner
			$params = array_merge( $params, $this->ownershipQueryVars( $ignoreowner ) ); // merges in any req.
			$ownQ   = $this->ownershipSQL( $ignoreowner );
			if ( ! empty( $ownQ ) ) {
				$additionalWhere = $this->spaceAnd( $additionalWhere ) . $ownQ; // adds str to query
			}
			#} / Ownership

			#} Append to sql (this also automatically deals with sortby and paging)
			$query .= $this->buildWhereStr( $whereStr, $additionalWhere ) . $this->buildSort( 'ID', 'DESC' ) . $this->buildPaging( 0, 1 );

			try {

				#} Prep & run query
				$queryObj     = $this->prepare( $query, $params );
				$potentialRes = $wpdb->get_var( $queryObj );
				if ( $potentialRes > -1 ) {
					return (int) $potentialRes;
				}
			} catch ( Exception $e ) {

				#} General SQL Err
				$this->catchSQLError( $e );

			}
		} // / if ID

		return false;
	}

	/**
	 * adds or updates a link object
	 * E.G. Contact -> Company
	 * this says "match obj X with obj Y" (effectively 'tagging' it)
	 * Using this generic format, but as of v2.5+ there's only contact->company links in here
	 *
	 * @param array $args Associative array of arguments
	 *              id (if update - probably never used here), data(objtype,objid,tagid)
	 *
	 * @return int line ID
	 */
	public function addUpdateObjLink( $args = array() ) {

		global $ZBSCRM_t, $wpdb;

		#} ============ LOAD ARGS =============
		$defaultArgs = array(

			'id'   => -1,

			// OWNERS will all be set to -1 for objlinks for now :)
			// 'owner'           => -1

			// fields (directly)
			'data' => array(

				'objtypefrom' => -1,
				'objtypeto'   => -1,
				'objfromid'   => -1,
				'objtoid'     => -1,

			),

		);
		foreach ( $defaultArgs as $argK => $argV ) {
			$$argK = $argV;
			if ( is_array( $args ) && isset( $args[ $argK ] ) ) {
				if ( is_array( $args[ $argK ] ) ) {
					$newData = $$argK;
					if ( ! is_array( $newData ) ) {
						$newData = array();
					} foreach ( $args[ $argK ] as $subK => $subV ) {
						$newData[ $subK ] = $subV;
					}$$argK = $newData;
				} else {
					$$argK = $args[ $argK ]; }
			}
		}
		#} =========== / LOAD ARGS ============

		#} ========== CHECK FIELDS ============

		// check obtype is completed + legit
		if ( empty( $data['objtypefrom'] ) ) {
			return false;
		}
		if ( $this->objTypeKey( $data['objtypefrom'] ) === -1 ) {
			return false;
		}
		if ( empty( $data['objtypeto'] ) ) {
			return false;
		}
		if ( $this->objTypeKey( $data['objtypeto'] ) === -1 ) {
			return false;
		}

			// if owner = -1, add current
			// for now, all -1 - not needed yet (makes tags dupe e.g.) if (!isset($owner) || $owner === -1) $owner = zeroBSCRM_user();
			$owner = -1;

			#} check obj ids
		if ( empty( $data['objfromid'] ) || $data['objfromid'] < 1 || empty( $data['objtoid'] ) || $data['objtoid'] < 1 ) {
			return false;
		}

		#} ========= / CHECK FIELDS ===========

		#} Check if ID present
		$id = (int) $id;
		if ( ! empty( $id ) && $id > 0 ) {

				#} Check if obj exists (here) - for now just brutal update (will error when doesn't exist)

				#} Attempt update
			if ( $wpdb->update(
				$ZBSCRM_t['objlinks'],
				array(

					// ownership
					// no need to update these (as of yet) - can't move teams etc.
					// 'zbs_site' => zeroBSCRM_installSite(),
					// 'zbs_team' => zeroBSCRM_installTeam(),
					'zbs_owner'          => $owner,

					// fields
					'zbsol_objtype_from' => $data['objtypefrom'],
					'zbsol_objtype_to'   => $data['objtypeto'],
					'zbsol_objid_from'   => $data['objfromid'],
					'zbsol_objid_to'     => $data['objtoid'],
				),
				array( // where
					'ID' => $id,
				),
				array( // field data types
					'%d',
					'%d',
					'%d',
					'%d',
					'%d',
				),
				array( // where data types
					'%d',
				)
			) !== false ) {

						// Successfully updated - Return id
						return $id;

			} else {

				// FAILED update
				return false;

			}
		} else {

			#} No ID - must be an INSERT
			if ( $wpdb->insert(
				$ZBSCRM_t['objlinks'],
				array(

					// ownership
					'zbs_site'           => zeroBSCRM_site(),
					'zbs_team'           => zeroBSCRM_team(),
					'zbs_owner'          => $owner,

					// fields
					'zbsol_objtype_from' => $data['objtypefrom'],
					'zbsol_objtype_to'   => $data['objtypeto'],
					'zbsol_objid_from'   => $data['objfromid'],
					'zbsol_objid_to'     => $data['objtoid'],
				),
				array( // field data types
					'%d',  // site
					'%d',  // team
					'%d',  // owner

					'%d',
					'%d',
					'%d',
					'%d',
				)
			) > 0 ) {

					#} Successfully inserted, lets return new ID
					$newID = $wpdb->insert_id;
					return $newID;

			} else {

				#} Failed to Insert
				return false;

			}
		}

		return false;
	}

	/**
	 * adds or updates object - object link  against an obj
	 * this says "match company X,Y,Z with contact Y"
	 *
	 * @param array $args Associative array of arguments
	 *              objtype,objid,tags (array of tagids)
	 *
	 * @return array $tags
	 */
	public function addUpdateObjLinks( $args = array() ) {

		global $ZBSCRM_t, $wpdb;

		#} ============ LOAD ARGS =============
		$defaultArgs = array(

			'owner'       => -1,

			'objtypefrom' => -1,
			'objtypeto'   => -1,
			'objfromid'   => -1,
			'objtoids'    => -1, // array of ID's

			'mode'        => 'replace', // replace|append|remove

		);
		foreach ( $defaultArgs as $argK => $argV ) {
			$$argK = $argV;
			if ( is_array( $args ) && isset( $args[ $argK ] ) ) {
				if ( is_array( $args[ $argK ] ) ) {
					$newData = $$argK;
					if ( ! is_array( $newData ) ) {
						$newData = array();
					} foreach ( $args[ $argK ] as $subK => $subV ) {
						$newData[ $subK ] = $subV;
					}$$argK = $newData;
				} else {
					$$argK = $args[ $argK ]; }
			}
		}
		#} =========== / LOAD ARGS ============

		#} ========== CHECK FIELDS ============

		// check obtype is completed + legit
		if ( empty( $objtypefrom ) ) {
			return false;
		}
		if ( $this->objTypeKey( $objtypefrom ) === -1 ) {
			return false;
		}
		if ( empty( $objtypeto ) ) {
			return false;
		}
		if ( $this->objTypeKey( $objtypeto ) === -1 ) {
			return false;
		}

		// if owner = -1, add current
		if ( $owner === -1 ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable
			$owner = zeroBSCRM_user();
		}

			// tagging id
			$objfromid = (int) $objfromid;
		if ( empty( $objfromid ) || $objfromid < 1 ) {
			return false;
		}

			// to obj list
		if ( ! is_array( $objtoids ) ) {
			return false;
		}

			// mode
		if ( gettype( $mode ) != 'string' || ! in_array( $mode, array( 'replace', 'append', 'remove' ) ) ) {
			return false;
		}

		#} ========= / CHECK FIELDS ===========

		switch ( $mode ) {

			case 'replace':
				// cull all previous
				$deleted = $this->deleteObjLinks(
					array(
						'objtypefrom' => $objtypefrom, // contact
						'objtypeto'   => $objtypeto, // company
						'objfromid'   => $objfromid,
					)
				); // where contact id =

				// cycle through & add
				foreach ( $objtoids as $objtoid ) {

					$added = $this->addUpdateObjLink(
						array(
							'data' => array(
								'objtypefrom' => $objtypefrom,
								'objtypeto'   => $objtypeto,
								'objfromid'   => $objfromid,
								'objtoid'     => $objtoid,
								'owner'       => $owner,
							),
						)
					);

				}

				break;

			case 'append':
				// get existing
				$objLinks = $this->getObjsLinksLinkedToObj(
					array(
						'objtypefrom' => $objtypefrom, // contact
						'objtypeto'   => $objtypeto, // company
						'objfromid'   => $objfromid,
					)
				);

				// make just ids
				$existingLinkIDs = array();
				foreach ( $objLinks as $l ) {
					$existingLinkIDs[] = $l['id'];
				}

				// cycle through& add
				foreach ( $objtoids as $objtoid ) {

					if ( ! in_array( $objtoid, $existingLinkIDs ) ) {

						// add a link
						$this->addUpdateObjLink(
							array(
								'data' => array(
									'objtypefrom' => $objtypefrom,
									'objtypeto'   => $objtypeto,
									'objfromid'   => $objfromid,
									'objtoid'     => $objtoid,
									'owner'       => $owner,
								),
							)
						);

					}
				}

				break;

			case 'remove':
				// cycle through & remove links
				foreach ( $objtoids as $objtoid ) {

					// add a link
					$this->deleteObjLinks(
						array(
							'objtypefrom' => $objtypefrom, // contact
							'objtypeto'   => $objtypeto, // company
							'objfromid'   => $objfromid,
							'objtoid'     => $objtoid,
						)
					); // where contact id =

				}

				break;

		}

		return false;
	}

	/**
	 * deletes all object links for a specific obj
	 *
	 * @param array $args Associative array of arguments
	 *              id
	 *
	 * @return int success;
	 */
	public function deleteObjLinks( $args = array() ) {

		global $ZBSCRM_t, $wpdb;

		#} ============ LOAD ARGS =============
		$defaultArgs = array(

			'objtypefrom' => -1,
			'objtypeto'   => -1,
			'objfromid'   => -1,
			'objtoid'     => -1, // only toid/fromid to be set if want to delete all contact->company links

		);
		foreach ( $defaultArgs as $argK => $argV ) {
			$$argK = $argV;
			if ( is_array( $args ) && isset( $args[ $argK ] ) ) {
				if ( is_array( $args[ $argK ] ) ) {
					$newData = $$argK;
					if ( ! is_array( $newData ) ) {
						$newData = array();
					} foreach ( $args[ $argK ] as $subK => $subV ) {
						$newData[ $subK ] = $subV;
					}$$argK = $newData;
				} else {
					$$argK = $args[ $argK ]; }
			}
		}
		#} =========== / LOAD ARGS ============

		#} ========== CHECK FIELDS ============

		// check obtype is completed + legit
		if ( empty( $objtypefrom ) ) {
			return false;
		}
		if ( $this->objTypeKey( $objtypefrom ) === -1 ) {
			return false;
		}
		if ( empty( $objtypeto ) ) {
			return false;
		}
		if ( $this->objTypeKey( $objtypeto ) === -1 ) {
			return false;
		}

			// obj id
			$objfromid = (int) $objfromid;
		$objtoid       = (int) $objtoid;
		if (
				( empty( $objfromid ) || $objfromid < 1 )
				&&
				( empty( $objtoid ) || $objtoid < 1 )
			) {
			return false;
		}

			// CHECK PERMISSIONS?

			#} ========= / CHECK FIELDS ===========

			// basics
			$where = array( // where
				'zbsol_objtype_from' => $objtypefrom,
				'zbsol_objtype_to'   => $objtypeto,
			);

			$whereFormat = array( // where
				'%d',
				'%d',
			);

			// any to add?
			if ( ! empty( $objfromid ) && $objfromid > 0 ) {
				$where['zbsol_objid_from'] = $objfromid;
				$whereFormat[]             = '%d';
			}
			if ( ! empty( $objtoid ) && $objtoid > 0 ) {
				$where['zbsol_objid_to'] = $objtoid;
				$whereFormat[]           = '%d';
			}

			// brutal
			return $wpdb->delete(
				$ZBSCRM_t['objlinks'],
				$where,
				$whereFormat
			);
	}

	/**
	 * tidy's the object from wp db into clean array
	 *
	 * @param array $obj (DB obj)
	 *
	 * @return array (clean obj)
	 */
	private function tidy_objlink( $obj = false ) {

			$res = false;

		if ( isset( $obj->ID ) ) {
			$res       = array();
			$res['id'] = $obj->ID;
			/*
			`zbs_site` INT NULL DEFAULT NULL,
			`zbs_team` INT NULL DEFAULT NULL,
			`zbs_owner` INT NOT NULL,
			*/

			$res['objtypefrom'] = $obj->zbsol_objtype_from;
			$res['objtypeto']   = $obj->zbsol_objtype_to;
			$res['objidfrom']   = $obj->zbsol_objid_from;
			$res['objidto']     = $obj->zbsol_objid_to;

		}

		return $res;
	}

	// ===========   OBJ LINKS   =====================================================
	// ===============================================================================

	// ===============================================================================
	// ===========   SETTINGS   ======================================================

	/**
	 * Wrapper, use $this->setting($key) for easy retrieval of singular
	 * Simplifies $this->getSetting
	 *
	 * @param string key
	 *
	 * @return bool result
	 */
	public function setting( $key = '', $default = false, $accept_cached = false ) {
		if ( ! empty( $key ) ) {
			return $this->getSetting(
				array(
					'key'           => $key,
					'fullDetails'   => false,
					'default'       => $default,
					'accept_cached' => $accept_cached,
				)
			);
		}

		return $default;
	}

	/**
	 * Wrapper, use $this->userSetting($key) for easy retrieval of singular setting FOR USER ID
	 * Simplifies $this->getSetting
	 * Specific for USER settings, this prefixes setting keys with usrset_ID_
	 *
	 * @param string key
	 *
	 * @return bool result
	 */
	public function userSetting( $userID = -1, $key = '', $default = false ) {

		if ( ! empty( $key ) && $userID > 0 ) {

			return $this->getSetting(
				array(

					// old way of doing it'key' => $this->getUserSettingPrefix($userID).$key,
					'key'         => $this->userSettingPrefix . $key,
					'fullDetails' => false,
					'default'     => $default,

					// this makes it 'per user'
					'ownedBy'     => $userID,

				)
			);

		}

		return $default;
	}

	/**
	 * returns full setting line +- details
	 *
	 * @param array $args   Associative array of arguments
	 *                      key, fullDetails, default
	 *
	 * @return array result
	 */
	public function getSetting( $args = array() ) {

		$key         = isset( $args['key'] ) ? $args['key'] : false;
		$fullDetails = isset( $args['fullDetails'] ) ? $args['fullDetails'] : false; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase

		if ( ! $fullDetails && static::mitigation_cache_for_issue_3504_contains_key( $key ) ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase, VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable
			return static::mitigation_cache_for_issue_3504_single_setting( $key ); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable
		}

		#} =========== LOAD ARGS ==============
		$defaultArgs = array(

			'key'           => false,
			'default'       => false,
			'fullDetails'   => false, // set this to 1 and get ID|key|val, rather than just the val

			// permissions - these are currently only used by screenoptions
			'ignoreowner'   => true, // this'll let you not-check the owner of obj
			'ownedBy'       => -1,

			// returns scalar ID of line
			'onlyID'        => false,

			// whether or not to accept cached variant.
			// Added in gh-2019, we often recall this function on one load, this allows us to accept a once-loaded version
			'accept_cached' => false,

		);
		foreach ( $defaultArgs as $argK => $argV ) {
			$$argK = $argV;
			if ( is_array( $args ) && isset( $args[ $argK ] ) ) {
				if ( is_array( $args[ $argK ] ) ) {
					$newData = $$argK;
					if ( ! is_array( $newData ) ) {
						$newData = array();
					} foreach ( $args[ $argK ] as $subK => $subV ) {
						$newData[ $subK ] = $subV;
					}$$argK = $newData;
				} else {
					$$argK = $args[ $argK ]; }
			}
		}
		#} =========== / LOAD ARGS =============

		#} Check key
		if ( ! empty( $key ) ) {

			// if accepting a cached obj and is present, pass that to save the query
			// (only do so on singular return step, not full details)
			if ( ! $fullDetails && $accept_cached ) {

				$cached = $this->get_cache_var( 'setting_' . $key );

				if ( $cached ) {

					return $cached;

				}
			}

			global $ZBSCRM_t, $wpdb;
			$wheres          = array( 'direct' => array() );
			$whereStr        = '';
			$additionalWhere = '';
			$params          = array();
			$res             = array();

			#} Build query
			$query = 'SELECT * FROM ' . $ZBSCRM_t['settings'];

			#} ============= WHERE ================

				#} Add ID
				$wheres['zbsset_key'] = array( 'zbsset_key', '=', '%s', $key );

				#} Owned by
			if ( ! empty( $ownedBy ) && $ownedBy > 0 ) {

				// would never hard-type this in (would make generic as in buildWPMetaQueryWhere)
				// but this is only here until MIGRATED to db2 globally
				// $wheres['incompany'] = array('ID','IN','(SELECT DISTINCT post_id FROM '.$wpdb->prefix."postmeta WHERE meta_key = 'zbs_company' AND meta_value = %d)",$inCompany);
				// Use obj links now
				$wheres['ownedBy'] = array( 'zbs_owner', '=', '%s', $ownedBy );

			}

			#} ============ / WHERE ==============

			#} Build out any WHERE clauses
			$wheresArr = $this->buildWheres( $wheres, $whereStr, $params );
			$whereStr  = $wheresArr['where'];
			$params    = $params + $wheresArr['params'];
			#} / Build WHERE

			#} Ownership v1.0 - the following adds SITE + TEAM checks, and (optionally), owner
			$params = array_merge( $params, $this->ownershipQueryVars( $ignoreowner ) ); // merges in any req.
			$ownQ   = $this->ownershipSQL( $ignoreowner );
			if ( ! empty( $ownQ ) ) {
				$additionalWhere = $this->spaceAnd( $additionalWhere ) . $ownQ; // adds str to query
			}
			#} / Ownership

			#} Append to sql (this also automatically deals with sortby and paging)
			$query .= $this->buildWhereStr( $whereStr, $additionalWhere ) . $this->buildSort( 'ID', 'DESC' ) . $this->buildPaging( 0, 1 );

			try {

				#} Prep & run query
				$queryObj     = $this->prepare( $query, $params );
				$potentialRes = $wpdb->get_row( $queryObj, OBJECT );

			} catch ( Exception $e ) {

				#} General SQL Err
				$this->catchSQLError( $e );

			}

			#} Interpret Results (ROW)
			if ( isset( $potentialRes ) && isset( $potentialRes->ID ) ) {

				// Has results, tidy + return

					// Only ID? return it directly
				if ( $onlyID === true ) {
					return $potentialRes->ID;
				}

					// full line or scalar setting val
				if ( $fullDetails ) {

					$setting = $this->tidy_setting( $potentialRes );

				} else {

					$setting = $this->tidy_settingSingular( $potentialRes );

					// We only update the mitigation cache when $fullDetails is false.
					static::mitigation_cache_for_issue_3504_set_single_setting( $key, $setting ); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable

					// cache (commonly retrieved)
					$this->update_cache_var( 'setting_' . $key, $setting );

				}

					return $setting;

			}
		} // / if ID

		if ( ! $fullDetails ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase, VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable
			// We only update the mitigation cache when $fullDetails is false.
			static::mitigation_cache_for_issue_3504_set_single_setting( $key, $default ); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable
		}

		return $default;
	}

	/**
	 * returns all settings as settings arr (later add autoload)
	 *
	 * @param array $args Associative array of arguments
	 *
	 * @return array of settings lines
	 */
	public function getSettings( $args = array() ) {
		// If we have already loaded all settings in our cache, return them.
		// In this function we won't care if the values have changed or not because when they become invalid due to other functions, these functions should reset the cache.
		if ( static::mitigation_cache_for_issue_3504_contains_all_settings() ) {
			return static::mitigation_cache_for_issue_3504_all_settings();
		}
		// Reseting any single settings we may have already loaded.
		static::reset_mitigation_cache_for_issue_3504();

		#} ============ LOAD ARGS =============
		$defaultArgs = array(

			'autoloadOnly' => true,
			'fullDetails'  => false, // if true returns inc id etc.

			// permissions
			// 'ignoreowner'     => false // this'll let you not-check the owner of obj
			// NOTE 'owner' will ALWAYS be ignored by this, but allows for team/site
			// settings don't need owners yet :)

		);
		foreach ( $defaultArgs as $argK => $argV ) {
			$$argK = $argV;
			if ( is_array( $args ) && isset( $args[ $argK ] ) ) {
				if ( is_array( $args[ $argK ] ) ) {
					$newData = $$argK;
					if ( ! is_array( $newData ) ) {
						$newData = array();
					} foreach ( $args[ $argK ] as $subK => $subV ) {
						$newData[ $subK ] = $subV;
					}$$argK = $newData;
				} else {
					$$argK = $args[ $argK ]; }
			}
		}
		#} =========== / LOAD ARGS =============

		#} ========== CHECK FIELDS ============

			// check obtype is legit
			// autoload?

			$fields = 'ID,zbsset_key,zbsset_val';
		if ( $fullDetails ) {
			$fields = '*';
		}

			// always ignore owner for now (settings global)
			$ignoreowner = true;

		#} ========= / CHECK FIELDS ===========

		global $ZBSCRM_t, $wpdb;
		$wheres          = array( 'direct' => array() );
		$whereStr        = '';
		$additionalWhere = '';
		$params          = array();
		$res             = array();

		#} Build query
		$query = "SELECT $fields FROM " . $ZBSCRM_t['settings'];

		#} ============= WHERE ================

			#} autoload?

		#} ============ / WHERE ===============

		#} Build out any WHERE clauses
		$wheresArr = $this->buildWheres( $wheres, $whereStr, $params );
		$whereStr  = $wheresArr['where'];
		$params    = $params + $wheresArr['params'];
		#} / Build WHERE

		#} Ownership v1.0 - the following adds SITE + TEAM checks, and (optionally), owner
		$params = array_merge( $params, $this->ownershipQueryVars( $ignoreowner ) ); // merges in any req.
		$ownQ   = $this->ownershipSQL( $ignoreowner );
		if ( ! empty( $ownQ ) ) {
			$additionalWhere = $this->spaceAnd( $additionalWhere ) . $ownQ; // adds str to query
		}
		#} / Ownership

		#} Append to sql (this also automatically deals with sortby and paging)
		$query .= $this->buildWhereStr( $whereStr, $additionalWhere ) . $this->buildSort( 'ID', 'ASC' ) . $this->buildPaging( 0, 10000 );

		try {

			#} Prep & run query
			$queryObj     = $this->prepare( $query, $params );
			$potentialRes = $wpdb->get_results( $queryObj, OBJECT );

		} catch ( Exception $e ) {

			#} General SQL Err
			$this->catchSQLError( $e );

		}

		#} Interpret results (Result Set - multi-row)
		if ( isset( $potentialRes ) && is_array( $potentialRes ) && count( $potentialRes ) > 0 ) {

			#} Has results, tidy + return
			foreach ( $potentialRes as $resDataLine ) {
				// We are only caching the singular setting (i.e. the value), because it is good enough.
				static::mitigation_cache_for_issue_3504_set_single_setting( $resDataLine->zbsset_key, $this->tidy_settingSingular( $resDataLine ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase

				if ( $fullDetails ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase, VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable
					$resArr                = $this->tidy_setting( $resDataLine ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
					$res[ $resArr['key'] ] = $resArr; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
				} else {
					$res[ $resDataLine->zbsset_key ] = $this->tidy_settingSingular( $resDataLine ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
				}
			}
		}

		static::mitigation_cache_for_issue_3504_set_contains_all( true );

		return $res;
	}

	/**
	 * Wrapper, use $this->updateSetting($key,$val) for easy update of setting
	 * Uses $this->addUpdateSetting
	 *
	 * @param string key
	 * @param string value
	 *
	 * @return bool result
	 */
	public function updateSetting( $key = '', $val = '' ) {

		if ( ! empty( $key ) ) {

			return $this->addUpdateSetting(
				array(

					'data' => array(

						'key' => $key,
						'val' => $val,
					),

				)
			);

		}

		return false;
	}

	/**
	 * Wrapper, use $this->updateSetting($key,$val) for easy update of setting
	 * Uses $this->addUpdateSetting
	 *
	 * @param string key
	 * @param string value
	 *
	 * @return bool result
	 */
	public function updateUserSetting( $userID = -1, $key = '', $val = '' ) {

		// if -1 passed use current user?

		if ( ! empty( $key ) && $userID > 0 ) {

			// because the following addUpdateSetting is dumb to owners (e.g. can't update 'per owner')
			// we must set perOwnerSetting to force 1 setting per key per user (owner)

			return $this->addUpdateSetting(
				array(

					'owner'           => $userID,
					'data'            => array(

						// old way of doing it'key' => $this->getUserSettingPrefix($userID).$key,
						'key' => $this->userSettingPrefix . $key,
						'val' => $val,
					),

					'perOwnerSetting' => true,

				)
			);

		}

		return false;
	}

	/**
	 * adds or updates a setting object
	 * ... for a quicker wrapper, use $this->updateSetting($key,$val)
	 *
	 * @param array $args Associative array of arguments
	 *              id (not req.), owner (not req.) data -> key/val
	 *
	 * @return int line ID
	 */
	public function addUpdateSetting( $args = array() ) {

		global $ZBSCRM_t, $wpdb;

		#} ============ LOAD ARGS =============
		$defaultArgs = array(

			'id'              => -1,
			// NOTE 'owner' will ALWAYS be ignored by this, but allows for team/site
			// meta don't need owners yet :)
			// not anymore! use this for screenoptions, will be ignored unless specifically set
			'owner'           => -1,

			// fields (directly)
			'data'            => array(

				'key' => '',
				'val' => '',

			),

			'perOwnerSetting' => false, // if set to true this'll make sure only 1 key per 'owner' (potentially multi-key if set incorrectly, so beware)

		);
		foreach ( $defaultArgs as $argK => $argV ) {
			$$argK = $argV;
			if ( is_array( $args ) && isset( $args[ $argK ] ) ) {
				if ( is_array( $args[ $argK ] ) ) {
					$newData = $$argK;
					if ( ! is_array( $newData ) ) {
						$newData = array();
					} foreach ( $args[ $argK ] as $subK => $subV ) {
						$newData[ $subK ] = $subV;
					}$$argK = $newData;
				} else {
					$$argK = $args[ $argK ]; }
			}
		}
		#} =========== / LOAD ARGS ============

		// Invalidates our whole cache.
		static::reset_mitigation_cache_for_issue_3504();

		#} ========== CHECK FIELDS ============

			$id = (int) $id;

		// if owner = -1, add current
		// Hard -1 for now - settings don't need - if (!isset($owner) || $owner === -1) $owner = zeroBSCRM_user();
		// ... they do now, (screen options) $owner = -1;
		if ( $owner !== -1 ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable
			$owner = (int) $owner;
		}

		// check key present + legit
		if ( empty( $data['key'] ) ) {
			return false;
		}

			// setting ID finder - if obj key provided, check setting not already present (if so overwrite)
			// keeps unique...
		if ( ( empty( $id ) || $id <= 0 )
				&&
				( isset( $data['key'] ) && ! empty( $data['key'] ) ) ) {

			// if perOwnerSetting it's 1 key-ed ret per owner, so query bit diff here:
			if ( ! $perOwnerSetting ) {

				// check existence + return ID
				$potentialID = (int) $this->getSetting(
					array(
						'key'    => $data['key'],
						'onlyID' => true,
					)
				);

			} else {

				// perownedBy

				// if no owner, return false, cannot be (shouldn't be cos of above)
				if ( $owner <= 0 ) {
					return false;
				}

				// check existence + return ID
				$potentialID = (int) $this->getSetting(
					array(
						'key'     => $data['key'],
						'onlyID'  => true,
						'ownedBy' => $owner,
					)
				);
			}

			// override empty ID
			if ( ! empty( $potentialID ) && $potentialID > 0 ) {
				$id = $potentialID;
			}
		}

		#} ========= / CHECK FIELDS ===========

		#} Var up any val (json_encode)
		if ( in_array( gettype( $data['val'] ), array( 'object', 'array' ) ) ) {

			// WH note: it was necessary to add JSON_UNESCAPED_SLASHES to properly save down without issue
			// combined with a more complex zeroBSCRM_stripSlashes recurrsive
			// https://stackoverflow.com/questions/7282755/how-to-remove-backslash-on-json-encode-function
			$data['val'] = wp_json_encode( $data['val'], JSON_UNESCAPED_SLASHES ); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable

		}

		if ( isset( $id ) && ! empty( $id ) && $id > 0 ) {

			// echo 'updating setting id '.$id.'!';

				#} Check if obj exists (here) - for now just brutal update (will error when doesn't exist)

				#} Attempt update
			if ( $wpdb->update(
				$ZBSCRM_t['settings'],
				array(

					// ownership
					// no need to update these (as of yet) - can't move teams etc.
					// 'zbs_site' => zeroBSCRM_installSite(),
					// 'zbs_team' => zeroBSCRM_installTeam(),
					'zbs_owner'          => $owner,

					// fields
					'zbsset_key'         => $data['key'],
					'zbsset_val'         => $data['val'],
					'zbsset_lastupdated' => time(),
				),
				array( // where
					'ID' => $id,
				),
				array( // field data types
					'%d',
					'%s',
					'%s',
					'%d',
				),
				array( // where data types
					'%d',
				)
			) !== false ) {

						// Successfully updated - Return id
						return $id;

			} else {

				// FAILED update
				return false;

			}
		} else {

			#} No ID - must be an INSERT
			if ( $wpdb->insert(
				$ZBSCRM_t['settings'],
				array(

					// ownership
					'zbs_site'           => zeroBSCRM_site(),
					'zbs_team'           => zeroBSCRM_team(),
					'zbs_owner'          => $owner,

					// fields
					'zbsset_key'         => $data['key'],
					'zbsset_val'         => $data['val'],
					'zbsset_created'     => time(),
					'zbsset_lastupdated' => time(),
				),
				array( // field data types
					'%d',  // site
					'%d',  // team
					'%d',  // owner

					'%s',
					'%s',
					'%d',
					'%d',
				)
			) > 0 ) {

					#} Successfully inserted, lets return new ID
					$newID = $wpdb->insert_id;
					return $newID;

			} else {

				#} Failed to Insert
				return false;

			}
		}

		return false;
	}

	/**
	 * deletes a setting object
	 *
	 * @param array $args Associative array of arguments
	 *              id
	 *
	 * @return int success;
	 */
	public function deleteSetting( $args = array() ) {

		global $ZBSCRM_t, $wpdb;

		#} ============ LOAD ARGS =============
		$defaultArgs = array(

			'id'  => -1,
			'key' => '',

		);
		foreach ( $defaultArgs as $argK => $argV ) {
			$$argK = $argV;
			if ( is_array( $args ) && isset( $args[ $argK ] ) ) {
				if ( is_array( $args[ $argK ] ) ) {
					$newData = $$argK;
					if ( ! is_array( $newData ) ) {
						$newData = array();
					} foreach ( $args[ $argK ] as $subK => $subV ) {
						$newData[ $subK ] = $subV;
					}$$argK = $newData;
				} else {
					$$argK = $args[ $argK ]; }
			}
		}
		#} =========== / LOAD ARGS ============

		// if ID passed, Check ID & Delete :)
		$id = (int) $id;
		if ( $id > 0 ) {
			return zeroBSCRM_db2_deleteGeneric( $id, 'settings' );
		}

		// if key, find and delete
		if ( ! empty( $key ) ) {

			$setting_id = $this->getSetting(
				array(
					'key'    => $key,
					'onlyID' => true,
				)
			);

			return zeroBSCRM_db2_deleteGeneric( $setting_id, 'settings' );

		}

		return false;
	}

	/**
	 * tidy's the object from wp db into clean array
	 *
	 * @param array $obj (DB obj)
	 *
	 * @return array (clean obj)
	 */
	private function tidy_setting( $obj = false ) {

		$res = false;

		if ( isset( $obj->ID ) ) {

			$res            = array();
			$res['id']      = $obj->ID;
			$res['key']     = $obj->zbsset_key;
			$res['val']     = $this->unpack_setting( $obj->zbsset_val );
			$res['created'] = $obj->zbsset_created;
			$res['updated'] = $obj->zbsset_lastupdated;

		}

		return $res;
	}

	/**
	 * tidy's the object from wp db into clean array
	 *
	 * @param array $obj (DB obj)
	 *
	 * @return string
	 */
	private function tidy_settingSingular( $obj = false ) {

		if ( isset( $obj->ID ) ) {

			return $this->unpack_setting( $obj->zbsset_val );

		}

		return false;
	}

	/**
	 * Takes a setting as db value and attempts to cast it correctly
	 *
	 * @param mixed $var (DB value)
	 *
	 * @return mixed
	 */
	private function unpack_setting( $var = false ) {

		if ( $var !== false ) {

			$value = $this->stripSlashes( $this->decodeIfJSON( $var ) );

			// catch this oddly non-decoded case
			if ( $value == '[]' ) {
				return array();
			}

			// if we've a string, check it isn't viably an int
			// .. if so, cast as int
			if ( is_string( $value ) && jpcrm_is_int( $value ) ) {
				$value = (int) $value;
			}

			return $value;
		}

		// fallback to returning the value passed
		return $var;
	}

	// =========== / SETTINGS  =======================================================
	// ===============================================================================

	// ===============================================================================
	// ===========   META ============================================================

	/**
	 * Wrapper, use $this->meta($objtype,$objid,$key) for easy retrieval of singular
	 * Simplifies $this->getMeta
	 *
	 * @param int objtype
	 * @param int objid
	 * @param string key
	 *
	 * @return bool result
	 */
	public function meta( $objtype = -1, $objid = -1, $key = '', $default = false ) {

		if ( ! empty( $key ) ) {

			return $this->getMeta(
				array(

					'objtype'     => $objtype,
					'objid'       => $objid,
					'key'         => $key,
					'fullDetails' => false,
					'default'     => $default,

				)
			);

		}

		return $default;
	}

	/**
	 * returns full meta line +- details
	 *
	 * @param array $args   Associative array of arguments
	 *                      key, fullDetails, default
	 *
	 * @return array result
	 */
	public function getMeta( $args = array() ) {

		#} =========== LOAD ARGS ==============
		$defaultArgs = array(

			'objid'            => -1, // Object ID
			'objtype'          => -1, // Object Type
			'key'              => false, // key *Required

			'default'          => false,

			'fullDetails'      => false, // set this to 1 and get ID|key|val, rather than just the val

			// permissions
			// 'ignoreowner'     => false // this'll let you not-check the owner of obj
			// NOTE 'owner' will ALWAYS be ignored by this, but allows for team/site
			// meta don't need owners yet :)

			'onlyID'           => false, // returns scalar ID of line
			'return_all_lines' => false, // if just specifying a key and setting this to true, will return all meta lines with key

		);
		foreach ( $defaultArgs as $argK => $argV ) {
			$$argK = $argV;
			if ( is_array( $args ) && isset( $args[ $argK ] ) ) {
				if ( is_array( $args[ $argK ] ) ) {
					$newData = $$argK;
					if ( ! is_array( $newData ) ) {
						$newData = array();
					} foreach ( $args[ $argK ] as $subK => $subV ) {
						$newData[ $subK ] = $subV;
					}$$argK = $newData;
				} else {
					$$argK = $args[ $argK ]; }
			}
		}
		#} =========== / LOAD ARGS =============

		#} =========== CHECK FIELDS =============

		// check obtype is legit
		if ( $objtype !== -1 && $this->objTypeKey( $objtype ) === -1 ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable
			return false;
		}

			// obj id
			$objid = (int) $objid;

			// for now, meta hard ignores owners
			$ignoreowner = true;

		#} =========== / CHECK FIELDS =============

		#} Check key
		if ( ! empty( $key ) ) {

			global $ZBSCRM_t, $wpdb;
			$wheres          = array( 'direct' => array() );
			$whereStr        = '';
			$additionalWhere = '';
			$params          = array();
			$res             = array();

			#} Build query
			$query = 'SELECT * FROM ' . $ZBSCRM_t['meta'];

			#} ============= WHERE ================

				// Add ID
			if ( $objid > 0 ) {
				$wheres['zbsm_objid'] = array( 'zbsm_objid', '=', '%d', $objid );
			}
				// Add OBJTYPE
			if ( $objtype > 0 ) {
				$wheres['zbsm_objtype'] = array( 'zbsm_objtype', '=', '%d', $objtype );
			}
				// Add KEY
				$wheres['zbsm_key'] = array( 'zbsm_key', '=', '%s', $key );

			#} ============ / WHERE ==============

			#} Build out any WHERE clauses
			$wheresArr = $this->buildWheres( $wheres, $whereStr, $params );
			$whereStr  = $wheresArr['where'];
			$params    = $params + $wheresArr['params'];
			#} / Build WHERE

			#} Ownership v1.0 - the following adds SITE + TEAM checks, and (optionally), owner
			$params = array_merge( $params, $this->ownershipQueryVars( $ignoreowner ) ); // merges in any req.
			$ownQ   = $this->ownershipSQL( $ignoreowner );
			if ( ! empty( $ownQ ) ) {
				$additionalWhere = $this->spaceAnd( $additionalWhere ) . $ownQ; // adds str to query
			}
			#} / Ownership

			#} Append to sql (this also automatically deals with sortby and paging)
			$query .= $this->buildWhereStr( $whereStr, $additionalWhere ) . $this->buildSort( 'ID', 'DESC' );

			if ( ! $return_all_lines ) {
				$query .= $this->buildPaging( 0, 1 );
			}

			try {

				#} Prep & run query
				$queryObj = $this->prepare( $query, $params );

				if ( ! $return_all_lines ) {

					// singular
					$result = $wpdb->get_row( $queryObj, OBJECT );

				} else {

					// multi-line
					$result = $wpdb->get_results( $queryObj, OBJECT );

				}
			} catch ( Exception $e ) {

				#} General SQL Err
				$this->catchSQLError( $e );

			}

			// results
			if ( isset( $result ) ) {

				#} Has results, tidy + return
				if ( ! $return_all_lines ) {

					#} Only ID? return it directly
					if ( $onlyID === true ) {
						return $result->ID;
					}

					#} full line or scalar setting val
					if ( $fullDetails ) {
						return $this->tidy_meta( $result );
					} else {
						return $this->tidy_metaSingular( $result );
					}
				} else {

					// multi-line
					$results_array = array();
					foreach ( $result as $index => $meta_line ) {

						$results_array[] = $this->tidy_meta( $meta_line );

					}

					return $results_array;
				}
			}
		} // / if ID

		return $default;
	}

	/**
	 * returns FIRST ID which has matching meta keval pair
	 *
	 * @param array $args   Associative array of arguments
	 *                      key, fullDetails, default
	 *
	 * @return array result
	 */
	public function getIDWithMeta( $args = array() ) {

		#} =========== LOAD ARGS ==============
		$defaultArgs = array(

			'objtype' => -1, // REQ
			'key'     => false, // REQ
			'val'     => false, // REQ

			// permissions
			// 'ignoreowner'     => false // this'll let you not-check the owner of obj
			// NOTE 'owner' will ALWAYS be ignored by this, but allows for team/site
			// meta don't need owners yet :)

		);
		foreach ( $defaultArgs as $argK => $argV ) {
			$$argK = $argV;
			if ( is_array( $args ) && isset( $args[ $argK ] ) ) {
				if ( is_array( $args[ $argK ] ) ) {
					$newData = $$argK;
					if ( ! is_array( $newData ) ) {
						$newData = array();
					} foreach ( $args[ $argK ] as $subK => $subV ) {
						$newData[ $subK ] = $subV;
					}$$argK = $newData;
				} else {
					$$argK = $args[ $argK ]; }
			}
		}
		#} =========== / LOAD ARGS =============

		#} =========== CHECK FIELDS =============

		// check obtype is completed + legit
		if ( empty( $objtype ) ) {
			return false;
		}
		if ( $this->objTypeKey( $objtype ) === -1 ) {
			return false;
		}

			// meta key
		if ( empty( $key ) || empty( $val ) || $key == false || $val == false ) {
			return false;
		}

			// for now, meta hard ignores owners
			$ignoreowner = true;

		#} =========== / CHECK FIELDS =============

			global $ZBSCRM_t, $wpdb;
			$wheres      = array( 'direct' => array() );
		$whereStr        = '';
		$additionalWhere = '';
		$params          = array();
		$res             = array();

			#} Build query
			$query = 'SELECT zbsm_objid FROM ' . $ZBSCRM_t['meta'];

			#} ============= WHERE ================

				#} Add OBJTYPE
				$wheres['zbsm_objtype'] = array( 'zbsm_objtype', '=', '%d', $objtype );
				#} Add KEY
				$wheres['zbsm_key'] = array( 'zbsm_key', '=', '%s', $key );
				#} Add VAL
				$wheres['zbsm_val'] = array( 'zbsm_val', '=', '%s', $val );

			#} ============ / WHERE ==============

			#} Build out any WHERE clauses
			$wheresArr = $this->buildWheres( $wheres, $whereStr, $params );
			$whereStr  = $wheresArr['where'];
		$params        = $params + $wheresArr['params'];
			#} / Build WHERE

			#} Ownership v1.0 - the following adds SITE + TEAM checks, and (optionally), owner
			$params = array_merge( $params, $this->ownershipQueryVars( $ignoreowner ) ); // merges in any req.
			$ownQ   = $this->ownershipSQL( $ignoreowner );
		if ( ! empty( $ownQ ) ) {
			$additionalWhere = $this->spaceAnd( $additionalWhere ) . $ownQ; // adds str to query
		}
			#} / Ownership

			#} Append to sql (this also automatically deals with sortby and paging)
			$query .= $this->buildWhereStr( $whereStr, $additionalWhere ) . $this->buildSort( 'ID', 'DESC' ) . $this->buildPaging( 0, 1 );

		try {

			#} Prep & run query
			$queryObj = $this->prepare( $query, $params );
			$v        = (int) $wpdb->get_var( $queryObj );
			if ( $v > -1 ) {
				return $v;
			}
		} catch ( Exception $e ) {

			#} General SQL Err
			$this->catchSQLError( $e );

		}

		return -1;
	}

	/**
	 * Wrapper, use $this->updateMeta($objtype,$objid,$key,$val) for easy update of setting
	 * Uses $this->addUpdateMeta
	 * ... USE sub-layer rather than this direct, gives a degree of abstraction
	 *
	 * @param string key
	 * @param string value
	 *
	 * @return bool result
	 */
	public function updateMeta( $objtype = -1, $objid = -1, $key = '', $val = '' ) {

		if ( ! empty( $key ) ) { // && !empty($val)

			return $this->addUpdateMeta(
				array(

					'data' => array(

						'objid'   => $objid,
						'objtype' => $objtype,
						'key'     => $key,
						'val'     => $val,
					),

				)
			);

		}

		return false;
	}

	/**
	 * adds or updates a setting object
	 * ... for a quicker wrapper, use $this->updateMeta($key,$val)
	 *
	 * @param array $args Associative array of arguments
	 *              id (not req.), owner (not req.) data -> key/val
	 *
	 * @return int line ID
	 */
	public function addUpdateMeta( $args = array() ) {

		global $ZBSCRM_t, $wpdb;

		#} ============ LOAD ARGS =============
		$defaultArgs = array(

			'id'   => -1,
			// owner HARD disabled for this for now - not req. for each meta
			// 'owner'           => -1,

			// fields (directly)
			'data' => array(

				'objid'   => -1,
				'objtype' => -1,
				'key'     => '',
				'val'     => '',

			),

		);
		foreach ( $defaultArgs as $argK => $argV ) {
			$$argK = $argV;
			if ( is_array( $args ) && isset( $args[ $argK ] ) ) {
				if ( is_array( $args[ $argK ] ) ) {
					$newData = $$argK;
					if ( ! is_array( $newData ) ) {
						$newData = array();
					} foreach ( $args[ $argK ] as $subK => $subV ) {
						$newData[ $subK ] = $subV;
					}$$argK = $newData;
				} else {
					$$argK = $args[ $argK ]; }
			}
		}
		#} =========== / LOAD ARGS ============

		#} ========== CHECK FIELDS ============

			$id = (int) $id;

			// if owner = -1, add current
			// if (!isset($owner) || $owner === -1) $owner = zeroBSCRM_user();
			// owner HARD disabled for this for now - not req. for each meta
			$owner = -1;

		// check key present + legit
		if ( empty( $data['key'] ) ) {
			return false;
		}

			// check obtype is completed + legit
		if ( ! isset( $data['objtype'] ) || empty( $data['objtype'] ) ) {
			return false;
		}
		if ( $this->objTypeKey( $data['objtype'] ) === -1 ) {
			return false;
		}

			// obj id
			$objid = (int) $data['objid'];
		if ( empty( $objid ) || $objid < 1 ) {
			return false;
		}

			// meta ID finder - if obj key provided, check meta not already present (if so overwrite)
			// keeps unique...
		if ( ( empty( $id ) || $id <= 0 )
				&&
				( isset( $data['key'] ) && ! empty( $data['key'] ) )
				// no need to check obj id + type here, as will return false above if not legit :)
				) {

			// check existence + return ID
			$potentialID = (int) $this->getMeta(
				array(
					'objid'   => $objid,
					'objtype' => $data['objtype'],
					'key'     => $data['key'],
					'onlyID'  => true,
				)
			);

			// override empty ID
			if ( ! empty( $potentialID ) && $potentialID > 0 ) {
				$id = $potentialID;
			}
		}

		#} ========= / CHECK FIELDS ===========

		#} Var up any val (json_encode)
		if ( in_array( gettype( $data['val'] ), array( 'object', 'array' ) ) ) {

			$data['val'] = wp_json_encode( $data['val'], JSON_UNESCAPED_SLASHES ); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable

		}

		if ( isset( $id ) && ! empty( $id ) && $id > 0 ) {

				#} Check if obj exists (here) - for now just brutal update (will error when doesn't exist)

				#} Attempt update
			if ( $wpdb->update(
				$ZBSCRM_t['meta'],
				array(

					// ownership
					// no need to update these (as of yet) - can't move teams etc.
					// 'zbs_site' => zeroBSCRM_installSite(),
					// 'zbs_team' => zeroBSCRM_installTeam(),
					'zbs_owner'        => $owner,

					// fields
					'zbsm_objtype'     => $data['objtype'],
					'zbsm_objid'       => $objid,
					'zbsm_key'         => $data['key'],
					'zbsm_val'         => $data['val'],
					'zbsm_lastupdated' => time(),
				),
				array( // where
					'ID' => $id,
				),
				array( // field data types
					'%d',
					'%d',
					'%d',
					'%s',
					'%s',
					'%d',
				),
				array( // where data types
					'%d',
				)
			) !== false ) {

						// Successfully updated - Return id
						return $id;

			} else {

				// FAILED update
				return false;

			}
		} else {

			#} No ID - must be an INSERT
			if ( $wpdb->insert(
				$ZBSCRM_t['meta'],
				array(

					// ownership
					'zbs_site'         => zeroBSCRM_site(),
					'zbs_team'         => zeroBSCRM_team(),
					'zbs_owner'        => $owner,

					// fields
					'zbsm_objtype'     => $data['objtype'],
					'zbsm_objid'       => $objid,
					'zbsm_key'         => $data['key'],
					'zbsm_val'         => $data['val'],
					'zbsm_created'     => time(),
					'zbsm_lastupdated' => time(),
				),
				array( // field data types
					'%d',  // site
					'%d',  // team
					'%d',  // owner

					'%d',
					'%d',
					'%s',
					'%s',
					'%d',
					'%d',
				)
			) > 0 ) {

					#} Successfully inserted, lets return new ID
					$newID = $wpdb->insert_id;
					return $newID;

			} else {

				#} Failed to Insert
				return false;

			}
		}

		return false;
	}

	/**
	 * deletes a meta object based on objid + key
	 *
	 * @param array $args Associative array of arguments
	 *              id
	 *
	 * @return int success;
	 */
	public function deleteMeta( $args = array() ) {

		global $ZBSCRM_t, $wpdb;

		#} ============ LOAD ARGS =============
		$defaultArgs = array(

			'objtype' => -1,
			'objid'   => -1,
			'key'     => '',

		);
		foreach ( $defaultArgs as $argK => $argV ) {
			$$argK = $argV;
			if ( is_array( $args ) && isset( $args[ $argK ] ) ) {
				if ( is_array( $args[ $argK ] ) ) {
					$newData = $$argK;
					if ( ! is_array( $newData ) ) {
						$newData = array();
					} foreach ( $args[ $argK ] as $subK => $subV ) {
						$newData[ $subK ] = $subV;
					}$$argK = $newData;
				} else {
					$$argK = $args[ $argK ]; }
			}
		}
		#} =========== / LOAD ARGS ============

		#} Check ID, find, & Delete :)
		$objtype = (int) $objtype;
		if ( isset( $objtype ) && $objtype !== -1 && $this->objTypeKey( $objtype ) === -1 ) {
			return false;
		}
		$objid = (int) $objid;
		if ( empty( $objid ) || $objid < 1 ) {
			return false;
		}
		if ( empty( $key ) ) {
			return false;
		}

		#} FIND?
		$potentialID = (int) $this->getMeta(
			array(
				'objid'   => $objid,
				'objtype' => $objtype,
				'key'     => $key,
				'onlyID'  => true,
			)
		);

		// override empty ID
		if ( ! empty( $potentialID ) && $potentialID > 0 ) {

			return $this->deleteMetaByMetaID( array( 'id' => $potentialID ) );

		}

		return false;
	}

	/**
	 * deletes a meta object from a meta id
	 *
	 * @param array $args Associative array of arguments
	 *              id
	 *
	 * @return int success;
	 */
	public function deleteMetaByMetaID( $args = array() ) {

		global $ZBSCRM_t, $wpdb;

		#} ============ LOAD ARGS =============
		$defaultArgs = array(

			'id' => -1,

		);
		foreach ( $defaultArgs as $argK => $argV ) {
			$$argK = $argV;
			if ( is_array( $args ) && isset( $args[ $argK ] ) ) {
				if ( is_array( $args[ $argK ] ) ) {
					$newData = $$argK;
					if ( ! is_array( $newData ) ) {
						$newData = array();
					} foreach ( $args[ $argK ] as $subK => $subV ) {
						$newData[ $subK ] = $subV;
					}$$argK = $newData;
				} else {
					$$argK = $args[ $argK ]; }
			}
		}
		#} =========== / LOAD ARGS ============

		#} Check ID & Delete :)
		$id = (int) $id;
		return zeroBSCRM_db2_deleteGeneric( $id, 'meta' );
	}

	/**
	 * tidy's the object from wp db into clean array
	 *
	 * @param array $obj (DB obj)
	 *
	 * @return array (clean obj)
	 */
	private function tidy_meta( $obj = false ) {

			$res = false;

		if ( isset( $obj->ID ) ) {
			$res            = array();
			$res['id']      = $obj->ID;
			$res['objtype'] = $obj->zbsm_objtype;
			$res['objid']   = $obj->zbsm_objid;
			$res['key']     = $obj->zbsm_key;
			$res['val']     = $this->stripSlashes( $obj->zbsm_val );
			$res['created'] = $obj->zbsm_created;
			$res['updated'] = $obj->zbsm_lastupdated;

		}

		return $res;
	}

	/**
	 * tidy's the object from wp db into clean array
	 *
	 * @param array $obj (DB obj)
	 *
	 * @return string
	 */
	private function tidy_metaSingular( $obj = false ) {

		$res = false;

		if ( isset( $obj->ID ) ) {
			return $this->stripSlashes( $this->decodeIfJSON( $obj->zbsm_val ) );
		}

		return $res;
	}

	// =========== / META  ===========================================================
	// ===============================================================================

	// ===============================================================================
	// ===========   TAGS  ===========================================================
	/**
	 * returns full tag line +- details
	 *
	 * @param int id        tag id
	 * @param array                $args   Associative array of arguments
	 *                                     withStats
	 *
	 * @return array result
	 */
	public function getTag( $id = -1, $args = array() ) {

		#} =========== LOAD ARGS ==============
		$defaultArgs = array(

			// Alternative search criteria to ID :)
			// .. LEAVE blank if using ID
			// objtype + name or slug
			'objtype'   => -1,
			'name'      => '',
			'slug'      => '',

			'withStats' => false,

			// permissions
			// 'ignoreowner'     => false // this'll let you not-check the owner of obj
			// NOTE 'owner' will ALWAYS be ignored by this, but allows for team/site
			// Tags don't need owners yet :)

			// returns scalar ID of line
			'onlyID'    => false,
			'onlySlug'  => false,

		);
		foreach ( $defaultArgs as $argK => $argV ) {
			$$argK = $argV;
			if ( is_array( $args ) && isset( $args[ $argK ] ) ) {
				if ( is_array( $args[ $argK ] ) ) {
					$newData = $$argK;
					if ( ! is_array( $newData ) ) {
						$newData = array();
					} foreach ( $args[ $argK ] as $subK => $subV ) {
						$newData[ $subK ] = $subV;
					}$$argK = $newData;
				} else {
					$$argK = $args[ $argK ]; }
			}
		}
		#} =========== / LOAD ARGS =============

		#} ========== CHECK FIELDS ============

			$id = (int) $id;

			// got objtype / name/slug?

		// check obtype is legit (if completed)
		if ( $objtype !== -1 && $this->objTypeKey( $objtype ) === -1 ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable

				// if using obj type - check name/slug
			if ( empty( $name ) && empty( $slug ) ) {
				return false;
			}

				// ... else should be good to search

		}

			// Tags don't need owners yet :)
			$ignoreowner = true;

		#} ========= / CHECK FIELDS ===========

		#} Check ID or name/type
		if ( $id > 0 || $objtype > 0 ) {

			global $ZBSCRM_t, $wpdb;
			$wheres          = array( 'direct' => array() );
			$whereStr        = '';
			$additionalWhere = '';
			$params          = array();
			$res             = array();

			#} Build query
			$query = 'SELECT * FROM ' . $ZBSCRM_t['tags'];

			#} ============= WHERE ================

			// ID
			if ( $id > 0 ) {
				$wheres['ID'] = array( 'ID', '=', '%d', $id );
			}

			// Object Type
			if ( $objtype > 0 ) {
				$wheres['zbstag_objtype'] = array( 'zbstag_objtype', '=', '%d', $objtype );
			}

			// Name
			if ( ! empty( $name ) ) {
				$wheres['zbstag_name'] = array( 'zbstag_name', '=', '%s', $name );
			}

			// Slug
			if ( ! empty( $slug ) ) {
				$wheres['zbstag_slug'] = array( 'zbstag_slug', '=', '%s', $slug );
			}

			#} ============ / WHERE ==============

			#} Build out any WHERE clauses
			$wheresArr = $this->buildWheres( $wheres, $whereStr, $params );
			$whereStr  = $wheresArr['where'];
			$params    = $params + $wheresArr['params'];
			#} / Build WHERE

			#} Ownership v1.0 - the following adds SITE + TEAM checks, and (optionally), owner
			$params = array_merge( $params, $this->ownershipQueryVars( $ignoreowner ) ); // merges in any req.
			$ownQ   = $this->ownershipSQL( $ignoreowner );
			if ( ! empty( $ownQ ) ) {
				$additionalWhere = $this->spaceAnd( $additionalWhere ) . $ownQ; // adds str to query
			}
			#} / Ownership

			#} Append to sql (this also automatically deals with sortby and paging)
			$query .= $this->buildWhereStr( $whereStr, $additionalWhere ) . $this->buildSort( 'ID', 'DESC' ) . $this->buildPaging( 0, 1 );

			try {

				#} Prep & run query
				$queryObj     = $this->prepare( $query, $params );
				$potentialRes = $wpdb->get_row( $queryObj, OBJECT );

			} catch ( Exception $e ) {

				#} General SQL Err
				$this->catchSQLError( $e );

			}

			#} Interpret Results (ROW)
			if ( isset( $potentialRes ) && isset( $potentialRes->ID ) ) {

				#} Has results, tidy + return

					#} Only ID? return it directly
				if ( $onlyID === true ) {
					return $potentialRes->ID;
				}

					#} Only slug? return it directly
				if ( $onlySlug === true ) {
					return $potentialRes->zbstag_slug;
				}

					// tidy
					$res = $this->tidy_tag( $potentialRes );

				// with stats?
				if ( $withStats ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable,WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase

						// add all stats lines
						$res['stats'] = $this->getTagStats( array( 'tagid' => $potentialRes->ID ) );

				}

					return $res;

			}
		} // / if ID

		return false;
	}

	/**
	 * returns tag detail lines
	 *
	 * @param array $args Associative array of arguments
	 *              withStats, searchPhrase, sortByField, sortOrder, page, perPage
	 *
	 * @return array of tag lines
	 */
	public function getAllTags( $args = array() ) {

		#} ============ LOAD ARGS =============
		$defaultArgs = array(

			'searchPhrase' => '',
			'withStats'    => false,

			'sortByField'  => 'ID',
			'sortOrder'    => 'ASC',
			'page'         => 0,
			'perPage'      => 100,

			// permissions
			// 'ignoreowner'     => false // this'll let you not-check the owner of obj
			// NOTE 'owner' will ALWAYS be ignored by this, but allows for team/site
			// Tags don't need owners yet :)

		);
		foreach ( $defaultArgs as $argK => $argV ) {
			$$argK = $argV;
			if ( is_array( $args ) && isset( $args[ $argK ] ) ) {
				if ( is_array( $args[ $argK ] ) ) {
					$newData = $$argK;
					if ( ! is_array( $newData ) ) {
						$newData = array();
					} foreach ( $args[ $argK ] as $subK => $subV ) {
						$newData[ $subK ] = $subV;
					}$$argK = $newData;
				} else {
					$$argK = $args[ $argK ]; }
			}
		}
		#} =========== / LOAD ARGS =============

		// Tags don't need owners yet :)
		$ignoreowner = true;

		global $ZBSCRM_t, $wpdb;
		$wheres          = array( 'direct' => array() );
		$whereStr        = '';
		$additionalWhere = '';
		$params          = array();
		$res             = array();

		#} Build query
		$query = 'SELECT * FROM ' . $ZBSCRM_t['tags'];

		#} ============= WHERE ================

			#} Add Search phrase
		if ( ! empty( $searchPhrase ) ) {

			$wheres['zbstag_name'] = array( 'zbstag_name', 'LIKE', '%s', '%' . $searchPhrase . '%' );

		}

		#} ============ / WHERE ===============

		#} Build out any WHERE clauses
		$wheresArr = $this->buildWheres( $wheres, $whereStr, $params );
		$whereStr  = $wheresArr['where'];
		$params    = $params + $wheresArr['params'];
		#} / Build WHERE

		#} Ownership v1.0 - the following adds SITE + TEAM checks, and (optionally), owner
		$params = array_merge( $params, $this->ownershipQueryVars( $ignoreowner ) ); // merges in any req.
		$ownQ   = $this->ownershipSQL( $ignoreowner );
		if ( ! empty( $ownQ ) ) {
			$additionalWhere = $this->spaceAnd( $additionalWhere ) . $ownQ; // adds str to query
		}
		#} / Ownership

		#} Append to sql (this also automatically deals with sortby and paging)
		$query .= $this->buildWhereStr( $whereStr, $additionalWhere ) . $this->buildSort( $sortByField, $sortOrder ) . $this->buildPaging( $page, $perPage );

		try {

			#} Prep & run query
			$queryObj     = $this->prepare( $query, $params );
			$potentialRes = $wpdb->get_results( $queryObj, OBJECT );

		} catch ( Exception $e ) {

			#} General SQL Err
			$this->catchSQLError( $e );

		}

		#} Interpret results (Result Set - multi-row)
		if ( isset( $potentialRes ) && is_array( $potentialRes ) && count( $potentialRes ) > 0 ) {

			#} Has results, tidy + return
			foreach ( $potentialRes as $resDataLine ) {

					// tidy
					$resArr = $this->tidy_tag( $resDataLine );

					// with stats?
				if ( isset( $withStats ) && $withStats ) {

					// add all stats lines
					$res['stats'] = $this->getTagStats( array( 'tagid' => $resDataLine->ID ) );

				}

					$res[] = $resArr;

			}
		}

		return $res;
	}

	/**
	 * adds or updates a tag object
	 *
	 * @param array $args Associative array of arguments
	 *              id (if update), ???
	 *
	 * @return int line ID
	 */
	public function addUpdateTag( $args = array() ) {

		global $ZBSCRM_t, $wpdb;

		#} ============ LOAD ARGS =============
		$defaultArgs = array(

			'id'   => -1,

			// fields (directly)
			'data' => array(

				'objtype' => -1,
				'name'    => '',
				'slug'    => '',
				// OWNERS will all be set to -1 for tags for now :)
				// 'owner'           => -1

			),

		);
		foreach ( $defaultArgs as $argK => $argV ) {
			$$argK = $argV;
			if ( is_array( $args ) && isset( $args[ $argK ] ) ) {
				if ( is_array( $args[ $argK ] ) ) {
					$newData = $$argK;
					if ( ! is_array( $newData ) ) {
						$newData = array();
					} foreach ( $args[ $argK ] as $subK => $subV ) {
						$newData[ $subK ] = $subV;
					}$$argK = $newData;
				} else {
					$$argK = $args[ $argK ]; }
			}
		}
		#} =========== / LOAD ARGS ============

		#} ========== CHECK FIELDS ============

			$id = (int) $id;

		// check obtype is completed + legit
		if ( empty( $data['objtype'] ) ) {
			return false;
		}
		if ( $this->objTypeKey( $data['objtype'] ) === -1 ) {
			return false;
		}

			// if owner = -1, add current
			// tags don't really need this level of ownership
			// so leaving as -1 for now :)
			// if (!isset($data['owner']) || $data['owner'] === -1) $data['owner'] = zeroBSCRM_user();
			$data['owner'] = -1;

			// check name present + legit
		if ( ! isset( $data['name'] ) || empty( $data['name'] ) ) {
			return false;
		}
		if ( empty( $data['slug'] ) ) {

			$potential_slug = sanitize_key( $data['name'] ); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable

			// catch empty slugs as per gh-462, chinese characters, for example
			if ( empty( $potential_slug ) ) {
				$this->get_new_tag_slug( $data['objtype'], 'tag', true ); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable
			} else {
				$data['slug'] = $this->get_new_tag_slug( $data['objtype'], $potential_slug ); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable
			}

			// if slug STILL empty (e.g. database error?), return false for now...
			if ( empty( $data['slug'] ) ) {
				return false;
			}
		}

			// tag ID finder - if obj name provided, check tag not already present (if so overwrite)
			// keeps unique...
		if ( ( empty( $id ) || $id <= 0 )
				&&
				(
					( isset( $data['name'] ) && ! empty( $data['name'] ) ) ||
					( isset( $data['slug'] ) && ! empty( $data['slug'] ) )
				) ) {

			// check by slug
			// check existence + return ID
			$potentialID = (int) $this->getTag(
				-1,
				array(
					'objtype' => $data['objtype'],
					'slug'    => $data['slug'],
					'onlyID'  => true,
				)
			);

			// override empty ID
			if ( ! empty( $potentialID ) && $potentialID > 0 ) {
				$id = $potentialID;
			}
		}

		#} ========= / CHECK FIELDS ===========

		#} Check if ID present
		$id = (int) $id;
		if ( ! empty( $id ) && $id > 0 ) {

				#} Check if obj exists (here) - for now just brutal update (will error when doesn't exist)

				#} Attempt update
			if ( $wpdb->update(
				$ZBSCRM_t['tags'],
				array(

					// ownership
					// no need to update these (as of yet) - can't move teams etc.
					// 'zbs_site' => zeroBSCRM_installSite(),
					// 'zbs_team' => zeroBSCRM_installTeam(),
					'zbs_owner'          => $data['owner'],

					// fields
					'zbstag_objtype'     => $data['objtype'],
					'zbstag_name'        => $data['name'],
					'zbstag_slug'        => $data['slug'],
					'zbstag_lastupdated' => time(),
				),
				array( // where
					'ID' => $id,
				),
				array( // field data types
					'%d',
					'%d',
					'%s',
					'%s',
					'%d',
				),
				array( // where data types
					'%d',
				)
			) !== false ) {

						// Successfully updated - Return id
						return $id;

			} else {

				// FAILED update
				return false;

			}
		} else {

			#} No ID - must be an INSERT
			if ( $wpdb->insert(
				$ZBSCRM_t['tags'],
				array(

					// ownership
					'zbs_site'           => zeroBSCRM_site(),
					'zbs_team'           => zeroBSCRM_team(),
					'zbs_owner'          => $data['owner'],

					// fields
					'zbstag_objtype'     => $data['objtype'],
					'zbstag_name'        => $data['name'],
					'zbstag_slug'        => $data['slug'],
					'zbstag_created'     => time(),
					'zbstag_lastupdated' => time(),
				),
				array( // field data types
					'%d',  // site
					'%d',  // team
					'%d',  // owner

					'%d',
					'%s',
					'%s',
					'%d',
					'%d',
				)
			) > 0 ) {

					#} Successfully inserted, lets return new ID
					$newID = $wpdb->insert_id;
					return $newID;

			} else {

				#} Failed to Insert
				return false;

			}
		}

		return false;
	}

	/**
	 * adds or updates any object's tags
	 * ... this is really just a wrapper for addUpdateTagObjLinks
	 *
	 * @param array $args Associative array of arguments
	 *              id (if update), owner, data (array of field data)
	 *
	 * @return int line ID
	 */
	public function addUpdateObjectTags( $args = array() ) {

		global $ZBSCRM_t, $wpdb;

		#} ============ LOAD ARGS =============
		$defaultArgs = array(

			'objid'     => -1, // REQ
			'objtype'   => -1, // REQ

			// generic pass-through (array of tag strings or tag IDs):
			'tag_input' => -1,

			// or either specific:
			'tagIDs'    => -1,
			'tags'      => -1,

			'mode'      => 'replace',

		);
		foreach ( $defaultArgs as $argK => $argV ) {
			$$argK = $argV;
			if ( is_array( $args ) && isset( $args[ $argK ] ) ) {
				if ( is_array( $args[ $argK ] ) ) {
					$newData = $$argK;
					if ( ! is_array( $newData ) ) {
						$newData = array();
					} foreach ( $args[ $argK ] as $subK => $subV ) {
						$newData[ $subK ] = $subV;
					}$$argK = $newData;
				} else {
					$$argK = $args[ $argK ]; }
			}
		}
		#} =========== / LOAD ARGS ============

		#} ========== CHECK FIELDS ============

			// check id
			$objid = (int) $objid;
		if ( empty( $objid ) || $objid <= 0 ) {
			return false;
		}

		// check obtype is legit (if completed)
		if ( $objtype === -1 || $this->objTypeKey( $objtype ) === -1 ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable
			return false;
		}

		#} ========= / CHECK FIELDS ===========

			// If passed tag_input, infer if using ID's or tags
		if ( is_array( $tag_input ) ) {

			// assume ID's
			$tagIDs = $tag_input;

			// got strings?
			foreach ( $tag_input as $tag ) {

				// if it's not an int, we can assume it's a string
				if ( ! jpcrm_is_int( $tag ) ) {

					// process as strings (tags)
					$tagIDs = -1;
					$tags   = $tag_input;
					break;

				}
			}
		}

		// If using tags, convert these to ids :)
		// @phan-suppress-next-line PhanImpossibleCondition -- Phan is confused; this var is initialized at the beginning of the function.
		if ( is_array( $tags ) ) {

				// overwrite
				$tagIDs = array();

				// cycle through + find
			foreach ( $tags as $tag ) {

				$tagID = $this->getTag(
					-1,
					array(
						'objtype' => $objtype,
						'name'    => $tag,
						'onlyID'  => true,
					)
				);

				// echo 'looking for tag "'.$tag.'" got id '.$tagID.'!<br >';

				if ( ! empty( $tagID ) ) {
					$tagIDs[] = $tagID;
				} else {

					// create
					$tagID = $this->addUpdateTag(
						array(
							'data' => array(
								'objtype' => $objtype,
								'name'    => $tag,
							),
						)
					);
					// add
					if ( ! empty( $tagID ) ) {
						$tagIDs[] = $tagID;
					}
				}
			}
		}

		return $this->addUpdateTagObjLinks(
			array(
				'objtype' => $objtype,
				'objid'   => $objid,
				'tagIDs'  => $tagIDs,
				'mode'    => $mode,
			)
		);
	}

	/**
	 * deletes a tag object
	 *
	 * @param array $args Associative array of arguments
	 *              id
	 *
	 * @return int success;
	 */
	public function deleteTag( $args = array() ) {

		global $ZBSCRM_t, $wpdb;

		#} ============ LOAD ARGS =============
		$defaultArgs = array(

			'id'          => -1,
			'deleteLinks' => true,

		);
		foreach ( $defaultArgs as $argK => $argV ) {
			$$argK = $argV;
			if ( is_array( $args ) && isset( $args[ $argK ] ) ) {
				if ( is_array( $args[ $argK ] ) ) {
					$newData = $$argK;
					if ( ! is_array( $newData ) ) {
						$newData = array();
					} foreach ( $args[ $argK ] as $subK => $subV ) {
						$newData[ $subK ] = $subV;
					}$$argK = $newData;
				} else {
					$$argK = $args[ $argK ]; }
			}
		}
		#} =========== / LOAD ARGS ============

		#} Check ID & Delete :)
		$id = (int) $id;
		if ( ! empty( $id ) && $id > 0 ) {

			$deleted = zeroBSCRM_db2_deleteGeneric( $id, 'tags' );

			// if links, also delete them!
			if ( $deleteLinks ) {

				$deletedLinks = $wpdb->delete(
					$ZBSCRM_t['taglinks'],
					array( // where
						'zbstl_tagid' => $id,
					),
					array(
						'%d',
					)
				);
			}

			return $deleted;

		}

		return false;
	}

	/**
	 * retrieves stats for tag (how many contacts/obj's use this tag) (effectively counts tag links split per obj)
	 *
	 * @param array $args Associative array of arguments
	 *              id
	 *
	 * @return array
	 */
	public function getAllTagStats( $args = array() ) {

		#} ============ LOAD ARGS =============
		$defaultArgs = array(

			'id'    => -1,
			'owner' => -1,

			// permissions
			// 'ignoreowner'     => false // this'll let you not-check the owner of obj
			// NOTE 'owner' will ALWAYS be ignored by this, but allows for team/site
			// Tags don't need owners yet :)

		);
		foreach ( $defaultArgs as $argK => $argV ) {
			$$argK = $argV;
			if ( is_array( $args ) && isset( $args[ $argK ] ) ) {
				if ( is_array( $args[ $argK ] ) ) {
					$newData = $$argK;
					if ( ! is_array( $newData ) ) {
						$newData = array();
					} foreach ( $args[ $argK ] as $subK => $subV ) {
						$newData[ $subK ] = $subV;
					}$$argK = $newData;
				} else {
					$$argK = $args[ $argK ]; }
			}
		}
		#} =========== / LOAD ARGS ============

		$ignoreowner = true;

		#} Check ID
		$id = (int) $id;
		if ( ! empty( $id ) && $id > 0 ) {

			global $ZBSCRM_t, $wpdb;
			$wheres          = array( 'direct' => array() );
			$whereStr        = '';
			$additionalWhere = '';
			$params          = array();
			$res             = array();

			#} Build query
			$query = 'SELECT COUNT(zbstl_objid) c, zbstl_objtype FROM ' . $ZBSCRM_t['taglinks'];

			#} ============= WHERE ================

				#} Add ID
				$wheres['zbstl_tagid'] = array( 'zbstl_tagid', '=', '%d', $id );

			// If 'owner' is set then have to ignore owner, because can't do both
			if ( $owner > 0 ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable

					// stops ownership check
					$ignoreowner = true;

					// adds owner to query
					$wheres['zbs_owner'] = array( 'zbs_owner', '=', '%d', $owner );

			}

			#} ============ / WHERE ==============

			#} Build out any WHERE clauses
			$wheresArr = $this->buildWheres( $wheres, $whereStr, $params );
			$whereStr  = $wheresArr['where'];
			$params    = $params + $wheresArr['params'];
			#} / Build WHERE

			#} Ownership v1.0 - the following adds SITE + TEAM checks, and (optionally), owner
			$params = array_merge( $params, $this->ownershipQueryVars( $ignoreowner ) ); // merges in any req.
			$ownQ   = $this->ownershipSQL( $ignoreowner );
			if ( ! empty( $ownQ ) ) {
				$additionalWhere = $this->spaceAnd( $additionalWhere ) . $ownQ; // adds str to query
			}
			#} / Ownership

			#} ============ CUSTOM GROUP/ORDERBY ==============

				// this allows grouping :)
				$orderByCustom = ' GROUP BY zbstl_objtype ORDER BY c ASC';

			#} ============ / CUSTOM GROUP/ORDERBY ============

			#} Append to sql (and use our custom order by etc.)
			$query .= $this->buildWhereStr( $whereStr, $additionalWhere ) . $orderByCustom;

			try {

				#} Prep & run query
				$queryObj     = $this->prepare( $query, $params );
				$potentialRes = $wpdb->get_results( $queryObj, OBJECT );

			} catch ( Exception $e ) {

				#} General SQL Err
				$this->catchSQLError( $e );

			}

			#} Interpret results (Result Set - multi-row)
			if ( isset( $potentialRes ) && is_array( $potentialRes ) && count( $potentialRes ) > 0 ) {

				#} Has results, tidy + return
				foreach ( $potentialRes as $resDataLine ) {

						// tidy
						$res[] = $this->tidy_tagstat( $resDataLine );

				}
			}

			return $res;

		} // / if ID

		return false;
	}

	/**
	 * retrieves stats for tag (how many contacts/obj's use this tag)
	 * this version returns specific count of uses for an objtypeid
	 *
	 * @param array $args Associative array of arguments
	 *              id
	 *
	 * @return array
	 */
	public function getTagObjStats( $args = array() ) {

		#} ============ LOAD ARGS =============
		$defaultArgs = array(

			'id'        => -1,
			'objtypeid' => -1,
			'owner'     => -1,

			// permissions
			// 'ignoreowner'     => false // this'll let you not-check the owner of obj
			// NOTE 'owner' will ALWAYS be ignored by this, but allows for team/site
			// Tags don't need owners yet :)

			// returns scalar ID of line
			'onlyID'    => false,

		);
		foreach ( $defaultArgs as $argK => $argV ) {
			$$argK = $argV;
			if ( is_array( $args ) && isset( $args[ $argK ] ) ) {
				if ( is_array( $args[ $argK ] ) ) {
					$newData = $$argK;
					if ( ! is_array( $newData ) ) {
						$newData = array();
					} foreach ( $args[ $argK ] as $subK => $subV ) {
						$newData[ $subK ] = $subV;
					}$$argK = $newData;
				} else {
					$$argK = $args[ $argK ]; }
			}
		}
		#} =========== / LOAD ARGS ============

		$ignoreowner = true;

		#} Check ID
		$id = (int) $id;
		if ( ! empty( $id ) && $id > 0 ) {

			global $ZBSCRM_t, $wpdb;
			$wheres          = array( 'direct' => array() );
			$whereStr        = '';
			$additionalWhere = '';
			$params          = array();
			$res             = array();

			#} Build query
			$query = 'SELECT COUNT(zbstl_objid) c, zbstl_objtype FROM ' . $ZBSCRM_t['taglinks'];

			#} ============= WHERE ================

				#} Add ID
				$wheres['zbstl_tagid'] = array( 'zbstl_tagid', '=', '%d', $id );

				#} Adds a specific type id
			if ( ! empty( $objtypeid ) ) {

				$wheres['zbstl_objtype'] = array( 'zbstl_objtype', '=', '%d', $objtypeid );

			}

			// If 'owner' is set then have to ignore owner, because can't do both
			if ( $owner > 0 ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable

					// stops ownership check
					$ignoreowner = true;

					// adds owner to query
					$wheres['zbs_owner'] = array( 'zbs_owner', '=', '%d', $owner );

			}

			#} ============ / WHERE ==============

			#} Build out any WHERE clauses
			$wheresArr = $this->buildWheres( $wheres, $whereStr, $params );
			$whereStr  = $wheresArr['where'];
			$params    = $params + $wheresArr['params'];
			#} / Build WHERE

			#} Ownership v1.0 - the following adds SITE + TEAM checks, and (optionally), owner
			$params = array_merge( $params, $this->ownershipQueryVars( $ignoreowner ) ); // merges in any req.
			$ownQ   = $this->ownershipSQL( $ignoreowner );
			if ( ! empty( $ownQ ) ) {
				$additionalWhere = $this->spaceAnd( $additionalWhere ) . $ownQ; // adds str to query
			}
			#} / Ownership

			#} ============ CUSTOM GROUP/ORDERBY ==============

				// this allows grouping :)
				$orderByCustom = ' GROUP BY zbstl_objtype ORDER BY c ASC LIMIT 0,1';

			#} ============ / CUSTOM GROUP/ORDERBY ============

			#} Append to sql (and use our custom order by etc.)
			$query .= $this->buildWhereStr( $whereStr, $additionalWhere ) . $orderByCustom;

			try {

				#} Prep & run query
				$queryObj     = $this->prepare( $query, $params );
				$potentialRes = $wpdb->get_row( $queryObj, OBJECT );

			} catch ( Exception $e ) {

				#} General SQL Err
				$this->catchSQLError( $e );

			}

			#} Interpret Results (ROW)
			if ( isset( $potentialRes ) && isset( $potentialRes->ID ) ) {

				#} Only ID? return it directly
				if ( $onlyID === true ) {
					return $potentialRes->ID;
				}

				#} Has results, tidy + return
				return $this->tidy_tagstat( $potentialRes );

			}
		} // / if ID

		return false;
	}

	/**
	 * Checks if a tag slug exists
	 *
	 * @param int    $obj_type_id Object type id.
	 * @param string $slug Tag slug to check.
	 *
	 * @return string tag slug
	 */
	public function tag_slug_exists( int $obj_type_id, string $slug ) {
		$slug_exists = $this->getTag(
			-1,
			array(
				'objtype' => $obj_type_id,
				'slug'    => $slug,
				'onlyID'  => true,
			)
		);
		return $slug_exists !== false;
	}

	/**
	 * Get a unique tag slug
	 *
	 * @param int    $obj_type_id Object type id.
	 * @param string $slug Tag slug to check.
	 * @param bool   $force_iteration Force iteration to occur (e.g. use `slug-N` instead of `slug`).
	 *
	 * @return string unique tag slug
	 */
	public function get_new_tag_slug( int $obj_type_id, string $slug, bool $force_iteration = false ) {
		global $wpdb, $ZBSCRM_t; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
		$slug_exists = $this->tag_slug_exists( $obj_type_id, $slug );

		// slug as provided doesn't exist, so use that
		if ( ! $slug_exists && ! $force_iteration ) {
			return $slug;
		}

		$slug_base = $slug . '-';

		// get last iteration of tag slug
		$sql_query = 'SELECT CAST(TRIM(LEADING %s FROM zbstag_slug) AS SIGNED) AS slug_iteration FROM ' . $ZBSCRM_t['tags'] . ' WHERE zbstag_slug LIKE CONCAT(%s,"%") AND zbstag_objtype = %d ORDER BY slug_iteration DESC LIMIT 1'; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase

		$cur_slug_iteration = $wpdb->get_var( $wpdb->prepare( $sql_query, $slug_base, $slug_base, $obj_type_id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared

		// slug hasn't yet iterated, so use first iteration
		if ( $cur_slug_iteration === null ) {
			return $slug_base . '1';
		}

		// otherwise use next iteration
		$next_slug_iteration = (int) $cur_slug_iteration + 1;
		return $slug_base . $next_slug_iteration;
	}

	/**
	 * tidy's the object from wp db into clean array
	 *
	 * @param array $obj (DB obj)
	 *
	 * @return array (clean obj)
	 */
	private function tidy_tag( $obj = false ) {

		$res = false;

		if ( isset( $obj->ID ) ) {
			$res       = array();
			$res['id'] = $obj->ID;
			/*
				`zbs_site` INT NULL DEFAULT NULL,
				`zbs_team` INT NULL DEFAULT NULL,
				`zbs_owner` INT NOT NULL,
			*/

			$res['objtype'] = $obj->zbstag_objtype;
			$res['name']    = $this->stripSlashes( $obj->zbstag_name );
			$res['slug']    = $obj->zbstag_slug;

			$res['created']     = $obj->zbstag_created;
			$res['lastupdated'] = $obj->zbstag_lastupdated;

		}

		return $res;
	}

	/**
	 * tidy's the object from wp db into clean array
	 *
	 * @param array $obj (DB obj)
	 *
	 * @return array (clean obj)
	 */
	private function tidy_tagstat( $obj = false ) {

			$res = false;

		if ( isset( $obj->ID ) ) {
			$res              = array();
			$res['count']     = $obj->c;
			$res['objtypeid'] = $obj->zbstl_objtype;
			$res['objtype']   = $this->objTypeKey( $obj->zbstl_objtype );

		}

		return $res;
	}

	// =========== / TAGS      =======================================================
	// ===============================================================================

	// ===============================================================================
	// ===========   TAG LINKS  =======================================================
	/**
	 * returns tags against an obj type (e.g. contact tags)
	 *
	 * @param array $args   Associative array of arguments
	 *                      objtypeid
	 *
	 * @return array result
	 */
	public function getTagsForObjType( $args = array() ) {

		#} =========== LOAD ARGS ==============
		$defaultArgs = array(

			'objtypeid'    => -1,

			// select
			'excludeEmpty' => -1,
			'excludeIDs'   => -1, // if is an array of tag id's will exclude these :)

			// with
			'withCount'    => -1,

			// sort
			'sortByField'  => 'zbstag_name',
			'sortOrder'    => 'ASC',

			'page'         => 0, // this is what page it is (gets * by for limit)
			'perPage'      => 10000,

			// permissions
			// 'ignoreowner'     => false // this'll let you not-check the owner of obj
			// NOTE 'owner' will ALWAYS be ignored by this, but allows for team/site
			// Tags don't need owners yet :)

		);
		foreach ( $defaultArgs as $argK => $argV ) {
			$$argK = $argV;
			if ( is_array( $args ) && isset( $args[ $argK ] ) ) {
				if ( is_array( $args[ $argK ] ) ) {
					$newData = $$argK;
					if ( ! is_array( $newData ) ) {
						$newData = array();
					} foreach ( $args[ $argK ] as $subK => $subV ) {
						$newData[ $subK ] = $subV;
					}$$argK = $newData;
				} else {
					$$argK = $args[ $argK ]; }
			}
		}
		#} =========== / LOAD ARGS =============

		$ignoreowner = true;

		#} Check ID
		$objtypeid = (int) $objtypeid;
		if ( ! empty( $objtypeid ) && $objtypeid > 0 ) {

			global $ZBSCRM_t, $wpdb;
			$wheres          = array( 'direct' => array() );
			$whereStr        = '';
			$additionalWhere = '';
			$params          = array();
			$res             = array();

			#} ============ EXTRA SELECT ==============

				$extraSelect = '';

			if ( $withCount !== -1 || $excludeEmpty !== -1 ) {

				// could make this distinct zbstl_objid if need more precision
				// NOTE! Ownership leak here - this'll count GLOBALLY! todo: add ownership into this subquery
				$extraSelect = ',(SELECT COUNT(taglink.ID) FROM ' . $ZBSCRM_t['taglinks'] . ' taglink WHERE zbstl_tagid = tags.ID AND zbstl_objtype = %d) tagcount';
				$params[]    = $objtypeid;

			}

			#} ============ / EXTRA SELECT ==============

			#} Build query
			$query = 'SELECT tags.*' . $extraSelect . ' FROM ' . $ZBSCRM_t['tags'] . ' tags';

			#} ============= WHERE ================

				// type id
				$wheres['zbstag_objtype'] = array( 'zbstag_objtype', '=', '%d', $objtypeid );

				// if exclude empty
			if ( $excludeEmpty ) {
				$wheres['direct'][] = array( '(SELECT COUNT(taglink.ID) FROM ' . $ZBSCRM_t['taglinks'] . ' taglink WHERE zbstl_tagid = tags.ID AND zbstl_objtype = %d) > 0', array( $objtypeid ) );

			}

			if ( is_array( $excludeIDs ) ) {

				$checkedExcludedIDs = array();
				foreach ( $excludeIDs as $potentialID ) {
					$pID = (int) $potentialID;
					if ( $pID > 0 && ! in_array( $pID, $checkedExcludedIDs ) ) {
						$checkedExcludedIDs[] = $pID;
					}
				}

				if ( count( $checkedExcludedIDs ) > 0 ) {

					// add exclude ids query part (okay to directly inject here, as validated ints above.)
					$wheres['excludedids'] = array( 'ID', 'NOT IN', '(' . implode( ',', $checkedExcludedIDs ) . ')' );

				}
			}

			#} ============ / WHERE ==============

			#} Build out any WHERE clauses
			$wheresArr = $this->buildWheres( $wheres, $whereStr, $params );
			$whereStr  = $wheresArr['where'];
			$params    = $params + $wheresArr['params'];
			#} / Build WHERE

			#} Ownership v1.0 - the following adds SITE + TEAM checks, and (optionally), owner
			$params = array_merge( $params, $this->ownershipQueryVars( $ignoreowner ) ); // merges in any req.
			$ownQ   = $this->ownershipSQL( $ignoreowner );
			if ( ! empty( $ownQ ) ) {
				$additionalWhere = $this->spaceAnd( $additionalWhere ) . $ownQ; // adds str to query
			}
			#} / Ownership

			#} Append to sql (this also automatically deals with sortby and paging)
			$query .= $this->buildWhereStr( $whereStr, $additionalWhere ) . $this->buildSort( $sortByField, $sortOrder ) . $this->buildPaging( $page, $perPage );

			try {

				#} Prep & run query
				$queryObj     = $this->prepare( $query, $params );
				$potentialRes = $wpdb->get_results( $queryObj, OBJECT );

			} catch ( Exception $e ) {

				#} General SQL Err
				$this->catchSQLError( $e );

			}

			#} Interpret results (Result Set - multi-row)
			if ( isset( $potentialRes ) && is_array( $potentialRes ) && count( $potentialRes ) > 0 ) {

				#} Has results, tidy + return
				foreach ( $potentialRes as $resDataLine ) {

						// tidy
						$resArr = $this->tidy_tag( $resDataLine );

					if ( $withCount !== -1 ) {

						if ( isset( $resDataLine->tagcount ) ) {
							$resArr['count'] = $resDataLine->tagcount;
						} else {
							$resArr['count'] = -1;
						}
					}

						$res[] = $resArr;

				}
			}

			return $res;

		} // / if ID

		return false;
	}
	/**
	 * returns tags against an obj (e.g. contact id 101)
	 *
	 * @param array $args   Associative array of arguments
	 *                      objtypeid, objid
	 *
	 * @return array result
	 */
	public function getTagsForObjID( $args = array() ) {

		#} =========== LOAD ARGS ==============
		$defaultArgs = array(

			'objtypeid' => -1,
			'objid'     => -1,

			// with
			'withCount' => -1,
			'onlyID'    => -1,

			// permissions
			// 'ignoreowner'     => false // this'll let you not-check the owner of obj
			// NOTE 'owner' will ALWAYS be ignored by this, but allows for team/site
			// Tags don't need owners yet :)

		);
		foreach ( $defaultArgs as $argK => $argV ) {
			$$argK = $argV;
			if ( is_array( $args ) && isset( $args[ $argK ] ) ) {
				if ( is_array( $args[ $argK ] ) ) {
					$newData = $$argK;
					if ( ! is_array( $newData ) ) {
						$newData = array();
					} foreach ( $args[ $argK ] as $subK => $subV ) {
						$newData[ $subK ] = $subV;
					}$$argK = $newData;
				} else {
					$$argK = $args[ $argK ]; }
			}
		}
		#} =========== / LOAD ARGS =============

		$ignoreowner = true;

		#} Check ID
		$objtypeid = (int) $objtypeid;
		$objid     = (int) $objid;
		if ( ! empty( $objtypeid ) && $objtypeid > 0 && ! empty( $objid ) && $objid > 0 ) {

			global $ZBSCRM_t, $wpdb;
			$wheres          = array( 'direct' => array() );
			$whereStr        = '';
			$additionalWhere = '';
			$params          = array();
			$res             = array();

			#} Build query
			$query = 'SELECT * FROM ' . $ZBSCRM_t['tags'];

			#} ============= WHERE ================

				#} Add ID
				// rather than using the $wheres, here we have to manually add, because sub queries don't work otherwise.
				$whereStr = ' WHERE ID in (SELECT zbstl_tagid FROM ' . $ZBSCRM_t['taglinks'] . ' WHERE zbstl_objtype = %d AND zbstl_objid = %d)';
				$params[] = $objtypeid;
			$params[]     = $objid;

			#} ============ / WHERE ==============

			#} Build out any WHERE clauses
			$wheresArr = $this->buildWheres( $wheres, $whereStr, $params );
			$whereStr  = $wheresArr['where'];
			$params    = $params + $wheresArr['params'];
			#} / Build WHERE

			#} Ownership v1.0 - the following adds SITE + TEAM checks, and (optionally), owner
			$params = array_merge( $params, $this->ownershipQueryVars( $ignoreowner ) ); // merges in any req.
			$ownQ   = $this->ownershipSQL( $ignoreowner );
			if ( ! empty( $ownQ ) ) {
				$additionalWhere = $this->spaceAnd( $additionalWhere ) . $ownQ; // adds str to query
			}
			#} / Ownership

			#} Append to sql (this also automatically deals with sortby and paging)
			$query .= $this->buildWhereStr( $whereStr, $additionalWhere ) . $this->buildSort( 'ID', 'DESC' ) . $this->buildPaging( 0, 10000 );
			// echo $query; print_r($params);

			try {

				#} Prep & run query
				$queryObj     = $this->prepare( $query, $params );
				$potentialRes = $wpdb->get_results( $queryObj, OBJECT );

			} catch ( Exception $e ) {

				#} General SQL Err
				$this->catchSQLError( $e );

			}

			#} Interpret results (Result Set - multi-row)
			if ( isset( $potentialRes ) && is_array( $potentialRes ) && count( $potentialRes ) > 0 ) {

				#} Has results, tidy + return
				foreach ( $potentialRes as $resDataLine ) {

						#} Only ID? return it directly
					if ( $onlyID === true ) {
						$resObj = $resDataLine->ID;
					} else { // tidy
							$resObj = $this->tidy_tag( $resDataLine );
					}

					if ( $withCount ) {

					}

						$res[] = $resObj;

				}
			}

			return $res;

		} // / if ID

		return false;
	}

	/**
	 * adds or updates a tag link object
	 * this says "match tag X with obj Y" (effectively 'tagging' it)
	 * NOTE: DO NOT CALL DIRECTLY, ALWAYS use addUpdateTagObjLinks (or it's wrappers) - because those fire actions :)
	 *
	 * @param array $args Associative array of arguments
	 *              id (if update - probably never used here), data(objtype,objid,tagid)
	 *
	 * @return int line ID
	 */
	public function addUpdateTagObjLink( $args = array() ) {

		global $ZBSCRM_t, $wpdb;

		#} ============ LOAD ARGS =============
		$defaultArgs = array(

			'id'    => -1,
			'owner' => -1,

			// fields (directly)
			'data'  => array(

				'objtype' => -1,
				'objid'   => -1,
				'tagid'   => -1,

			),

		);
		foreach ( $defaultArgs as $argK => $argV ) {
			$$argK = $argV;
			if ( is_array( $args ) && isset( $args[ $argK ] ) ) {
				if ( is_array( $args[ $argK ] ) ) {
					$newData = $$argK;
					if ( ! is_array( $newData ) ) {
						$newData = array();
					} foreach ( $args[ $argK ] as $subK => $subV ) {
						$newData[ $subK ] = $subV;
					}$$argK = $newData;
				} else {
					$$argK = $args[ $argK ]; }
			}
		}
		#} =========== / LOAD ARGS ============

		#} ========== CHECK FIELDS ============

		// check obtype is completed + legit
		if ( empty( $data['objtype'] ) ) {
			return false;
		}
		if ( $this->objTypeKey( $data['objtype'] ) === -1 ) {
			return false;
		}

		// if owner = -1, add current
		if ( $owner === -1 ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable
			$owner = zeroBSCRM_user();
		}

			$objid = (int) $data['objid'];
		$tagid     = (int) $data['tagid'];
		if ( empty( $data['objid'] ) || $data['objid'] < 1 || empty( $data['tagid'] ) || $data['tagid'] < 1 ) {
			return false;
		}

		#} ========= / CHECK FIELDS ===========

		#} Check if ID present
		$id = (int) $id;
		if ( ! empty( $id ) && $id > 0 ) {

				#} Check if obj exists (here) - for now just brutal update (will error when doesn't exist)

				#} Attempt update
			if ( $wpdb->update(
				$ZBSCRM_t['taglinks'],
				array(

					// ownership
					// no need to update these (as of yet) - can't move teams etc.
					// 'zbs_site' => zeroBSCRM_installSite(),
					// 'zbs_team' => zeroBSCRM_installTeam(),
					'zbs_owner'     => $owner,

					// fields
					'zbstl_objtype' => $data['objtype'],
					'zbstl_objid'   => $data['objid'],
					'zbstl_tagid'   => $data['tagid'],
				),
				array( // where
					'ID' => $id,
				),
				array( // field data types
					'%d',
					'%d',
					'%d',
					'%d',
				),
				array( // where data types
					'%d',
				)
			) !== false ) {

						// Successfully updated - Return id
						return $id;

			} else {

				// FAILED update
				return false;

			}
		} else {

			#} No ID - must be an INSERT
			if ( $wpdb->insert(
				$ZBSCRM_t['taglinks'],
				array(

					// ownership
					'zbs_site'      => zeroBSCRM_site(),
					'zbs_team'      => zeroBSCRM_team(),
					'zbs_owner'     => $owner,

					// fields
					'zbstl_objtype' => $data['objtype'],
					'zbstl_objid'   => $data['objid'],
					'zbstl_tagid'   => $data['tagid'],
				),
				array( // field data types
					'%d',  // site
					'%d',  // team
					'%d',  // owner

					'%d',
					'%d',
					'%d',
				)
			) > 0 ) {

					#} Successfully inserted, lets return new ID
					$newID = $wpdb->insert_id;
					return $newID;

			} else {

				#} Failed to Insert
				return false;

			}
		}

		return false;
	}

	/**
	 * adds or updates tag link objects against an obj
	 * this says "match tag X,Y,Z with obj Y" (effectively 'tagging' it)
	 *
	 * @param array $args Associative array of arguments
	 *              objtype,objid,tags (array of tagids)
	 *
	 * @return array $tags
	 */
	public function addUpdateTagObjLinks( $args = array() ) {

		global $ZBSCRM_t, $wpdb;

		#} ============ LOAD ARGS =============
		$defaultArgs = array(

			'owner'   => -1,

			'objtype' => -1,
			'objid'   => -1,
			'tagIDs'  => -1, // array of tag ID's

			'mode'    => 'replace', // replace|append|remove

		);
		foreach ( $defaultArgs as $argK => $argV ) {
			$$argK = $argV;
			if ( is_array( $args ) && isset( $args[ $argK ] ) ) {
				if ( is_array( $args[ $argK ] ) ) {
					$newData = $$argK;
					if ( ! is_array( $newData ) ) {
						$newData = array();
					} foreach ( $args[ $argK ] as $subK => $subV ) {
						$newData[ $subK ] = $subV;
					}$$argK = $newData;
				} else {
					$$argK = $args[ $argK ]; }
			}
		}
		#} =========== / LOAD ARGS ============

		#} ========== CHECK FIELDS ============

		// check obtype is completed + legit
		if ( empty( $objtype ) ) {
			return false;
		}
		if ( $this->objTypeKey( $objtype ) === -1 ) {
			return false;
		}

		// if owner = -1, add current
		if ( $owner === -1 ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable
			$owner = zeroBSCRM_user();
		}

			// tagging id
			$objid = (int) $objid;
		if ( empty( $objid ) || $objid < 1 ) {
			return false;
		}

			// tag list
		if ( ! is_array( $tagIDs ) ) {
			return false;
		}

			// mode
		if ( gettype( $mode ) != 'string' || ! in_array( $mode, array( 'replace', 'append', 'remove' ) ) ) {
			return false;
		}

		#} ========= / CHECK FIELDS ===========

		switch ( $mode ) {

			case 'replace':
				// (for actions) log starting objs
				$existingTagIDs = $this->getTagsForObjID(
					array(
						'objtypeid' => $objtype,
						'objid'     => $objid,
						'onlyID'    => true,
					)
				);
				if ( ! is_array( $existingTagIDs ) ) {
					$existingTagIDs = array();
				}
				$removedTagsByID = array();
				$addedTagsByID   = array();

				// cull all previous
				$deleted = $this->deleteTagObjLinks(
					array(
						'objid'   => $objid,
						'objtype' => $objtype,
					)
				);

				// cycle through & add
				foreach ( $tagIDs as $tid ) {

					$added = $this->addUpdateTagObjLink(
						array(
							'data' => array(
								'objid'   => $objid,
								'objtype' => $objtype,
								'tagid'   => $tid,
							),
						)
					);

					if ( $added !== false ) {

						if ( ! in_array( $tid, $existingTagIDs ) ) {
							$addedTagsByID[] = $tid; // tag was added
						}
						// else
							// tag was already in there, just re-added
					}
				}

				// actions

					// check removed
				foreach ( $existingTagIDs as $tid ) {

					if ( ! in_array( $tid, $tagIDs ) ) {
						$removedTagsByID[] = $tid;
					}
				}

					// fire actions for each tag

						// added to
				if ( count( $addedTagsByID ) > 0 ) {
					foreach ( $addedTagsByID as $tagID ) {
						do_action( 'zbs_tag_added_to_objid', $tagID, $objtype, $objid );
					}
				}

						// removed from
				if ( count( $removedTagsByID ) > 0 ) {
					foreach ( $removedTagsByID as $tagID ) {
						do_action( 'zbs_tag_removed_from_objid', $tagID, $objtype, $objid );
					}
				}

						// return
				return true;

				break;

			case 'append':
				// get existing
				$existingTagIDs = $this->getTagsForObjID(
					array(
						'objtypeid' => $objtype,
						'objid'     => $objid,
						'onlyID'    => true,
					)
				);

				// make just ids
				// no need, added ,'onlyID'=>true above
				// $existingTagIDs = array(); foreach ($tags as $t) $existingTagIDs[] = $t['id'];

				// cycle through& add
				foreach ( $tagIDs as $tid ) {

					if ( ! in_array( $tid, $existingTagIDs ) ) {

						// add a link
						$this->addUpdateTagObjLink(
							array(
								'data' => array(
									'objid'   => $objid,
									'objtype' => $objtype,
									'tagid'   => $tid,
								),
							)
						);

						// fire action
						do_action( 'zbs_tag_added_to_objid', $tid, $objtype, $objid );

					}
				}

				$this->compile_segments_from_tagIDs( $tagIDs, $owner ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase,VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable

				return true;

				break;

			case 'remove':
				// get existing
				$existingTagIDs = $this->getTagsForObjID(
					array(
						'objtypeid' => $objtype,
						'objid'     => $objid,
						'onlyID'    => true,
					)
				);

				// cycle through & remove links
				foreach ( $tagIDs as $tid ) {

					if ( in_array( $tid, $existingTagIDs ) ) {

						// delete link
						$this->deleteTagObjLink(
							array(
								'objid'   => $objid,
								'objtype' => $objtype,
								'tagid'   => $tid,
							)
						);

						// action
						do_action( 'zbs_tag_removed_from_objid', $tid, $objtype, $objid );

					}
				}

				$this->compile_segments_from_tagIDs( $tagIDs, $owner ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase,VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable

				return true;

				break;

		}

		return false;
	}

	/**
	 * Compiles segments based on an array of given tag IDs
	 *
	 * @param array $tagIDs An array of tag IDs.
	 * @param ID    $owner An ID representing the owner of the current tagID.
	 */
	public function compile_segments_from_tagIDs( $tagIDs, $owner ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
		global $zbs;
		$segments = $zbs->DAL->segments->getSegments( $owner, 1000, 0, true ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
		foreach ( $segments as $segment ) {
			foreach ( $segment['conditions'] as $condition ) {
				if ( $condition['type'] === 'tagged' && in_array( $condition['value'], $tagIDs ) ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase,WordPress.PHP.StrictInArray.MissingTrueStrict
					$zbs->DAL->segments->compileSegment( $segment['id'] ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
				}
			}
		}
	}

	/**
	 * deletes a tag object link
	 *
	 * @param array $args Associative array of arguments
	 *              id
	 *
	 * @return int success;
	 */
	public function deleteTagObjLink( $args = array() ) {

		global $ZBSCRM_t, $wpdb;

		#} ============ LOAD ARGS =============
		$defaultArgs = array(

			'id'      => -1,

			// or...

			'objtype' => -1,
			'objid'   => -1,
			'tagid'   => -1,

		);
		foreach ( $defaultArgs as $argK => $argV ) {
			$$argK = $argV;
			if ( is_array( $args ) && isset( $args[ $argK ] ) ) {
				if ( is_array( $args[ $argK ] ) ) {
					$newData = $$argK;
					if ( ! is_array( $newData ) ) {
						$newData = array();
					} foreach ( $args[ $argK ] as $subK => $subV ) {
						$newData[ $subK ] = $subV;
					}$$argK = $newData;
				} else {
					$$argK = $args[ $argK ]; }
			}
		}
		#} =========== / LOAD ARGS ============

		#} Check ID & Delete :) (IF ID PRESENT)
		$id = (int) $id;
		if ( $id > 0 ) {
			return zeroBSCRM_db2_deleteGeneric( $id, 'taglinks' );
		}

		#} ... else delete by objtype etc.

		#} ========== CHECK FIELDS ============

		// check obtype is completed + legit
		if ( empty( $objtype ) ) {
			return false;
		}
		if ( $this->objTypeKey( $objtype ) === -1 ) {
			return false;
		}

			// obj id
			$objid = (int) $objid;
		if ( empty( $objid ) || $objid < 1 ) {
			return false;
		}

			// tag id
			$tagid = (int) $tagid;
		if ( empty( $tagid ) || $tagid < 1 ) {
			return false;
		}

			// CHECK PERMISSIONS?

		#} ========= / CHECK FIELDS ===========

			#} ... if here then is trying to delete specific tag linkid
			return $wpdb->delete(
				$ZBSCRM_t['taglinks'],
				array( // where
					'zbstl_objtype' => $objtype,
					'zbstl_objid'   => $objid,
					'zbstl_tagid'   => $tagid,
				),
				array(
					'%d',
					'%d',
					'%d',
				)
			);
	}

	/**
	 * deletes all tag object links for a specific obj
	 *
	 * @param array $args Associative array of arguments
	 *              id
	 *
	 * @return int success;
	 */
	public function deleteTagObjLinks( $args = array() ) {

		global $ZBSCRM_t, $wpdb;

		#} ============ LOAD ARGS =============
		$defaultArgs = array(

			'objtype' => -1,
			'objid'   => -1,

		);
		foreach ( $defaultArgs as $argK => $argV ) {
			$$argK = $argV;
			if ( is_array( $args ) && isset( $args[ $argK ] ) ) {
				if ( is_array( $args[ $argK ] ) ) {
					$newData = $$argK;
					if ( ! is_array( $newData ) ) {
						$newData = array();
					} foreach ( $args[ $argK ] as $subK => $subV ) {
						$newData[ $subK ] = $subV;
					}$$argK = $newData;
				} else {
					$$argK = $args[ $argK ]; }
			}
		}
		#} =========== / LOAD ARGS ============

		#} ========== CHECK FIELDS ============

		// check obtype is completed + legit
		if ( empty( $objtype ) ) {
			return false;
		}
		if ( $this->objTypeKey( $objtype ) === -1 ) {
			return false;
		}

			// obj id
			$objid = (int) $objid;
		if ( empty( $objid ) || $objid < 1 ) {
			return false;
		}

			// CHECK PERMISSIONS?

		#} ========= / CHECK FIELDS ===========

		// brutal
		return $wpdb->delete(
			$ZBSCRM_t['taglinks'],
			array( // where
				'zbstl_objtype' => $objtype,
				'zbstl_objid'   => $objid,
			),
			array(
				'%d',
				'%d',
			)
		);
	}

	/**
	 * tidy's the object from wp db into clean array
	 *
	 * @param array $obj (DB obj)
	 *
	 * @return array (clean obj)
	 */
	private function tidy_taglink( $obj = false ) {

			$res = false;

		if ( isset( $obj->ID ) ) {
			$res       = array();
			$res['id'] = $obj->ID;
			/*
			`zbs_site` INT NULL DEFAULT NULL,
			`zbs_team` INT NULL DEFAULT NULL,
			`zbs_owner` INT NOT NULL,
			*/

			$res['objtype'] = $obj->zbstag_objtype;
			$res['name']    = $this->stripSlashes( $obj->zbstag_name );
			$res['slug']    = $obj->zbstag_slug;

			$res['created']     = $obj->zbstag_created;
			$res['lastupdated'] = $obj->zbstag_lastupdated;

		}

		return $res;
	}

	// =========== / TAG LINKS      ==================================================
	// ===============================================================================

	// ===============================================================================
	// ===========   CUSTOM FIELDS   =================================================

	/**
	 * returns true if field key exists as custom field for CONTACT
	 *
	 * @param array $args Associative array of arguments
	 *              objtypeid
	 *
	 * @return array of customfield field keys
	 */
	public function isActiveCustomField_Contact( $customFieldKey = '' ) {

		#} These are simply stored in settings with a key of customfields_objtype e.g. customfields_contact
		if ( ! empty( $objtypeid ) && $objtypeid > 0 && ! empty( $customFieldKey ) ) {

			// get custom fields
			$customFields = $this->getActiveCustomFields( array( 'objtypeid' => ZBS_TYPE_CONTACT ) );

			// validate there
			if ( is_array( $customFields ) ) {
				foreach ( $customFields as $cfK => $cfV ) {

					if ( $cfK == $customFieldKey ) {
						return true;
					}
				}
			}
		}

		return false;
	}

	/**
	 * returns true if field key exists as custom field for obj
	 *
	 * @param array $args Associative array of arguments
	 *              objtypeid
	 *
	 * @return array of customfield field keys
	 */
	public function isActiveCustomField( $args = array() ) {

		#} ============ LOAD ARGS =============
		$defaultArgs = array(

			'objtypeid'      => -1,
			'customFieldKey' => '',

		);
		foreach ( $defaultArgs as $argK => $argV ) {
			$$argK = $argV;
			if ( is_array( $args ) && isset( $args[ $argK ] ) ) {
				if ( is_array( $args[ $argK ] ) ) {
					$newData = $$argK;
					if ( ! is_array( $newData ) ) {
						$newData = array();
					} foreach ( $args[ $argK ] as $subK => $subV ) {
						$newData[ $subK ] = $subV;
					}$$argK = $newData;
				} else {
					$$argK = $args[ $argK ]; }
			}
		}
		#} =========== / LOAD ARGS =============

		#} These are simply stored in settings with a key of customfields_objtype e.g. customfields_contact
		if ( ! empty( $objtypeid ) && $objtypeid > 0 && ! empty( $customFieldKey ) ) {

			// get custom fields
			$customFields = $this->getActiveCustomFields( array( 'objtypeid' => $objtypeid ) );

			// validate there
			if ( is_array( $customFields ) ) {
				foreach ( $customFields as $cfK => $cfV ) {

					if ( $cfK == $customFieldKey ) {
						return true;
					}
				}
			}
		}

		return false;
	}

	/**
	 * returns active custom field keys for an obj type
	 *
	 * @param array $args Associative array of arguments
	 *              objtypeid
	 *
	 * @return array of customfield field keys
	 */
	public function getActiveCustomFields( $args = array() ) {

		#} ============ LOAD ARGS =============
		$defaultArgs = array(

			// object type id
			'objtypeid'     => -1,

			// whether or not to accept cached variant.
			// Added in gh-2019, we often recall this function on one load, this allows us to accept a once-loaded version
			'accept_cached' => true,

		);
		foreach ( $defaultArgs as $argK => $argV ) {
			$$argK = $argV;
			if ( is_array( $args ) && isset( $args[ $argK ] ) ) {
				if ( is_array( $args[ $argK ] ) ) {
					$newData = $$argK;
					if ( ! is_array( $newData ) ) {
						$newData = array();
					} foreach ( $args[ $argK ] as $subK => $subV ) {
						$newData[ $subK ] = $subV;
					}$$argK = $newData;
				} else {
					$$argK = $args[ $argK ]; }
			}
		}
		#} =========== / LOAD ARGS =============

		#} These are simply stored in settings with a key of customfields_objtype e.g. customfields_contact
		if ( ! empty( $objtypeid ) && $objtypeid > 0 ) {

			// retrieve
			return $this->setting( 'customfields_' . $this->objTypeKey( $objtypeid ), array(), $accept_cached );

		}

		return array();
	}

	/**
	 * updates active custom field keys for an obj type
	 * No checking whatsoever
	 *
	 * @param array $args Associative array of arguments
	 *              objtypeid
	 *
	 * @return array of customfield field keys
	 */
	public function updateActiveCustomFields( $args = array() ) {

		#} ============ LOAD ARGS =============
		$defaultArgs = array(

			'objtypeid' => -1,
			'fields'    => array(),

		);
		foreach ( $defaultArgs as $argK => $argV ) {
			$$argK = $argV;
			if ( is_array( $args ) && isset( $args[ $argK ] ) ) {
				if ( is_array( $args[ $argK ] ) ) {
					$newData = $$argK;
					if ( ! is_array( $newData ) ) {
						$newData = array();
					} foreach ( $args[ $argK ] as $subK => $subV ) {
						$newData[ $subK ] = $subV;
					}$$argK = $newData;
				} else {
					$$argK = $args[ $argK ]; }
			}
		}
		#} =========== / LOAD ARGS =============

		#} These are simply stored in settings with a key of customfields_objtype e.g. customfields_contact
		if ( ! empty( $objtypeid ) && $objtypeid > 0 ) {

			return $this->updateSetting( 'customfields_' . $this->objTypeKey( $objtypeid ), $fields );

		}

		return array();
	}

	/**
	 * returns scalar value of 1 custom field line (or it's ID)
	 * ... real custom fields will be got as part of getCustomers more commonly (this is for 1 alone)
	 *
	 * @param array $args   Associative array of arguments
	 *                      objtypeid,objid,objkey
	 *
	 * @return array result
	 */
	public function getCustomFieldVal( $args = array() ) {

		#} =========== LOAD ARGS ==============
		$defaultArgs = array(

			'objtypeid'   => -1, // e.g. 1 = contact
			'objid'       => -1, // e.g. contact #101
			'objkey'      => '', // e.g. notes

			// permissions
			'ignoreowner' => false, // this'll let you not-check the owner of obj

			// returns scalar ID of line
			'onlyID'      => false,

		);
		foreach ( $defaultArgs as $argK => $argV ) {
			$$argK = $argV;
			if ( is_array( $args ) && isset( $args[ $argK ] ) ) {
				if ( is_array( $args[ $argK ] ) ) {
					$newData = $$argK;
					if ( ! is_array( $newData ) ) {
						$newData = array();
					} foreach ( $args[ $argK ] as $subK => $subV ) {
						$newData[ $subK ] = $subV;
					}$$argK = $newData;
				} else {
					$$argK = $args[ $argK ]; }
			}
		}
		#} =========== / LOAD ARGS =============

		#} Check IDs
		$objtypeid = (int) $objtypeid;
		$objid     = (int) $objid;
		if ( ! empty( $objtypeid ) && $objtypeid > 0 && ! empty( $objid ) && $objid > 0 && ! empty( $objkey ) ) {

			global $ZBSCRM_t, $wpdb;
			$wheres          = array( 'direct' => array() );
			$whereStr        = '';
			$additionalWhere = '';
			$params          = array();
			$res             = array();

			#} Build query
			$query = 'SELECT ID,zbscf_objval FROM ' . $ZBSCRM_t['customfields'];

			#} ============= WHERE ================

				#} Add obj type
				$wheres['zbscf_objtype'] = array( 'zbscf_objtype', '=', '%d', $objtypeid );

				#} Add obj ID
				$wheres['zbscf_objid'] = array( 'zbscf_objid', '=', '%d', $objid );

				#} Add obj key
				$wheres['zbscf_objkey'] = array( 'zbscf_objkey', '=', '%s', $objkey );

			#} ============ / WHERE ==============

			#} Build out any WHERE clauses
			$wheresArr = $this->buildWheres( $wheres, $whereStr, $params );
			$whereStr  = $wheresArr['where'];
			$params    = $params + $wheresArr['params'];
			#} / Build WHERE

			#} Ownership v1.0 - the following adds SITE + TEAM checks, and (optionally), owner
			$params = array_merge( $params, $this->ownershipQueryVars( $ignoreowner ) ); // merges in any req.
			$ownQ   = $this->ownershipSQL( $ignoreowner );
			if ( ! empty( $ownQ ) ) {
				$additionalWhere = $this->spaceAnd( $additionalWhere ) . $ownQ; // adds str to query
			}
			#} / Ownership

			#} Append to sql (this also automatically deals with sortby and paging)
			$query .= $this->buildWhereStr( $whereStr, $additionalWhere ) . $this->buildSort( 'ID', 'DESC' ) . $this->buildPaging( 0, 1 );

			try {

				#} Prep & run query
				$queryObj     = $this->prepare( $query, $params );
				$potentialRes = $wpdb->get_row( $queryObj, OBJECT );

			} catch ( Exception $e ) {

				#} General SQL Err
				$this->catchSQLError( $e );

			}

			#} Interpret Results (ROW)
			if ( isset( $potentialRes ) && isset( $potentialRes->ID ) ) {

				#} Has results, tidy + return

					#} Only ID? return it directly
				if ( $onlyID === true ) {
					return $potentialRes->ID;
				}

					// tidy
					$res = $this->tidy_customfieldvalSingular( $potentialRes );

					return $res;

			}
		} // / if ID

		return false;
	}

	/**
	 * adds or updates a customfield object
	 * NOTE: because these are specific to unique ID of obj, there's no need for site/team etc. here
	 *
	 * @param array $args Associative array of arguments
	 *              id (if update), owner, data (array of field data)
	 *
	 * @return int line ID
	 */
	public function addUpdateCustomField( $args = array() ) {

		global $ZBSCRM_t, $wpdb;

		#} ============ LOAD ARGS =============
		$defaultArgs = array(

			'id'    => -1, // Custom field line ID (not obj id!)
			'owner' => -1,

			// fields (directly)
			'data'  => array(
				'objtype' => -1,
				'objid'   => -1,
				'objkey'  => '',
				'objval'  => 'NULL',
			),

		);
		foreach ( $defaultArgs as $argK => $argV ) {
			$$argK = $argV;
			if ( is_array( $args ) && isset( $args[ $argK ] ) ) {
				if ( is_array( $args[ $argK ] ) ) {
					$newData = $$argK;
					if ( ! is_array( $newData ) ) {
						$newData = array();
					} foreach ( $args[ $argK ] as $subK => $subV ) {
						$newData[ $subK ] = $subV;
					}$$argK = $newData;
				} else {
					$$argK = $args[ $argK ]; }
			}
		}
		#} =========== / LOAD ARGS ============

		#} ========== CHECK FIELDS ============

			$id = (int) $id;

		if ( ! empty( $data['objid'] ) ) { // phpcs:ignore -- PHPCS chokes on this line, but the var does exist.
			$data['objid'] = (int) $data['objid']; // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable
		}
		if ( isset( $data['objtype'] ) ) {
			$data['objtype'] = (int) $data['objtype'];
		}

			// check obtype is completed + legit
		if ( ! isset( $data['objtype'] ) || empty( $data['objtype'] ) ) {
			return false;
		}
		if ( $this->objTypeKey( $data['objtype'] ) === -1 ) {
			return false;
		}

			// check key + ID present
		if ( ! isset( $data['objkey'] ) || empty( $data['objkey'] ) ) {
			return false;
		}
		if ( $data['objid'] <= 0 ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable
			return false;
		}

		// if owner = -1, add current
		if ( $owner === -1 ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable
			$owner = zeroBSCRM_user();
		}

			// ID finder - if obj id +  key + val + typeid provided, check CF not already present (if so overwrite)
		if ( ( empty( $id ) || $id <= 0 )
				&&
				( isset( $data['objtype'] ) && ! empty( $data['objtype'] ) )
				&&
				( isset( $data['objid'] ) && ! empty( $data['objid'] ) )
				&&
				( isset( $data['objkey'] ) && ! empty( $data['objkey'] ) ) ) {

			// check existence + return ID
			$potentialID = (int) $this->getCustomFieldVal(
				array(
					'objtypeid'   => $data['objtype'],
					'objid'       => $data['objid'],
					'objkey'      => $data['objkey'],
					'onlyID'      => true,
					'ignoreowner' => true,
				)
			);
			// override empty ID
			if ( ! empty( $potentialID ) && $potentialID > 0 ) {
				$id = $potentialID;
			}
		}

			// handle radio, select, and checkbox fields
		if ( is_array( $data['objval'] ) ) {
			$data['objval'] = implode( ',', $data['objval'] );
		}

		#} ========= / CHECK FIELDS ===========

		#} Check if ID present
		if ( ! empty( $id ) && $id > 0 ) {

				#} Check if obj exists (here) - for now just brutal update (will error when doesn't exist)

				#} Attempt update
			if ( $wpdb->update(
				$ZBSCRM_t['customfields'],
				array(

					// ownership
					// no need to update these (as of yet) - can't move teams etc.
					// 'zbs_site' => zeroBSCRM_installSite(),
					// 'zbs_team' => zeroBSCRM_installTeam(),
					'zbs_owner'         => $owner,

					// fields
					'zbscf_objtype'     => $data['objtype'],
					'zbscf_objid'       => $data['objid'],
					'zbscf_objkey'      => $data['objkey'],
					'zbscf_objval'      => $data['objval'],

					// 'zbscf_created' => time(),
					'zbscf_lastupdated' => time(),
				),
				array( // where
					'ID' => $id,
				),
				array( // field data types
							// '%d',  // site
							// '%d',  // team
					'%d',  // owner

					'%d',
					'%d',
					'%s',
					'%s',

					'%d', // last updated
				),
				array( // where data types
					'%d',
				)
			) !== false ) {

						// Successfully updated - Return id
						return $id;

			} else {

				// FAILED update
				return false;

			}
		} else {

			#} No ID - must be an INSERT
			if ( $wpdb->insert(
				$ZBSCRM_t['customfields'],
				array(

					// ownership
					'zbs_site'          => zeroBSCRM_site(),
					'zbs_team'          => zeroBSCRM_team(),
					'zbs_owner'         => $owner,

					// fields
					'zbscf_objtype'     => $data['objtype'],
					'zbscf_objid'       => $data['objid'],
					'zbscf_objkey'      => $data['objkey'],
					'zbscf_objval'      => $data['objval'],

					'zbscf_created'     => time(),
					'zbscf_lastupdated' => time(),
				),
				array( // field data types
					'%d',  // site
					'%d',  // team
					'%d',  // owner

					'%d',
					'%d',
					'%s',
					'%s',

					'%d',
					'%d',
				)
			) > 0 ) {

					#} Successfully inserted, lets return new ID
					$newID = $wpdb->insert_id;
					return $newID;

			} else {

				#} Failed to Insert
				return false;

			}
		}

		return false;
	}

	/**
	 * deletes a customfield object
	 *
	 * @param array $args Associative array of arguments
	 *              id
	 *
	 * @return int success;
	 */
	public function deleteCustomField( $args = array() ) {

		global $ZBSCRM_t, $wpdb;

		#} ============ LOAD ARGS =============
		$defaultArgs = array(

			'id' => -1,

		);
		foreach ( $defaultArgs as $argK => $argV ) {
			$$argK = $argV;
			if ( is_array( $args ) && isset( $args[ $argK ] ) ) {
				if ( is_array( $args[ $argK ] ) ) {
					$newData = $$argK;
					if ( ! is_array( $newData ) ) {
						$newData = array();
					} foreach ( $args[ $argK ] as $subK => $subV ) {
						$newData[ $subK ] = $subV;
					}$$argK = $newData;
				} else {
					$$argK = $args[ $argK ]; }
			}
		}
		#} =========== / LOAD ARGS ============

		#} Check ID & Delete :)
		$id = (int) $id;
		return zeroBSCRM_db2_deleteGeneric( $id, 'customfields' );
	}

	/**
	 * tidy's the object from wp db into clean array
	 *
	 * @param array $obj (DB obj)
	 *
	 * @return array (clean obj)
	 */
	private function tidy_customfieldvalSingular( $obj = false ) {

		$res = false;

		if ( isset( $obj->ID ) ) {

			// just return the value here!
			$res = $this->stripSlashes( $obj->zbscf_objval );

		}

		return $res;
	}

	// =========== / CUSTOM FIELDS     ===============================================
	// ===============================================================================

	// ===============================================================================
	// ===========   EXTERNAL SOURCES  ===============================================
	/**
	 * returns first external source line +- details
	 *
	 * @param int id        tag id
	 * @param array                $args   Associative array of arguments
	 *                                     withStats
	 *
	 * @return array result
	 */
	public function getExternalSource( $id = -1, $args = array() ) {

		#} =========== LOAD ARGS ==============
		$defaultArgs = array(

			// Alternative search criteria to ID :)
			// .. LEAVE blank if using ID
			// 'contactID'         => -1, // NOTE: This only returns the FIRST source, if using multiple sources, use getExternalSourcesForContact
			'objectID'    => -1,
			'objectType'  => -1,
			'source'      => -1, // Optional, if used with contact ID will return 1 line :D
			'origin'      => '', // Optional

			// permissions
			'ignoreowner' => true, // this'll let you not-check the owner of obj

			// returns scalar ID of line
			'onlyID'      => false,

		);
		foreach ( $defaultArgs as $argK => $argV ) {
			$$argK = $argV;
			if ( is_array( $args ) && isset( $args[ $argK ] ) ) {
				if ( is_array( $args[ $argK ] ) ) {
					$newData = $$argK;
					if ( ! is_array( $newData ) ) {
						$newData = array();
					} foreach ( $args[ $argK ] as $subK => $subV ) {
						$newData[ $subK ] = $subV;
					}$$argK = $newData;
				} else {
					$$argK = $args[ $argK ]; }
			}
		}
		#} =========== / LOAD ARGS =============

		#} ========== CHECK FIELDS ============

			$id         = (int) $id;
			$objectID   = (int) $objectID;
			$objectType = (int) $objectType;

		#} ========= / CHECK FIELDS ===========

		#} Check ID or name/type
		if (
			$objectType > 0 &&
				(
					( ! empty( $id ) && $id > 0 )
					||
					( ! empty( $objectID ) && $objectID > 0 )
				)
			) {

			global $ZBSCRM_t, $wpdb;
			$wheres          = array( 'direct' => array() );
			$whereStr        = '';
			$additionalWhere = '';
			$params          = array();
			$res             = array();

			#} Build query
			$query = 'SELECT * FROM ' . $ZBSCRM_t['externalsources'];

			#} ============= WHERE ================

				// Line ID
			if ( ! empty( $id ) && $id > 0 ) {
				$wheres['ID'] = array( 'ID', '=', '%d', $id );
			}

				// Object ID
			if ( ! empty( $objectID ) && $objectID > 0 ) {
				$wheres['zbss_objid'] = array( 'zbss_objid', '=', '%d', $objectID );
			}

					$wheres['zbss_objtype'] = array( 'zbss_objtype', '=', '%d', $objectType );

				// Source
			if ( ! empty( $source ) && $source !== -1 ) {
				$wheres['zbss_source'] = array( 'zbss_source', '=', '%s', $source );
			}

				// Origin
			if ( ! empty( $origin ) ) {
				$wheres['zbss_origin'] = array( 'zbss_origin', '=', '%s', $origin );
			}

			#} ============ / WHERE ==============

			#} Build out any WHERE clauses
			$wheresArr = $this->buildWheres( $wheres, $whereStr, $params );
			$whereStr  = $wheresArr['where'];
			$params    = $params + $wheresArr['params'];
			#} / Build WHERE

			#} Ownership v1.0 - the following adds SITE + TEAM checks, and (optionally), owner
			$params = array_merge( $params, $this->ownershipQueryVars( $ignoreowner ) ); // merges in any req.
			$ownQ   = $this->ownershipSQL( $ignoreowner );
			if ( ! empty( $ownQ ) ) {
				$additionalWhere = $this->spaceAnd( $additionalWhere ) . $ownQ; // adds str to query
			}
			#} / Ownership

			#} Append to sql (this also automatically deals with sortby and paging)
			$query .= $this->buildWhereStr( $whereStr, $additionalWhere ) . $this->buildSort( 'ID', 'DESC' ) . $this->buildPaging( 0, 1 );

			try {

				#} Prep & run query
				$queryObj     = $this->prepare( $query, $params );
				$potentialRes = $wpdb->get_row( $queryObj, OBJECT );

			} catch ( Exception $e ) {

				#} General SQL Err
				$this->catchSQLError( $e );

			}

			#} Interpret Results (ROW)
			if ( isset( $potentialRes ) && isset( $potentialRes->ID ) ) {

				#} Has results, tidy + return

					#} Only ID? return it directly
				if ( $onlyID === true ) {
					return $potentialRes->ID;
				}

					// tidy
					$res = $this->tidy_externalsource( $potentialRes );

					return $res;

			}
		} // / if ID

		return false;
	}

	/**
	 * returns multiple external source line +- details
	 *
	 * @param int id        tag id
	 * @param array                $args   Associative array of arguments
	 *                                     withStats
	 *
	 * @return array results
	 */
	public function getExternalSources( $id = -1, $args = array() ) {

		#} =========== LOAD ARGS ==============
		$defaultArgs = array(

			'objectID'          => -1,
			'objectType'        => -1,

			'grouped_by_source' => false, // if true, will return array organised by source (e.g. array('woo'=>array({woosources})))

			// permissions
			'ignoreowner'       => true, // this'll let you not-check the owner of obj

		);
		foreach ( $defaultArgs as $argK => $argV ) {
			$$argK = $argV;
			if ( is_array( $args ) && isset( $args[ $argK ] ) ) {
				if ( is_array( $args[ $argK ] ) ) {
					$newData = $$argK;
					if ( ! is_array( $newData ) ) {
						$newData = array();
					} foreach ( $args[ $argK ] as $subK => $subV ) {
						$newData[ $subK ] = $subV;
					}$$argK = $newData;
				} else {
					$$argK = $args[ $argK ]; }
			}
		}
		#} =========== / LOAD ARGS =============

		#} ========== CHECK FIELDS ============

			$id         = (int) $id;
			$objectID   = (int) $objectID;
			$objectType = (int) $objectType;

		#} ========= / CHECK FIELDS ===========

		#} Check ID or name/type
		if (
			$objectType > 0 &&
				(
					( ! empty( $id ) && $id > 0 )
					||
					( ! empty( $objectID ) && $objectID > 0 )
				)
			) {

			global $ZBSCRM_t, $wpdb;
			$wheres          = array( 'direct' => array() );
			$whereStr        = '';
			$additionalWhere = '';
			$params          = array();
			$res             = array();

			#} Build query
			$query = 'SELECT * FROM ' . $ZBSCRM_t['externalsources'];

			#} ============= WHERE ================

				#} Add ID
			if ( ! empty( $id ) && $id > 0 ) {
				$wheres['ID'] = array( 'ID', '=', '%d', $id );
			}

				#} zbss_objid
			if ( ! empty( $objectID ) && $objectID > 0 ) {
				$wheres['zbss_objid'] = array( 'zbss_objid', '=', '%d', $objectID );
			}

				// zbss_objid
				$wheres['zbss_objtype'] = array( 'zbss_objtype', '=', '%d', $objectType ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase

			#} ============ / WHERE ==============

			#} Build out any WHERE clauses
			$wheresArr = $this->buildWheres( $wheres, $whereStr, $params );
			$whereStr  = $wheresArr['where'];
			$params    = $params + $wheresArr['params'];
			#} / Build WHERE

			#} Ownership v1.0 - the following adds SITE + TEAM checks, and (optionally), owner
			$params = array_merge( $params, $this->ownershipQueryVars( $ignoreowner ) ); // merges in any req.
			$ownQ   = $this->ownershipSQL( $ignoreowner );
			if ( ! empty( $ownQ ) ) {
				$additionalWhere = $this->spaceAnd( $additionalWhere ) . $ownQ; // adds str to query
			}
			#} / Ownership

			#} Append to sql (this also automatically deals with sortby and paging)
			$query .= $this->buildWhereStr( $whereStr, $additionalWhere ) . $this->buildSort( 'ID', 'DESC' ) . $this->buildPaging( 0, 1000 );

			try {

				#} Prep & run query
				$queryObj     = $this->prepare( $query, $params );
				$potentialRes = $wpdb->get_results( $queryObj, OBJECT );

			} catch ( Exception $e ) {

				#} General SQL Err
				$this->catchSQLError( $e );

			}

			$res = array();

			#} Interpret results (Result Set - multi-row)
			if ( isset( $potentialRes ) && is_array( $potentialRes ) && count( $potentialRes ) > 0 ) {

				#} Has results, tidy + return
				foreach ( $potentialRes as $data_line ) {

					// default simple array
					if ( ! $grouped_by_source ) {

						// tidy
						$res[] = $this->tidy_externalsource( $data_line );

					} else {

						$tidied_line = $this->tidy_externalsource( $data_line );

						// grouped by source adds another dimension to array, keyed by source (e.g. 'woo')
						if ( ! isset( $res[ $tidied_line['source'] ] ) ) {

							$res[ $tidied_line['source'] ] = array();

						}

						$res[ $tidied_line['source'] ][] = $tidied_line;

					}
				}
			}

			return $res;

		} // / if ID

		return false;
	}

	/**
	 * adds or updates an external source object
	 *
	 * @param array $args Associative array of arguments
	 *
	 * @return int line ID
	 */
	public function addUpdateExternalSource( $args = array() ) {

		global $ZBSCRM_t, $wpdb;

		#} ============ LOAD ARGS =============
		$defaultArgs = array(

			'id'   => -1,

			// fields (directly)
			'data' => array(

				'objectType' => -1,
				'objectID'   => -1,
				'source'     => '',
				'uid'        => '',
				'origin'     => '',
				'owner'      => 0, // -1 for current user, for now, disregard owenship for ext sources

			),

		);
		foreach ( $defaultArgs as $argK => $argV ) {
			$$argK = $argV;
			if ( is_array( $args ) && isset( $args[ $argK ] ) ) {
				if ( is_array( $args[ $argK ] ) ) {
					$newData = $$argK;
					if ( ! is_array( $newData ) ) {
						$newData = array();
					} foreach ( $args[ $argK ] as $subK => $subV ) {
						$newData[ $subK ] = $subV;
					}$$argK = $newData;
				} else {
					$$argK = $args[ $argK ]; }
			}
		}
		#} =========== / LOAD ARGS ============

		#} ========== CHECK FIELDS ============

			$id = (int) $id;

		// objectType
		if ( empty( $data['objectType'] ) || $data['objectType'] <= 0 ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable
			return false;
		}

			// objectID
		if ( ! isset( $data['objectID'] ) || $data['objectID'] <= 0 ) {
			return false;
		}

			// if owner = -1, add current
		if ( ! isset( $data['owner'] ) || $data['owner'] === -1 ) {
			$data['owner'] = zeroBSCRM_user();
		}

			// check name present + legit
		if ( ! isset( $data['source'] ) || empty( $data['source'] ) ) {
			return false;
		}

			// extsource ID finder - if obj source + cid provided, check not already present (if so overwrite)
			// keeps unique...
		if ( ( empty( $id ) || $id <= 0 )
				&&
				(
					( isset( $data['objectType'] ) && ! empty( $data['objectType'] ) ) ||
					( isset( $data['objectID'] ) && ! empty( $data['objectID'] ) ) ||
					( isset( $data['source'] ) && ! empty( $data['source'] ) )
				) ) {

			$args = array(
				'objectType' => $data['objectType'],
				'objectID'   => $data['objectID'],
				'source'     => $data['source'],
				'onlyID'     => true,
			);

			// check by source + cid
			// check existence + return ID
			$potentialID = (int) $this->getExternalSource( -1, $args );

			// override empty ID
			if ( ! empty( $potentialID ) && $potentialID > 0 ) {
				$id = $potentialID;
			}
		}

		#} ========= / CHECK FIELDS ===========

		#} Check if ID present
		$id = (int) $id;
		if ( ! empty( $id ) && $id > 0 ) {

				#} Check if obj exists (here) - for now just brutal update (will error when doesn't exist)

				#} Attempt update
			if ( $wpdb->update(
				$ZBSCRM_t['externalsources'],
				array(

					// ownership
					// no need to update these (as of yet) - can't move teams etc.
					// 'zbs_site' => zeroBSCRM_installSite(),
					// 'zbs_team' => zeroBSCRM_installTeam(),
					'zbs_owner'        => $data['owner'],

					// fields
					'zbss_objid'       => $data['objectID'],
					'zbss_objtype'     => $data['objectType'],
					'zbss_source'      => $data['source'],
					'zbss_uid'         => $data['uid'],
					'zbss_origin'      => $data['origin'],
					'zbss_lastupdated' => time(),

				),
				array( // where
					'ID' => $id,
				),
				array( // field data types
					'%d',
					'%d',
					'%d',
					'%s',
					'%s',
					'%s',
					'%d',
				),
				array( // where data types
					'%d',
				)
			) !== false ) {

						// Successfully updated - Return id
						return $id;

			} else {

				// FAILED update
				return false;

			}
		} else {

			#} No ID - must be an INSERT
			if ( $wpdb->insert(
				$ZBSCRM_t['externalsources'],
				array(

					// ownership
					'zbs_site'         => zeroBSCRM_site(),
					'zbs_team'         => zeroBSCRM_team(),
					'zbs_owner'        => $data['owner'],

					// fields
					'zbss_objid'       => $data['objectID'],
					'zbss_objtype'     => $data['objectType'],
					'zbss_source'      => $data['source'],
					'zbss_uid'         => $data['uid'],
					'zbss_origin'      => $data['origin'],
					'zbss_created'     => time(),
					'zbss_lastupdated' => time(),
				),
				array( // field data types
					'%d',  // site
					'%d',  // team
					'%d',  // owner

					'%d',
					'%d',
					'%s',
					'%s',
					'%s',
					'%d',
					'%d',
				)
			) > 0 ) {

					#} Successfully inserted, lets return new ID
					$newID = $wpdb->insert_id;

					return $newID;

			} else {

				#} Failed to Insert
				return false;

			}
		}

		return false;
	}

	/**
	 * adds or updates an object's external sources
	 *
	 * @param array $args Associative array of arguments
	 *
	 * @return bool success
	 */
	public function addUpdateExternalSources( $args = array() ) {

		// ============ LOAD ARGS =============
		$defaultArgs = array(

			'obj_id'           => -1,
			'obj_type_id'      => -1,
			'external_sources' => array(),

		);
		foreach ( $defaultArgs as $argK => $argV ) {
			$$argK = $argV;
			if ( is_array( $args ) && isset( $args[ $argK ] ) ) {
				if ( is_array( $args[ $argK ] ) ) {
					$newData = $$argK;
					if ( ! is_array( $newData ) ) {
						$newData = array();
					} foreach ( $args[ $argK ] as $subK => $subV ) {
						$newData[ $subK ] = $subV;
					}$$argK = $newData;
				} else {
					$$argK = $args[ $argK ]; }
			}
		}
		// =========== / LOAD ARGS ============

		$obj_id      = (int) $obj_id;
		$obj_type_id = (int) $obj_type_id;
		if ( ! is_array( $external_sources ) ) {
			return '';
		}

		$approvedExternalSource = ''; // for IA

		foreach ( $external_sources as $es ) {

			$external_source_id = isset( $es['id'] ) ? $es['id'] : -1;
			$origin             = isset( $es['origin'] ) ? $es['origin'] : null;

			$external_source_id = $this->addUpdateExternalSource(
				array(

					'id'   => $external_source_id,

					// fields (directly)
					'data' => array(
						'objectID'   => $obj_id,
						'objectType' => $obj_type_id,
						'source'     => $es['source'],
						'origin'     => $origin,
						'uid'        => $es['uid'],
					),
				)
			);

			$approvedExternalSource = array(
				'id'      => $external_source_id,
				'objID'   => $obj_id,
				'objType' => $obj_type_id,
				'source'  => $es['source'],
				'origin'  => $origin,
				'uid'     => $es['uid'],
			);

		} // / each ext source

		// this is a bit hackish, but allows DRY code without a refactor
		return $approvedExternalSource;
	}

	/**
	 * deletes an external source object
	 *
	 * @param array $args Associative array of arguments
	 *              id
	 *
	 * @return int success;
	 */
	public function deleteExternalSource( $args = array() ) {

		global $ZBSCRM_t, $wpdb;

		#} ============ LOAD ARGS =============
		$defaultArgs = array(

			'id' => -1,

		);
		foreach ( $defaultArgs as $argK => $argV ) {
			$$argK = $argV;
			if ( is_array( $args ) && isset( $args[ $argK ] ) ) {
				if ( is_array( $args[ $argK ] ) ) {
					$newData = $$argK;
					if ( ! is_array( $newData ) ) {
						$newData = array();
					} foreach ( $args[ $argK ] as $subK => $subV ) {
						$newData[ $subK ] = $subV;
					}$$argK = $newData;
				} else {
					$$argK = $args[ $argK ]; }
			}
		}
		#} =========== / LOAD ARGS ============

		#} Check ID & Delete :)
		$id = (int) $id;
		return zeroBSCRM_db2_deleteGeneric( $id, 'externalsources' );
	}

	/**
	 * deletes all external source lines for a specific obj
	 *
	 * @param array $args Associative array of arguments
	 *              id
	 *
	 * @return int success;
	 */
	public function delete_external_sources( $args = array() ) {

		global $ZBSCRM_t, $wpdb;

		#} ============ LOAD ARGS =============
		$defaultArgs = array(

			'obj_type'   => -1,
			'obj_id'     => -1,
			'obj_source' => 'all', // 'all' = every source

		);
		foreach ( $defaultArgs as $argK => $argV ) {
			$$argK = $argV;
			if ( is_array( $args ) && isset( $args[ $argK ] ) ) {
				if ( is_array( $args[ $argK ] ) ) {
					$newData = $$argK;
					if ( ! is_array( $newData ) ) {
						$newData = array();
					} foreach ( $args[ $argK ] as $subK => $subV ) {
						$newData[ $subK ] = $subV;
					}$$argK = $newData;
				} else {
					$$argK = $args[ $argK ]; }
			}
		}
		#} =========== / LOAD ARGS ============

		#} ========== CHECK FIELDS ============

			// checks
		if ( empty( $obj_type ) || $this->objTypeKey( $obj_type ) === -1 ) {
			return false;
		}
		if ( empty( $obj_id ) ) {
			return false;
		}

		#} ========= / CHECK FIELDS ===========

			// basics
			$where = array( // where
				'zbss_objtype' => $obj_type,
				'zbss_objid'   => $obj_id,
			);

			$whereFormat = array( // where
				'%d',
				'%d',
			);

			// add source if passed add where clause for it (can delete just for a particular source)
			if ( $obj_source !== 'all' ) {
				$where['zbss_source'] = $obj_source;
				$whereFormat[]        = '%s';
			}

			// brutal
			return $wpdb->delete(
				$ZBSCRM_t['externalsources'],
				$where,
				$whereFormat
			);
	}

	/**
	 * tidy's the object from wp db into clean array
	 *
	 * @param array $obj (DB obj)
	 *
	 * @return array (clean obj)
	 */
	public function tidy_externalsource( $obj = false ) {

			$res = false;

		if ( isset( $obj->ID ) ) {
			$res       = array();
			$res['id'] = $obj->ID;
			/*
			`zbs_site` INT NULL DEFAULT NULL,
			`zbs_team` INT NULL DEFAULT NULL,
			`zbs_owner` INT NOT NULL,
			*/

			$res['objid']   = $obj->zbss_objid;
			$res['objtype'] = $obj->zbss_objtype;
			$res['source']  = $obj->zbss_source;
			$res['uid']     = $this->stripSlashes( $obj->zbss_uid );
			$res['origin']  = $this->stripSlashes( $obj->zbss_origin );

			$res['created']     = $obj->zbss_created;
			$res['lastupdated'] = $obj->zbss_lastupdated;

		}

		return $res;
	}

	/**
	 * Returns a clean domain origin string for use with external sources, where possible
	 *
	 * @return string|bool(false) - tidied up domain origin, or false
	 */
	public function clean_external_source_domain_string( $domain ) {

		// clean it up a bit
		$origin = str_replace( array( 'https://', 'http://' ), '', $domain );
		$origin = rtrim( $origin, '/' );

		// prefix it for later querying
		if ( ! empty( $origin ) ) {

			return $origin;

		}

		return false;
	}

	// =========== / External Sources      ===========================================
	// ===============================================================================

	// ===============================================================================
	// ===========  Origin Helpers      ==============================================
	// To start with these will help store origin strings for external sources in a
	// machine-readable format

	/**
	 * Returns a prefixed origin string
	 * (Currently only origins stored are domains)
	 *
	 * @param string $string - string to prefix
	 * @param string $origin_type - a type of origin record
	 *
	 * @return string|bool(false) - prefixed origin string, or false
	 */
	public function add_origin_prefix( $string, $origin_type ) {

		switch ( $origin_type ) {

			case 'domain':
				return $this->prefix_domain . $string;
				break;

		}

		return false;
	}

	/**
	 * Returns a de-prefixed origin string
	 *
	 * @param string $string - string to prefix
	 *
	 * @return string|bool(false) - deprefixed origin string, or false
	 */
	public function remove_origin_prefix( $string ) {

		// split at first :
		$split_point = strpos( $string, ':' );

		if ( $split_point ) {

			return substr( $string, $split_point + 1 );

		}

		return false;
	}

	/**
	 * Returns an origin string and type from a prefixed origin string
	 *
	 * @param string $string Prefixed origin string.
	 *
	 * @return array|bool(false) - origin string and type, or false
	 */
	public function hydrate_origin( $string ) {

		// domain
		if ( str_starts_with( $string, 'd:' ) ) {

			return array(
				'origin'      => $this->remove_origin_prefix( $string ),
				'origin_type' => 'domain',
			);

		}

		return false;
	}

	// =========== / Origin Helpers      =============================================
	// ===============================================================================

	// ===============================================================================
	// ===========   Web Tracking (UTM etc.)  ========================================

	/**
	 * returns full tracking line +- details
	 *
	 * @param int id        tag id
	 * @param array                $args   Associative array of arguments
	 *                                     withStats
	 *
	 * @return array result
	 */
	public function getTracking( $id = -1, $args = array() ) {

		#} =========== LOAD ARGS ==============
		$defaultArgs = array(

			// permissions
			'ignoreowner' => false, // this'll let you not-check the owner of obj

			// returns scalar ID of line
			'onlyID'      => false,

		);
		foreach ( $defaultArgs as $argK => $argV ) {
			$$argK = $argV;
			if ( is_array( $args ) && isset( $args[ $argK ] ) ) {
				if ( is_array( $args[ $argK ] ) ) {
					$newData = $$argK;
					if ( ! is_array( $newData ) ) {
						$newData = array();
					} foreach ( $args[ $argK ] as $subK => $subV ) {
						$newData[ $subK ] = $subV;
					}$$argK = $newData;
				} else {
					$$argK = $args[ $argK ]; }
			}
		}
		#} =========== / LOAD ARGS =============

		#} ========== CHECK FIELDS ============

			$id = (int) $id;
		#} ========= / CHECK FIELDS ===========

		#} Check ID or name/type
		if ( ! empty( $id ) && $id > 0 ) {

			global $ZBSCRM_t, $wpdb;
			$wheres          = array( 'direct' => array() );
			$whereStr        = '';
			$additionalWhere = '';
			$params          = array();
			$res             = array();

			#} Build query
			$query = 'SELECT * FROM ' . $ZBSCRM_t['tracking'];

			#} ============= WHERE ================

				// Add ID
				$wheres['ID'] = array( 'ID', '=', '%d', $id );

			#} ============ / WHERE ==============

			#} Build out any WHERE clauses
			$wheresArr = $this->buildWheres( $wheres, $whereStr, $params );
			$whereStr  = $wheresArr['where'];
			$params    = $params + $wheresArr['params'];
			#} / Build WHERE

			#} Ownership v1.0 - the following adds SITE + TEAM checks, and (optionally), owner
			$params = array_merge( $params, $this->ownershipQueryVars( $ignoreowner ) ); // merges in any req.
			$ownQ   = $this->ownershipSQL( $ignoreowner );
			if ( ! empty( $ownQ ) ) {
				$additionalWhere = $this->spaceAnd( $additionalWhere ) . $ownQ; // adds str to query
			}
			#} / Ownership

			#} Append to sql (this also automatically deals with sortby and paging)
			$query .= $this->buildWhereStr( $whereStr, $additionalWhere ) . $this->buildSort( 'ID', 'DESC' ) . $this->buildPaging( 0, 1 );

			try {

				#} Prep & run query
				$queryObj     = $this->prepare( $query, $params );
				$potentialRes = $wpdb->get_row( $queryObj, OBJECT );

			} catch ( Exception $e ) {

				#} General SQL Err
				$this->catchSQLError( $e );

			}

			#} Interpret Results (ROW)
			if ( isset( $potentialRes ) && isset( $potentialRes->ID ) ) {

				#} Has results, tidy + return

					#} Only ID? return it directly
				if ( $onlyID === true ) {
					return $potentialRes->ID;
				}

					// tidy
					$res = $this->tidy_tracking( $potentialRes );

					return $res;

			}
		} // / if ID

		return false;
	}

	/**
	 * adds or updates a tracking object
	 *
	 * @param array $args Associative array of arguments
	 *
	 * @return int line ID
	 */
	public function addUpdateTracking( $args = array() ) {

		global $ZBSCRM_t, $wpdb;

		#} ============ LOAD ARGS =============
		$defaultArgs = array(

			'id'   => -1,

			// fields (directly)
			'data' => array(

				'contactID'     => -1,
				'action'        => '',
				'action_detail' => '',
				'referrer'      => '',
				'utm_source'    => '',
				'utm_medium'    => '',
				'utm_name'      => '',
				'utm_term'      => '',
				'utm_content'   => '',

				'owner'         => -1,

			),

		);
		foreach ( $defaultArgs as $argK => $argV ) {
			$$argK = $argV;
			if ( is_array( $args ) && isset( $args[ $argK ] ) ) {
				if ( is_array( $args[ $argK ] ) ) {
					$newData = $$argK;
					if ( ! is_array( $newData ) ) {
						$newData = array();
					} foreach ( $args[ $argK ] as $subK => $subV ) {
						$newData[ $subK ] = $subV;
					}$$argK = $newData;
				} else {
					$$argK = $args[ $argK ]; }
			}
		}
		#} =========== / LOAD ARGS ============

		#} ========== CHECK FIELDS ============

			$id = (int) $id;

		// contactID
		if ( empty( $data['contactID'] ) || $data['contactID'] <= 0 ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable
			return false;
		}

			// if owner = -1, add current
		if ( ! isset( $data['owner'] ) || $data['owner'] === -1 ) {
			$data['owner'] = zeroBSCRM_user();
		}

			// check action present + legit
		if ( ! isset( $data['action'] ) || empty( $data['action'] ) ) {
			return false;
		}

		#} ========= / CHECK FIELDS ===========

		#} Check if ID present
		$id = (int) $id;
		if ( ! empty( $id ) && $id > 0 ) {

				#} Check if obj exists (here) - for now just brutal update (will error when doesn't exist)

				#} Attempt update
			if ( $wpdb->update(
				$ZBSCRM_t['tracking'],
				array(

					// ownership
					// no need to update these (as of yet) - can't move teams etc.
					// 'zbs_site' => zeroBSCRM_installSite(),
					// 'zbs_team' => zeroBSCRM_installTeam(),
					'zbs_owner'          => $data['owner'],

					// fields
					'zbst_contactid'     => $data['contactID'],
					'zbst_action'        => $data['action'],
					'zbst_action_detail' => $data['action_detail'],
					'zbst_referrer'      => $data['referrer'],
					'zbst_utm_source'    => $data['utm_source'],
					'zbst_utm_medium'    => $data['utm_medium'],
					'zbst_utm_name'      => $data['utm_name'],
					'zbst_utm_term'      => $data['utm_term'],
					'zbst_utm_content'   => $data['utm_content'],

					'zbst_lastupdated'   => time(),
				),
				array( // where
					'ID' => $id,
				),
				array( // field data types
					'%d',

					'%d',
					'%s',
					'%s',
					'%s',
					'%s',
					'%s',
					'%s',
					'%s',
					'%s',

					'%d',
				),
				array( // where data types
					'%d',
				)
			) !== false ) {

						// Successfully updated - Return id
						return $id;

			} else {

				// FAILED update
				return false;

			}
		} else {

			#} No ID - must be an INSERT
			if ( $wpdb->insert(
				$ZBSCRM_t['tracking'],
				array(

					// ownership
					'zbs_site'           => zeroBSCRM_site(),
					'zbs_team'           => zeroBSCRM_team(),
					'zbs_owner'          => $data['owner'],

					// fields
					'zbst_contactid'     => $data['contactID'],
					'zbst_action'        => $data['action'],
					'zbst_action_detail' => $data['action_detail'],
					'zbst_referrer'      => $data['referrer'],
					'zbst_utm_source'    => $data['utm_source'],
					'zbst_utm_medium'    => $data['utm_medium'],
					'zbst_utm_name'      => $data['utm_name'],
					'zbst_utm_term'      => $data['utm_term'],
					'zbst_utm_content'   => $data['utm_content'],

					'zbst_created'       => time(),
					'zbst_lastupdated'   => time(),
				),
				array( // field data types
					'%d',  // site
					'%d',  // team
					'%d',  // owner

					'%d',
					'%s',
					'%s',
					'%s',
					'%s',
					'%s',
					'%s',
					'%s',
					'%s',

					'%d',
					'%d',
				)
			) > 0 ) {

					#} Successfully inserted, lets return new ID
					$newID = $wpdb->insert_id;
					return $newID;

			} else {

				#} Failed to Insert
				return false;

			}
		}

		return false;
	}

	/**
	 * deletes a tracking object
	 *
	 * @param array $args Associative array of arguments
	 *              id
	 *
	 * @return int success;
	 */
	public function deleteTracking( $args = array() ) {

		global $ZBSCRM_t, $wpdb;

		#} ============ LOAD ARGS =============
		$defaultArgs = array(

			'id' => -1,

		);
		foreach ( $defaultArgs as $argK => $argV ) {
			$$argK = $argV;
			if ( is_array( $args ) && isset( $args[ $argK ] ) ) {
				if ( is_array( $args[ $argK ] ) ) {
					$newData = $$argK;
					if ( ! is_array( $newData ) ) {
						$newData = array();
					} foreach ( $args[ $argK ] as $subK => $subV ) {
						$newData[ $subK ] = $subV;
					}$$argK = $newData;
				} else {
					$$argK = $args[ $argK ]; }
			}
		}
		#} =========== / LOAD ARGS ============

		#} Check ID & Delete :)
		$id = (int) $id;
		return zeroBSCRM_db2_deleteGeneric( $id, 'tracking' );
	}

	/**
	 * tidy's the object from wp db into clean array
	 *
	 * @param array $obj (DB obj)
	 *
	 * @return array (clean obj)
	 */
	private function tidy_tracking( $obj = false ) {

			$res = false;

		if ( isset( $obj->ID ) ) {
			$res       = array();
			$res['id'] = $obj->ID;
			/*
			`zbs_site` INT NULL DEFAULT NULL,
			`zbs_team` INT NULL DEFAULT NULL,
			`zbs_owner` INT NOT NULL,
			*/

			$res['contactid']     = $obj->zbss_contactid;
			$res['action']        = $obj->zbst_action;
			$res['action_detail'] = $obj->zbst_action_detail;
			$res['referrer']      = $obj->zbst_referrer;
			$res['utm_source']    = $obj->zbst_utm_source;
			$res['utm_medium']    = $obj->zbst_utm_medium;
			$res['utm_name']      = $obj->zbst_utm_name;
			$res['utm_term']      = $obj->zbst_utm_term;
			$res['utm_content']   = $obj->zbst_utm_content;

			$res['created']     = $obj->zbst_created;
			$res['lastupdated'] = $obj->zbst_lastupdated;

		}

		return $res;
	}

	// =========== / Web Tracking (UTM etc.)      ====================================
	// ===============================================================================

	// ===============================================================================
	// ===========   LOGS   ==========================================================

	/**
	 * returns cron log lines
	 *
	 * @param array $args Associative array of arguments
	 *              searchPhrase, sortByField, sortOrder, page, perPage
	 *
	 * @return array of tag lines
	 */
	public function getCronLogs( $args = array() ) {

		// ============ LOAD ARGS =============
		$with_notes = true;

		// phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
		$defaultArgs = array(
			'job'         => '',
			'sortByField' => 'ID', // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
			'sortOrder'   => 'DESC', // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
			'page'        => 0,
			'perPage'     => 100, // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
			'with_notes'  => true,
			'ignoreowner' => false, // this'll let you not-check the owner of obj

		);
		foreach ( $defaultArgs as $argK => $argV ) {
			$$argK = $argV;
			if ( is_array( $args ) && isset( $args[ $argK ] ) ) {
				if ( is_array( $args[ $argK ] ) ) {
					$newData = $$argK;
					if ( ! is_array( $newData ) ) {
						$newData = array();
					} foreach ( $args[ $argK ] as $subK => $subV ) {
						$newData[ $subK ] = $subV;
					}$$argK = $newData;
				} else {
					$$argK = $args[ $argK ]; }
			}
		}
		#} =========== / LOAD ARGS =============

		#} ========== CHECK FIELDS ============

		#} ========= / CHECK FIELDS ===========

		global $ZBSCRM_t, $wpdb;
		$wheres          = array( 'direct' => array() );
		$whereStr        = '';
		$additionalWhere = '';
		$params          = array();
		$res             = array();

		#} Build query
		$query = 'SELECT * FROM ' . $ZBSCRM_t['cronmanagerlogs'];

		#} ============= WHERE ================

			#} job
		if ( ! empty( $job ) && $job > 0 ) {
			$wheres['job'] = array( 'job', '=', '%s', $job );
		}

		if ( $with_notes ) {
			$wheres['notes'] = array( 'jobnotes', '<>', '%s', '' );
		}

		#} ============ / WHERE ===============

		#} Build out any WHERE clauses
		$wheresArr = $this->buildWheres( $wheres, $whereStr, $params );
		$whereStr  = $wheresArr['where'];
		$params    = $params + $wheresArr['params'];
		#} / Build WHERE

		#} Ownership v1.0 - the following adds SITE + TEAM checks, and (optionally), owner
		$params = array_merge( $params, $this->ownershipQueryVars( $ignoreowner ) ); // merges in any req.
		$ownQ   = $this->ownershipSQL( $ignoreowner );
		if ( ! empty( $ownQ ) ) {
			$additionalWhere = $this->spaceAnd( $additionalWhere ) . $ownQ; // adds str to query
		}
		#} / Ownership

		#} Append to sql (this also automatically deals with sortby and paging)
		$query .= $this->buildWhereStr( $whereStr, $additionalWhere ) . $this->buildSort( $sortByField, $sortOrder ) . $this->buildPaging( $page, $perPage );

		try {

			#} Prep & run query
			$queryObj     = $this->prepare( $query, $params );
			$potentialRes = $wpdb->get_results( $queryObj, OBJECT );

		} catch ( Exception $e ) {

			#} General SQL Err
			$this->catchSQLError( $e );

		}

		#} Interpret results (Result Set - multi-row)
		if ( isset( $potentialRes ) && is_array( $potentialRes ) && count( $potentialRes ) > 0 ) {

			#} Has results, tidy + return
			foreach ( $potentialRes as $resDataLine ) {

					// tidy
					$resArr = $this->tidy_cronlog( $resDataLine );

					$res[] = $resArr;

			}
		}

		return $res;
	}

	/**
	 * adds or updates a cron log object
	 *
	 * @param array $args Associative array of arguments
	 *              id (not req.), owner (not req.) data -> key/val
	 *
	 * @return int line ID
	 */
	public function addUpdateCronLog( $args = array() ) {

		global $ZBSCRM_t, $wpdb;

		#} ============ LOAD ARGS =============
		$defaultArgs = array(

			'id'    => -1,
			'owner' => -1,

			// fields (directly)
			'data'  => array(

				'job'         => '',
				'jobstatus'   => -1,
				'jobstarted'  => -1,
				'jobfinished' => -1,
				'jobnotes'    => '',

			),

		);
		foreach ( $defaultArgs as $argK => $argV ) {
			$$argK = $argV;
			if ( is_array( $args ) && isset( $args[ $argK ] ) ) {
				if ( is_array( $args[ $argK ] ) ) {
					$newData = $$argK;
					if ( ! is_array( $newData ) ) {
						$newData = array();
					} foreach ( $args[ $argK ] as $subK => $subV ) {
						$newData[ $subK ] = $subV;
					}$$argK = $newData;
				} else {
					$$argK = $args[ $argK ]; }
			}
		}
		#} =========== / LOAD ARGS ============

		#} ========== CHECK FIELDS ============

			$id = (int) $id;

		// if owner = -1, add current
		if ( $owner === -1 ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable
			$owner = zeroBSCRM_user();
		}

		#} ========= / CHECK FIELDS ===========

		$dataArr = array(

			// ownership
			// no need to update these (as of yet) - can't move teams etc.
			// 'zbs_site' => zeroBSCRM_installSite(),
			// 'zbs_team' => zeroBSCRM_installTeam(),
			'zbs_owner'   => $owner,

			// fields
			'job'         => $data['job'],
			'jobstatus'   => $data['jobstatus'],
			'jobstarted'  => $data['jobstarted'],
			'jobfinished' => $data['jobfinished'],
			'jobnotes'    => $data['jobnotes'],
		);

		$dataTypes = array( // field data types
			'%d',

			'%s',
			'%d',
			'%d',
			'%d',
			'%s',
		);

		if ( isset( $id ) && ! empty( $id ) && $id > 0 ) {

				#} Check if obj exists (here) - for now just brutal update (will error when doesn't exist)

				#} Attempt update
			if ( $wpdb->update(
				$ZBSCRM_t['cronmanagerlogs'],
				$dataArr,
				array( // where
					'ID' => $id,
				),
				$dataTypes,
				array( // where data types
					'%d',
				)
			) !== false ) {

						// Successfully updated - Return id
						return $id;

			} else {

				// FAILED update
				return false;

			}
		} else {

			// add team etc
			$dataArr['zbs_site'] = zeroBSCRM_site();
			$dataTypes[]         = '%d';
			$dataArr['zbs_team'] = zeroBSCRM_team();
			$dataTypes[]         = '%d';

			#} No ID - must be an INSERT
			if ( $wpdb->insert(
				$ZBSCRM_t['cronmanagerlogs'],
				$dataArr,
				$dataTypes
			) > 0 ) {

					#} Successfully inserted, lets return new ID
					$newID = $wpdb->insert_id;

					return $newID;

			} else {

				#} Failed to Insert
				return false;

			}
		}

		return false;
	}

	/**
	 * deletes a CRON Log object
	 * NOTE! this doesn't yet delete any META!
	 *
	 * @param array $args Associative array of arguments
	 *              id
	 *
	 * @return int success;
	 */
	public function deleteCronLog( $args = array() ) {

		global $ZBSCRM_t, $wpdb;

		#} ============ LOAD ARGS =============
		$defaultArgs = array(

			'id' => -1,

		);
		foreach ( $defaultArgs as $argK => $argV ) {
			$$argK = $argV;
			if ( is_array( $args ) && isset( $args[ $argK ] ) ) {
				if ( is_array( $args[ $argK ] ) ) {
					$newData = $$argK;
					if ( ! is_array( $newData ) ) {
						$newData = array();
					} foreach ( $args[ $argK ] as $subK => $subV ) {
						$newData[ $subK ] = $subV;
					}$$argK = $newData;
				} else {
					$$argK = $args[ $argK ]; }
			}
		}
		#} =========== / LOAD ARGS ============

		#} Check ID & Delete :)
		$id = (int) $id;
		return zeroBSCRM_db2_deleteGeneric( $id, 'cronmanagerlogs' );
	}

	/**
	 * tidy's the object from wp db into clean array
	 *
	 * @param array $obj (DB obj)
	 *
	 * @return array (clean obj)
	 */
	private function tidy_cronlog( $obj = false ) {

			$res = false;

		if ( isset( $obj->ID ) ) {
			$res          = array();
			$res['id']    = $obj->ID;
			$res['owner'] = $obj->zbs_owner;

			$res['job']         = $obj->job;
			$res['jobstatus']   = $obj->jobstatus;
			$res['jobstarted']  = $obj->jobstarted;
			$res['jobfinished'] = $obj->jobfinished;
			$res['jobnotes']    = $obj->jobnotes;

		}

		return $res;
	}

	// =========== / CRONLOGS  =======================================================
	// ===============================================================================

	// ===============================================================================
	// ============= GENERIC  ========================================================

	/**
	 * Wrapper function for emptying tables (use with care)
	 * ... can only truncate tables in our ZBSCRM_t
	 *
	 * @param string $tableKey (refers to ZBSCRM_t global)
	 *
	 * @return result
	 */
	public function truncate( $tableKey = '' ) {

		global $ZBSCRM_t;

		if ( is_string( $tableKey ) && ! empty( $tableKey ) && isset( $ZBSCRM_t[ $tableKey ] ) ) {

			global $wpdb;
			return $wpdb->query( 'TRUNCATE TABLE `' . $ZBSCRM_t[ $tableKey ] . '`' );

		}

		return false;
	}

	// =========== / GENERIC  ========================================================
	// ===============================================================================

	// ===============================================================================
	// ===========   FIELD HELPERS     ===============================================

	/**
	 * Returns a field from an object model if it exists
	 *
	 * @param CRM_TYPE int object_type_id - object type ID
	 * @param string field_key - key of field (e.g. `status`)
	 *
	 * @return bool|array - field type info (as per direct from the object_model)
	 */
	public function get_model_field_info( $object_type_id = -1, $field_key = '' ) {

		// valid obj type id and key?
		if ( $this->isValidObjTypeID( $object_type_id ) && ! empty( $field_key ) ) {

			// get object layer and load object model
			$object_layer = $this->getObjectLayerByType( $object_type_id );
			if ( $object_layer ) {

				$object_model = $object_layer->objModel( true );

				// if set, return
				if ( is_array( $object_model ) && isset( $object_model[ $field_key ] ) ) {

					return $object_model[ $field_key ];

				}
			}

			// as a temporary workaround until we have addresses loaded from DAL3
			// check address field presence via global
			if ( $object_type_id == ZBS_TYPE_ADDRESS ) {

				// effectively returns $zbsAddressFields:
				$obj_model_global = $this->get_object_field_global( ZBS_TYPE_ADDRESS );

				if ( isset( $obj_model_global[ $field_key ] ) ) {

					// As an aside, the $potential_field format will be different here
					// as this takes from the globals model, not the DAL model
					// ... please bear this in mind if interacting with address fields this way
					return $obj_model_global[ $field_key ];

				}
			}
		}

		return false;
	}

	/**
	 * Returns true if a field from an object model exists
	 *
	 * @param CRM_TYPE int object_type_id - object type ID
	 * @param string field_key - key of field (e.g. `status`)
	 *
	 * @return bool - does field exist
	 */
	public function does_model_field_exist( $object_type_id = -1, $field_key = '' ) {

		// valid obj type id and key?
		if ( $this->isValidObjTypeID( $object_type_id ) && ! empty( $field_key ) ) {

			// Check for field existence
			$potential_field = $this->get_model_field_info( $object_type_id, $field_key );

			if ( $potential_field !== false ) {

				// found it
				return true;

			}
		}

		return false;
	}

	// =========== / FIELD HELPERS     ===============================================
	// ===============================================================================

	/*
	======================================================
	/ DAL CRUD
	======================================================
	*/

	/*
	======================================================
	Formatters (Generic)
	======================================================
	*/

	// legacy signpost, this is now overwritten by DAL->contacts->fullname
	public function format_fullname( $contactArr = array() ) {

		return $this->contacts->format_fullname( $contactArr );
	}

	// legacy signpost, this is now overwritten by DAL->[contacts|companies]->format_name_etc
	public function format_name_etc( $objectArr = array(), $args = array() ) {

		#} =========== LOAD ARGS ==============
		$defaultArgs = array(

			'incFirstLineAddr' => false,
			'incID'            => false,
			'company'          => false, // if true, looks for 'name' not 'fname+lname'

		);
		foreach ( $defaultArgs as $argK => $argV ) {
			$$argK = $argV;
			if ( is_array( $args ) && isset( $args[ $argK ] ) ) {
				if ( is_array( $args[ $argK ] ) ) {
					$newData = $$argK;
					if ( ! is_array( $newData ) ) {
						$newData = array();
					} foreach ( $args[ $argK ] as $subK => $subV ) {
						$newData[ $subK ] = $subV;
					}$$argK = $newData;
				} else {
					$$argK = $args[ $argK ]; }
			}
		}
		#} =========== / LOAD ARGS =============

		if ( ! $company ) {
			return $this->contacts->format_name_etc( $objectArr, $args );
		} else {
			return $this->companies->format_name_etc( $objectArr, $args );
		}
	}

	/**
	 * Returns a formatted address
	 * via getContactFullName this replaces zeroBS_customerAddr in dal1
	 * NOTE, post v3.0 applies to ANY form of addr.
	 *
	 * @param array $obj (tidied db obj)
	 *
	 * @return string address
	 */
	public function format_address( $contactArr = array(), $args = array() ) {

		#} =========== LOAD ARGS ==============
		$defaultArgs = array(

			'addrFormat' => 'short',
			'delimiter'  => ', ', // could use <br>
			'secondaddr' => false, // if true, use second address (if present in contact_arr)

		);
		foreach ( $defaultArgs as $argK => $argV ) {
			$$argK = $argV;
			if ( is_array( $args ) && isset( $args[ $argK ] ) ) {
				if ( is_array( $args[ $argK ] ) ) {
					$newData = $$argK;
					if ( ! is_array( $newData ) ) {
						$newData = array();
					} foreach ( $args[ $argK ] as $subK => $subV ) {
						$newData[ $subK ] = $subV;
					}$$argK = $newData;
				} else {
					$$argK = $args[ $argK ]; }
			}
		}
		#} =========== / LOAD ARGS =============

		$ret         = '';
		$fieldPrefix = '';
		if ( $secondaddr ) {
			$fieldPrefix = 'sec';
		}
		// v3.0 exception, contacts need this prefix for second :/
		// attempt to account for that:
		if ( $secondaddr && ! isset( $contactArr[ $fieldPrefix . 'addr1' ] ) && isset( $contactArr[ 'secaddr_' . 'addr1' ] ) ) {
			$fieldPrefix = 'secaddr_';
		}

		#} Legacy from DAL1:
		$addrCustomFields = $this->getActiveCustomFields( array( 'objtypeid' => ZBS_TYPE_ADDRESS ) );

		if ( $addrFormat == 'short' ) {

			if ( isset( $contactArr[ $fieldPrefix . 'addr1' ] ) && ! empty( $contactArr[ $fieldPrefix . 'addr1' ] ) ) {
				$ret = $contactArr[ $fieldPrefix . 'addr1' ];
			}
			if ( isset( $contactArr[ $fieldPrefix . 'city' ] ) && ! empty( $contactArr[ $fieldPrefix . 'city' ] ) ) {
				$ret .= $this->delimiterIf( $delimiter, $ret ) . $contactArr[ $fieldPrefix . 'city' ];
			}
		} elseif ( $addrFormat == 'full' ) {

			if ( isset( $contactArr[ $fieldPrefix . 'addr1' ] ) && ! empty( $contactArr[ $fieldPrefix . 'addr1' ] ) ) {
				$ret = $contactArr[ $fieldPrefix . 'addr1' ];
			}
			if ( isset( $contactArr[ $fieldPrefix . 'addr2' ] ) && ! empty( $contactArr[ $fieldPrefix . 'addr2' ] ) ) {
				$ret .= $this->delimiterIf( $delimiter, $ret ) . $contactArr[ $fieldPrefix . 'addr2' ];
			}
			if ( isset( $contactArr[ $fieldPrefix . 'city' ] ) && ! empty( $contactArr[ $fieldPrefix . 'city' ] ) ) {
				$ret .= $this->delimiterIf( $delimiter, $ret ) . $contactArr[ $fieldPrefix . 'city' ];
			}
			if ( isset( $contactArr[ $fieldPrefix . 'county' ] ) && ! empty( $contactArr[ $fieldPrefix . 'county' ] ) ) {
				$ret .= $this->delimiterIf( $delimiter, $ret ) . $contactArr[ $fieldPrefix . 'county' ];
			}
			if ( isset( $contactArr[ $fieldPrefix . 'postcode' ] ) && ! empty( $contactArr[ $fieldPrefix . 'postcode' ] ) ) {
				$ret .= $this->delimiterIf( $delimiter, $ret ) . $contactArr[ $fieldPrefix . 'postcode' ];
			}
			if ( isset( $contactArr[ $fieldPrefix . 'country' ] ) && ! empty( $contactArr[ $fieldPrefix . 'country' ] ) && zeroBSCRM_getSetting( 'countries' ) == 1 ) {
				$ret .= $this->delimiterIf( $delimiter, $ret ) . $contactArr[ $fieldPrefix . 'country' ];
			}

			// any custom fields here
			if ( is_array( $addrCustomFields ) && count( $addrCustomFields ) > 0 ) {

				foreach ( $addrCustomFields as $cK => $cF ) {

					// v2:
					// $cKN = (int)$cK+1;
					// $cKey = $fieldPrefix.'addr_cf'.$cKN;
					// v3:
					$cKey = ( $secondaddr ) ? 'secaddr_' . $cK : 'addr_' . $cK;

					if ( isset( $contactArr[ $cKey ] ) && ! empty( $contactArr[ $cKey ] ) ) {

						// if someone is using date custom fields here, output date, not uts - super edge case gh-349
						if ( isset( $contactArr[ $cKey . '_cfdate' ] ) && ! empty( $contactArr[ $cKey . '_cfdate' ] ) ) {
							$ret .= $this->delimiterIf( $delimiter, $ret ) . $contactArr[ $cKey . '_cfdate' ];
						} else {
							$ret .= $this->delimiterIf( $delimiter, $ret ) . $contactArr[ $cKey ];
						}
					}
				}
			}
		}

		$trimRet = trim( $ret );
		return $trimRet;
	}

	public function makeSlug( $string, $replace = array(), $delimiter = '-' ) {

		// NOTE: the following can likely be replaced with sanitize_title
		// https://wordpress.stackexchange.com/questions/74415/how-does-wordpress-generate-url-slugs
		// return sanitize_title(sanitize_title($string, '', 'save'), '', 'query');

		// https://github.com/phalcon/incubator/blob/master/Library/Phalcon/Utils/Slug.php
		// and
		// https://stackoverflow.com/questions/4910627/php-iconv-translit-for-removing-accents-not-working-as-excepted
		// if (!extension_loaded('iconv')) {
		// throw new Exception('iconv module not loaded');
		// }
		// Save the old locale and set the new locale to UTF-8
		$oldLocale = setlocale( LC_ALL, '0' );
		setlocale( LC_ALL, 'en_US.UTF-8' );

		// replace non letter or digits by -
		$clean = preg_replace( '#[^\\pL\d]+#u', '-', $string );

		// transliterate
		if ( function_exists( 'iconv' ) ) {
			$clean = @iconv( 'UTF-8', 'ASCII//TRANSLIT', $clean );
		}
		// else? smt else?

		// replace
		if ( ! empty( $replace ) ) {
			$clean = str_replace( (array) $replace, ' ', $clean );
		}

		// clean
		$clean = $this->makeSlugCleanStr( $clean, $delimiter );

		// Revert back to the old locale
		setlocale( LC_ALL, $oldLocale );
		return $clean;
	}

	private function makeSlugCleanStr( $string = '', $delimiter = '-' ) {

		// fix for ascii passing (I think) of ' resulting in -039- in place of '
		$string = str_replace( '-039-', '', $string );

		// replace non letter or non digits by -
		$string = preg_replace( '#[^\pL\d]+#u', '-', $string );
		// Trim trailing -
		$string = trim( $string, '-' );
		$clean  = preg_replace( '~[^-\w]+~', '', $string );
		$clean  = strtolower( $clean );
		$clean  = preg_replace( '#[\/_|+ -]+#', $delimiter, $clean );
		$clean  = trim( $clean, $delimiter );

		return $clean;
	}

	/*
	======================================================
	/ Formatters
	======================================================
	*/

	/*
	======================================================
	To be sorted helpers
	======================================================
	*/

		/**
		 * helper - returns single field against db table WHERE X
		 * Will only work for native fields (not Cutom fields)
		 *
		 * @param array WHERE clauses (not Req.)
		 * @param string tablename
		 * @param string colname
		 *
		 * @return string
		 */
	public function getFieldByWHERE( $args = array() ) {

		#} =========== LOAD ARGS ==============
		$defaultArgs = array(

			'where'       => -1,
			'objtype'     => -1,
			'colname'     => '',

			// permissions
			'ignoreowner' => false, // this'll let you not-check the owner of obj

		);
		foreach ( $defaultArgs as $argK => $argV ) {
			$$argK = $argV;
			if ( is_array( $args ) && isset( $args[ $argK ] ) ) {
				if ( is_array( $args[ $argK ] ) ) {
					$newData = $$argK;
					if ( ! is_array( $newData ) ) {
						$newData = array();
					} foreach ( $args[ $argK ] as $subK => $subV ) {
						$newData[ $subK ] = $subV;
					}$$argK = $newData;
				} else {
					$$argK = $args[ $argK ]; }
			}
		}
		#} =========== / LOAD ARGS =============

		#} ========== CHECK FIELDS ============

			// check obtype is legit
			$objtype = (int) $objtype;
		if ( ! isset( $objtype ) || $objtype == -1 || $this->objTypeKey( $objtype ) === -1 ) {
			return false;
		}

			// check field (or 'COUNT(x)')
		if ( empty( $colname ) ) {
			return false;
		}

		#} ========= / CHECK FIELDS ===========

		global $ZBSCRM_t, $wpdb;
		$wheres          = array( 'direct' => array() );
		$whereStr        = '';
		$additionalWhere = '';
		$params          = array();
		$res             = array();

		#} Build query - NOTE this is vulnerable to injection.
		$query = "SELECT $colname FROM " . $this->lazyTable( $objtype );
		// $params[] = $colname;

		#} ============= WHERE ================

			#} Add any where's
			$wheres = $where;

		#} ============ / WHERE ==============

		#} Build out any WHERE clauses
		$wheresArr = $this->buildWheres( $wheres, $whereStr, $params );
		$whereStr  = $wheresArr['where'];
		$params    = $params + $wheresArr['params'];
		#} / Build WHERE

		#} Ownership v1.0 - the following adds SITE + TEAM checks, and (optionally), owner
		$params = array_merge( $params, $this->ownershipQueryVars( $ignoreowner ) ); // merges in any req.
		$ownQ   = $this->ownershipSQL( $ignoreowner );
		if ( ! empty( $ownQ ) ) {
			$additionalWhere = $this->spaceAnd( $additionalWhere ) . $ownQ; // adds str to query
		}
		#} / Ownership

		#} Append to sql (this also automatically deals with sortby and paging)
		$query .= $this->buildWhereStr( $whereStr, $additionalWhere ) . $this->buildSort( 'ID', 'DESC' ) . $this->buildPaging( 0, 1 );

		try {

			#} Prep & run query
			$queryObj = $this->prepare( $query, $params );
			return $wpdb->get_var( $queryObj );

		} catch ( Exception $e ) {

			#} General SQL Err
			$this->catchSQLError( $e );

		}

		return false;
	}

		/**
		 * helper - returns single field against db table (where ID =)
		 * Will only work for native fields (not Cutom fields)
		 *
		 * @param int objID     object id
		 * @param int objTypeID objectType id
		 * @param string colname
		 *
		 * @return string
		 */
	public function getFieldByID( $args = array() ) {

		#} =========== LOAD ARGS ==============
		$defaultArgs = array(

			'id'          => -1,
			'objtype'     => -1,
			'colname'     => '',

			// permissions
			'ignoreowner' => false, // this'll let you not-check the owner of obj

		);
		foreach ( $defaultArgs as $argK => $argV ) {
			$$argK = $argV;
			if ( is_array( $args ) && isset( $args[ $argK ] ) ) {
				if ( is_array( $args[ $argK ] ) ) {
					$newData = $$argK;
					if ( ! is_array( $newData ) ) {
						$newData = array();
					} foreach ( $args[ $argK ] as $subK => $subV ) {
						$newData[ $subK ] = $subV;
					}$$argK = $newData;
				} else {
					$$argK = $args[ $argK ]; }
			}
		}
		#} =========== / LOAD ARGS =============

		#} ========== CHECK FIELDS ============

			// check id
			$id = (int) $id;
		if ( ! isset( $id ) || $id < 1 ) {
			return false;
		}

			// check obtype is legit
			$objtype = (int) $objtype;
		if ( ! isset( $objtype ) || $objtype == -1 || $this->objTypeKey( $objtype ) === -1 ) {
			return false;
		}

			// check field
		if ( empty( $colname ) ) {
			return false;
		}

		#} ========= / CHECK FIELDS ===========

		global $ZBSCRM_t, $wpdb;
		$wheres          = array( 'direct' => array() );
		$whereStr        = '';
		$additionalWhere = '';
		$params          = array();
		$res             = array();

		#} Build query - NOTE this is vulnerable to injection.
		$query = "SELECT $colname FROM " . $this->lazyTable( $objtype );
		// $params[] = $colname;

		#} ============= WHERE ================

		// Add ID
		$wheres['ID'] = array( 'ID', '=', '%d', $id );

		#} ============ / WHERE ==============

		#} Build out any WHERE clauses
		$wheresArr = $this->buildWheres( $wheres, $whereStr, $params );
		$whereStr  = $wheresArr['where'];
		$params    = $params + $wheresArr['params'];
		#} / Build WHERE

		#} Ownership v1.0 - the following adds SITE + TEAM checks, and (optionally), owner
		$params = array_merge( $params, $this->ownershipQueryVars( $ignoreowner ) ); // merges in any req.
		$ownQ   = $this->ownershipSQL( $ignoreowner );
		if ( ! empty( $ownQ ) ) {
			$additionalWhere = $this->spaceAnd( $additionalWhere ) . $ownQ; // adds str to query
		}
		#} / Ownership

		#} Append to sql (this also automatically deals with sortby and paging)
		$query .= $this->buildWhereStr( $whereStr, $additionalWhere ) . $this->buildSort( 'ID', 'DESC' ) . $this->buildPaging( 0, 1 );

		try {

			#} Prep & run query
			$queryObj = $this->prepare( $query, $params );
			return $wpdb->get_var( $queryObj );

		} catch ( Exception $e ) {

			#} General SQL Err
			$this->catchSQLError( $e );

		}

		return false;
	}

		/**
		 * helper - forces update of field for obj id + type,
		 * THIS IS HARD usage, not for beginners/non-directors.
		 * ... can break things if using this, so only use strictly for globally generic columns
		 * ... e.g. zbs_owner, which appears in all obj.
		 * ... ALWAYS use the DAL->contacts->whatever before this, where possible
		 * // NOTE NOTE - THIS does not update "lastupdated" for each obj... AVOID USE!
		 *
		 * @param int objID     object id
		 * @param int objTypeID objectType id
		 * @param string colname
		 *
		 * @return string
		 */
	public function setFieldByID( $args = array() ) {

		#} =========== LOAD ARGS ==============
		$defaultArgs = array(

			'objID'       => -1,
			'objTypeID'   => -1,

			'colname'     => '',
			'coldatatype' => '%d', // %d/s
			'newValue'    => -99,

		);
		foreach ( $defaultArgs as $argK => $argV ) {
			$$argK = $argV;
			if ( is_array( $args ) && isset( $args[ $argK ] ) ) {
				if ( is_array( $args[ $argK ] ) ) {
					$newData = $$argK;
					if ( ! is_array( $newData ) ) {
						$newData = array();
					} foreach ( $args[ $argK ] as $subK => $subV ) {
						$newData[ $subK ] = $subV;
					}$$argK = $newData;
				} else {
					$$argK = $args[ $argK ]; }
			}
		}
		#} =========== / LOAD ARGS =============

		// this'll only update columnames present in here:
		$restrictedToColumns = array( 'zbs_owner' );

		if ( in_array( $colname, $restrictedToColumns ) && $objID > 0 && $objTypeID > 0 && ! empty( $colname ) && ! empty( $coldatatype ) && $newValue !== -99 ) {

			global $wpdb;

			// got table?
			$tableName = $this->lazyTable( $objTypeID );

			if ( empty( $tableName ) ) {
				return false;
			}

			#} Attempt update
			if ( $wpdb->update(
				$tableName,
				array(

					$colname => $newValue,

				),
				array( // where
					'ID' => $objID,
				),
				array( // field data types
					$coldatatype,
				),
				array( // where data types
					'%d',
				)
			) !== false ) {

						// Successfully updated - Return id
						return $objID;

			} else {

				// FAILED update
				return false;

			}
		}

		return false;
	}

		// brutal switch for lazy tablenames
	public function lazyTable( $objType = -1 ) {

		global $ZBSCRM_t;

		switch ( $objType ) {

			case ZBS_TYPE_CONTACT:
				return $ZBSCRM_t['contacts'];
				break;
			// dal3:
			case ZBS_TYPE_COMPANY:
				return $ZBSCRM_t['companies'];
				break;
			case ZBS_TYPE_QUOTE:
				return $ZBSCRM_t['quotes'];
				break;
			case ZBS_TYPE_INVOICE:
				return $ZBSCRM_t['invoices'];
				break;
			case ZBS_TYPE_TRANSACTION:
				return $ZBSCRM_t['transactions'];
				break;
			case ZBS_TYPE_TASK:
				return $ZBSCRM_t['events'];
				break;
			case ZBS_TYPE_FORM:
				return $ZBSCRM_t['forms'];
				break;
			case ZBS_TYPE_LOG:
				return $ZBSCRM_t['logs'];
				break;
			case ZBS_TYPE_SEGMENT:
				return $ZBSCRM_t['segments'];
				break;
			case ZBS_TYPE_LINEITEM:
				return $ZBSCRM_t['lineitems'];
				break;
			case ZBS_TYPE_TASK_REMINDER:
				return $ZBSCRM_t['eventreminders'];
				break;
			case ZBS_TYPE_QUOTETEMPLATE:
				return $ZBSCRM_t['quotetemplates'];
				break;

		}

		return false;
	}

		// brutal switch for lazy tidy func
	public function lazyTidy( $objType = -1, $obj = false ) {

		switch ( $objType ) {

			case ZBS_TYPE_CONTACT:
				return $this->contacts->tidy_contact( $obj );
				break;
			// dal3:
			case ZBS_TYPE_COMPANY:
				return $this->companies->tidy_company( $obj );
				break;
			case ZBS_TYPE_QUOTE:
				return $this->quotes->tidy_quote( $obj );
				break;
			case ZBS_TYPE_INVOICE:
				return $this->invoices->tidy_invoice( $obj );
				break;
			case ZBS_TYPE_TRANSACTION:
				return $this->transactions->tidy_transaction( $obj );
				break;
			case ZBS_TYPE_TASK:
				return $this->events->tidy_event( $obj );
				break;
			case ZBS_TYPE_FORM:
				return $this->forms->tidy_form( $obj );
				break;
			case ZBS_TYPE_LOG:
				return $this->logs->tidy_log( $obj );
				break;
			case ZBS_TYPE_SEGMENT:
				return $this->segments->tidy_segment( $obj );
				break;
			case ZBS_TYPE_LINEITEM:
				return $this->lineitems->tidy_lineitem( $obj );
				break;
			case ZBS_TYPE_TASK_REMINDER:
				return $this->eventreminders->tidy_eventreminder( $obj );
				break;
			case ZBS_TYPE_QUOTETEMPLATE:
				return $this->quotetemplates->tidy_quotetemplate( $obj );
				break;

		}

		return false;
	}

		// guesses at a tidy... lazy, remove these if hit walls
	public function lazyTidyGeneric( $obj = false ) {

		$res = false;

		foreach ( $obj as $propKey => $prop ) {

			if ( ! is_array( $res ) ) {
				$res = array();
			}

			if ( $propKey != 'ID' && strpos( $propKey, '_' ) > 0 ) {

				// zbs_owner -> owner
				$newKey = substr( $propKey, strpos( $propKey, '_' ) + 1 );

				$res[ $newKey ] = $this->stripSlashes( $prop );

			} else {
				$res['id'] = $prop;
			}
		}

		return $res;
	}

		// appends a space, if req. (lazy helper for amongst queries)
	public function space( $str = '', $pre = false ) {

		if ( ! empty( $str ) ) {
			if ( $pre ) {
				return ' ' . $str;
			} else {
				return $str . ' ';
			}
		}

		return $str;
	}

		// appends a space and 'AND', if req. (lazy helper for amongst queries)
	public function spaceAnd( $str = '' ) {

		if ( ! empty( $str ) ) {
			return $str . ' AND ';
		}

		return $str;
	}

		// appends a space and 'Where', if req. (lazy helper for amongst queries)
	public function spaceWhere( $str = '' ) {

		$trimmedStr = trim( $str );
		if ( ! empty( $trimmedStr ) ) {
			return ' WHERE ' . $trimmedStr;
		}

		return $str;
	}

		// returns delimiter, if str != epty
		// used to be zeroBS_delimiterIf pre dal1
	public function delimiterIf( $delimiter, $ifStr = '' ) {

		if ( ! empty( $ifStr ) ) {
			return $delimiter;
		}

		return '';
	}

		// internal middle man for zeroBSCRM_stripSlashes where ALWAYS returns
	public function stripSlashes( $obj = false ) {

		return zeroBSCRM_stripSlashes( $obj, true );
	}

		// if it thinks str is json, it'll decode + return obj, otherwise returns str
		// this only works with arr/obj
		// Note that `[]` doesn't hydrate into array with this
	public function decodeIfJSON( $str = '' ) {

		if ( zeroBSCRM_isJson( $str ) ) {
			return json_decode( $str, true ); // true req. https://stackoverflow.com/questions/22878219/json-encode-turns-array-into-an-object
		}

		return $str;
	}

		/*
		 * Builds Custom Fields Order by Str
		 * .. ultimately returns $sortByField unless numeric, if so casts the custom field (varchar)
		 * .. into an INT/DECIMAL in MySQL for the search
		 */
	public function build_custom_field_order_by_str( $sortByField = '', $customField = array() ) {

		// check if this custom field requires any special casting in it's sort string

		// where the CF is a numeric field, we'll need to also use CAST(* AS SIGNED)
		if ( $customField[0] == 'numberint' ) {

			$sortByField = 'CAST(' . $sortByField . ' AS SIGNED)';

		}

		// where the CF is a decimal field, we'll need to use CAST(* AS DECIMAL)
		if ( $customField[0] == 'numberfloat' ) {

			$sortByField = 'CAST(' . $sortByField . ' AS DECIMAL(18,2))';
		}

		return $sortByField;
	}

		/*
		 * Build's an escaped imploded, DB safe CSV
		 * e.g. "'a','b','c'" as used in SELECT * FROM x WHERE y in ('a','b','c')
		 */
	public function build_csv( $array = array() ) {

		// only arrays
		if ( ! is_array( $array ) ) {
			return '';
		}

		// Generate escaped csv, e.g. 'Call','Email'
		$array = array_map(
			function ( $v ) {
				return "'" . esc_sql( $v ) . "'";
			},
			$array
		);

		// return
		return implode( ',', $array );
	}

		// takes wherestr + additionalwhere and outputs legit SQL
		// GENERIC helper for all queries :)
	public function buildWhereStr( $whereStr = '', $additionalWhere = '' ) {

		// echo 'W:'.$whereStr.'<br >AW:'.$additionalWhere.'!!<br ><Br >';

		#} Build
		$where = trim( $whereStr );

		#} Any additional
		if ( ! empty( $additionalWhere ) ) {
			if ( ! empty( $where ) ) {
				$where = $this->spaceAnd( $where );
			} else {
				$where = 'WHERE ';
			}
			$where .= $additionalWhere;
		}

		return $this->space( $where, true );
	}

		// add where's to SQL
		// +
		// feed in params
		// GENERIC helper for all queries :)
	public function buildWheres( $wheres = array(), $whereStr = '', $params = array(), $andOr = 'AND', $includeInitialWHERE = true ) {

		$ret = array(
			'where'  => $whereStr,
			'params' => $params,
		);
		if ( $andOr != 'AND' && $andOr != 'OR' ) {
			$andOr = 'AND';
		}

			// clear empty direct
		if ( isset( $wheres['direct'] ) && is_array( $wheres['direct'] ) && count( $wheres['direct'] ) == 0 ) {
			unset( $wheres['direct'] );
		}

		if ( is_array( $wheres ) && count( $wheres ) > 0 ) {
			foreach ( $wheres as $key => $whereArr ) {

				if ( empty( $ret['where'] ) && $includeInitialWHERE ) {
					$ret['where'] .= ' WHERE ';
				}

				// Where's are passed 2 ways, "direct":
				// array(SQL,array(params))
				if ( $key == 'direct' ) {

					// several under 1 direct
					foreach ( $whereArr as $directWhere ) {

						if ( isset( $directWhere[0] ) && isset( $directWhere[1] ) ) {

							// multi-direct ANDor
							if ( ! empty( $ret['where'] ) && $ret['where'] != ' WHERE ' ) {
								$ret['where'] .= ' ' . $andOr . ' ';
							}

							// ++ query
							$ret['where'] .= $directWhere[0];

							// ++ params (any number, if set)
							if ( is_array( $directWhere[1] ) ) {
								foreach ( $directWhere[1] as $x ) {
									$ret['params'][] = $x;
								}
							} else {
								$ret['params'][] = $directWhere[1];
							}
						}
					}
				} else {

					if ( ! empty( $ret['where'] ) && $ret['where'] != ' WHERE ' ) {
						$ret['where'] .= ' ' . $andOr . ' ';
					}

					// Other way:
					// irrelevantKEY => array(fieldname,operator,comparisonval,array(params))
					// e.g. array('ID','=','%d',array(123))
					// e.g. array('ID','IN','(SUBSELECT)',array(123))

					// build where (e.g. "X = Y" or "Z IN (1,2,3)")
					$ret['where'] .= $whereArr[0] . ' ' . $whereArr[1] . ' ' . $whereArr[2];

					// ++ params (any number, if set)
					if ( isset( $whereArr[3] ) ) {
						if ( is_array( $whereArr[3] ) ) {
							foreach ( $whereArr[3] as $x ) {
								$ret['params'][] = $x;
							}
						} else {
							$ret['params'][] = $whereArr[3];
						}
					}

					/*
					legacy

					// add in - NOTE: this is TRUSTING key + whereArr[0]
					$ret['where'] .= $key.' '.$whereArr[0].' '.$whereArr[2];

					// feed in params
					$ret['params'][] = $whereArr[1];
					*/

				}
			}
		}

		return $ret;
	}

	/**
	 * Builds JOIN SQL for DAL queries.
	 *
	 * This was built for use by segments. Note that it's currently hard-coded for contacts, but could easily be extended.
	 *
	 * Note that $joins is an array of arrays, each of which contain three keys:
	 *    `table_query`: a subquery used to derive a table
	 *    `table_alias`: an alias we can use for this table
	 *    `vars`: the values that will be used by $wpdb->prepare()
	 *
	 * @param array $joins Array of arrays of join params.
	 * @param bool  $match_all Whether to match all (restrictive) or match any (UNION).
	 *
	 * @return array Looks like array( $join_sql, $join_params )
	 */
	public function build_joins( $joins = array(), $match_all = true ) {
		if ( ! is_array( $joins ) || empty( $joins ) ) {
			return '';
		}
		$join_sql    = ' JOIN ( ';
		$join_params = array_merge( ...array_column( $joins, 'vars' ) );

		if ( ! $match_all ) {
			// Simple; just join the tables!
			$join_sql .= implode( ' UNION ', array_column( $joins, 'table_query' ) );
		} else {
			$join_count        = 0;
			$first_table_alias = '';

			foreach ( $joins as $join ) {
				$table_alias = $join['table_alias'] . '_' . $join_count;
				if ( $join_count === 0 ) {
					$first_table_alias = $table_alias;

					$join_sql .= "SELECT {$table_alias}.contact_id FROM ({$join['table_query']}) AS {$table_alias}";
				} else {
					$join_sql .= " JOIN ({$join['table_query']}) AS {$table_alias} ON {$table_alias}.contact_id = {$first_table_alias}.contact_id";
				}
				++$join_count;
			}
		}

		$join_sql .= ') AS join_table ON join_table.contact_id = ID';

		return array( $join_sql, $join_params );
	}

		// takes sortby field + order and returns str if not empty :)
		// Note: Is trusting legitimacy of $sortByField as parametised in wp db doesn't seem to work
		// can also now pass array (multi-sort)
		// e.g. $sortByField = 'zbsc_fname' OR $sortByField = array('zbsc_fname'=>'ASC','zbsc_lname' => 'DESC');
	public function buildSort( $sortByField = '', $sortOrder = 'ASC' ) {

		#} Sort by
		if ( ! is_array( $sortByField ) && ! empty( $sortByField ) ) {

			$sortOrder = strtoupper( $sortOrder );

			if ( ! in_array( $sortOrder, array( 'DESC', 'ASC' ) ) ) {
				$sortOrder = 'DESC';
			}
			return ' ORDER BY ' . $sortByField . ' ' . $sortOrder;

		} elseif ( is_array( $sortByField ) ) {

			$orderByStr = '';
			foreach ( $sortByField as $field => $order ) {

				if ( ! empty( $orderByStr ) ) {
					$orderByStr .= ', ';
				}
				$orderByStr .= $field . ' ' . strtoupper( $order );

			}

			if ( ! empty( $orderByStr ) ) {
				return ' ORDER BY ' . $orderByStr;
			}
		}

		return '';
	}

		// takes $page and $perPage and adds limit str if req.
	public function buildPaging( $page = -1, $perPage = -1 ) {

		#} Pagination
		if ( $page == -1 && $perPage == -1 ) {

			// NO LIMITS :o

		} else {

			$perPage = (int) $perPage;

			// Because SQL USING zero indexed page numbers, we remove -1 here
			// ... DO NOT change this without seeing usage of the function (e.g. list view) - which'll break
			$page = (int) $page - 1;
			if ( $page < 0 ) {
				$page = 0;
			}

			// page needs multiplying :)
			if ( $page > 0 ) {
				$page = $page * $perPage;
			}

			// check params realistic
			// todo, for now, brute pass
			return ' LIMIT ' . (int) $page . ',' . (int) $perPage;

		}

		return '';
	}

		// builds WHERE query for meta key / val pairs.
		// e.g. Get customers in Company id 9:
		// ... contacts where their ID is in post_id WHERE meta_key = zbs_company and meta_value = 9
		// infill for half-migrated stuff
	public function buildWPMetaQueryWhere( $metaKey = -1, $metaVal = -1 ) {

		if ( ! empty( $metaKey ) && ! empty( $metaVal ) ) {

			global $wpdb;
			return array(

				'sql'    => 'ID IN (SELECT DISTINCT post_id FROM ' . $wpdb->prefix . 'postmeta WHERE meta_key = %s AND meta_value = %d)',
				'params' => array( $metaKey, $metaVal ),
			);

		}

		return false;
	}

	/**
	 * Generates GROUP_CONCAT SQL compatible with both SQLite and MySQL
	 *
	 * @param string $field Field that will be concatenated.
	 * @param string $separator Separator added between concatenated fields.
	 *
	 * @return string
	 */
	public function build_group_concat( $field, $separator ) {
		$db_engine = jpcrm_database_engine();
		if ( $db_engine === 'sqlite' ) {
			return sprintf( 'GROUP_CONCAT(%s, "%s")', $field, $separator );
		} else {
			return sprintf( 'GROUP_CONCAT(%s SEPARATOR "%s")', $field, $separator );
		}
	}

		// this returns %s etc. for common field names, will default to %s unless somt obv a date
	public function getTypeStr( $fieldKey = '' ) {

		if ( $fieldKey == 'zbs_site' || $fieldKey == 'zbs_team' || $fieldKey == 'zbs_owner' ) {
			return '%d';
		}

		if ( strpos( $fieldKey, '_created' ) > 0 ) {
			return '%d';
		}
		if ( strpos( $fieldKey, '_lastupdated' ) > 0 ) {
			return '%d';
		}
		if ( strpos( $fieldKey, '_lastcontacted' ) > 0 ) {
			return '%d';
		}
		if ( strpos( $fieldKey, '_id' ) > 0 ) {
			return '%d';
		}
		if ( strpos( $fieldKey, '_ID' ) > 0 ) {
			return '%d';
		}
		if ( $fieldKey == 'id' || $fieldKey == 'ID' ) {
			return '%d';
		}

		return '%s';
	}

		/*
		 * Converts verbs such as 'equal' to '='
		 * Note: these are relatively idiosyncratic as were part of segmentation layer but got generalised here
		 */
	public function comparison_to_sql_symbol( $comparison_verb ) {

		switch ( $comparison_verb ) {

			case 'equal':
			case 'equals':
				return '=';
				break;
			case 'notequal':
				return '<>';
				break;
			case 'larger':
				return '>';
				break;
			case 'largerequal':
				return '>=';
				break;
			case 'less':
				return '<';
				break;
			case 'lessequal':
				return '<=';
				break;
			case 'floatrange':
				// this is somewhat like hotglue, e.g. `WHERE column_a [BETWEEN %s AND ] %s`
				return 'BETWEEN %s AND ';
				break;
			case 'intrange':
				// this is somewhat like hotglue, e.g. `WHERE column_a [BETWEEN %d AND ] %d`
				return 'BETWEEN %d AND ';
				break;

		}

		return false;
	}

	public function prepare( $sql = '', $params = array() ) {

		global $wpdb;

		// empty arrays causes issues in wpdb prepare
		if ( is_array( $params ) && count( $params ) <= 0 ) {
			return $sql;
		}

		// normal return
		return $wpdb->prepare( $sql, $params );
	}

		// not yet used
	public function catchSQLError( $errObj = -1 ) {

		// log?

		return false;
	}

		/*
		* Retrieves a key-value pair from centralised global
		*
		* @param string $key
		*
		*/
	public function get_cache_var( $key ) {

		if ( isset( $this->cache[ $key ] ) ) {

			return $this->cache[ $key ];

		}

		return false;
	}

		/*
		* Stores a key-value pair in centralised global
		*  This is designed to allow basic caching at a DAL level without spawning multiple globals
		*
		* @param string $key
		* @param mixed $value
		*
		*/
	public function update_cache_var( $key, $value ) {

		// simplistic
		$this->cache[ $key ] = $value;
	}

	/*
	======================================================
	/ To be sorted helpers
	======================================================
	*/

	/*
	======================================================
	Middle Man funcs (until DAL3.1)
	======================================================
	*/

	// TEMP LOGGING to BACKTRACE LEGACY CALLS:
	/*
	CREATE TABLE `templogs` (
	`ID` int(32) NOT NULL AUTO_INCREMENT PRIMARY KEY,
	`funcname` varchar(500) NOT NULL,
	`filename` varchar(500) NOT NULL,
	`notes` longtext NOT NULL,
	`time` int(14) NOT NULL
	) ENGINE='InnoDB' COLLATE 'utf8_general_ci'; */

	// temporary function used in v3.0 prep to weed out bad/old reference use
	// logs to table 'templogs' if table exists
	// left in until 3.1 - see #gh-146
	private function v3templogBacktrace( $funcName = '', $caller = false, $backtrace = false ) {

		global $ZBSCRM_t, $wpdb;

		$tableExist = $wpdb->get_results( "SHOW TABLES LIKE 'templogs'" );
		if ( count( $tableExist ) >= 1 ) {

			if ( $wpdb->insert(
				'templogs',
				array(
					// fields
					'funcname' => $funcName,
					'filename' => $caller['file'] . ':' . $caller['line'],
					'notes'    => print_r( $backtrace, 1 ), // ,
					'time'     => time(),
				),
				array(
					'%s',
					'%s',
					'%s',
					'%d',
				)
			) <= 0 ) {
				exit( 'ERROR: Failed to log backtrace error (' . $funcName . ')!<pre>' . print_r( array( $backtrace, $caller, $funcName ), 1 ) . '</pre>' );
			}
		}

		return true;
	}

	/**
	 * Counts below are as of 19 November 2024.
	 */

	/* Stripe Sync: 1 */
	public function getContact( ...$args ) {

		// hard-typed
		$funcName = 'getContact';

		// retrieve backtrace
		$backtrace = debug_backtrace( DEBUG_BACKTRACE_PROVIDE_OBJECT, 1 );
		$caller    = array_shift( $backtrace );

		// log to db, if logging
		$this->v3templogBacktrace( $funcName, $caller, $backtrace );

		// return, if available
		if ( method_exists( $this->contacts, $funcName ) ) {
			return call_user_func_array( array( $this->contacts, $funcName ), func_get_args() );
		}

		// ultimate fallback
		return false;
	}

	/* Automations: 1 */
	public function getContacts( ...$args ) {

		// hard-typed
		$funcName = 'getContacts';

		// retrieve backtrace
		$backtrace = debug_backtrace( DEBUG_BACKTRACE_PROVIDE_OBJECT, 1 );
		$caller    = array_shift( $backtrace );

		// log to db, if logging
		$this->v3templogBacktrace( $funcName, $caller, $backtrace );

		// return, if available
		if ( method_exists( $this->contacts, $funcName ) ) {
			return call_user_func_array( array( $this->contacts, $funcName ), func_get_args() );
		}

		// ultimate fallback
		return false;
	}

	/* Stripe Sync: 1, Awesome Support: 1 */
	public function addUpdateContact( ...$args ) {

		// hard-typed
		$funcName = 'addUpdateContact';

		// retrieve backtrace
		$backtrace = debug_backtrace( DEBUG_BACKTRACE_PROVIDE_OBJECT, 1 );
		$caller    = array_shift( $backtrace );

		// log to db, if logging
		$this->v3templogBacktrace( $funcName, $caller, $backtrace );

		// return, if available
		if ( method_exists( $this->contacts, $funcName ) ) {
			return call_user_func_array( array( $this->contacts, $funcName ), func_get_args() );
		}

		// ultimate fallback
		return false;
	}

	/* Automations: 1, Livestorm: 1 */ // phpcs:ignore Squiz.PHP.CommentedOutCode.Found
	public function addUpdateContactTags( ...$args ) {

		// hard-typed
		$funcName = 'addUpdateContactTags';

		// retrieve backtrace
		$backtrace = debug_backtrace( DEBUG_BACKTRACE_PROVIDE_OBJECT, 1 );
		$caller    = array_shift( $backtrace );

		// log to db, if logging
		$this->v3templogBacktrace( $funcName, $caller, $backtrace );

		// return, if available
		if ( method_exists( $this->contacts, $funcName ) ) {
			return call_user_func_array( array( $this->contacts, $funcName ), func_get_args() );
		}

		// ultimate fallback
		return false;
	}

	/* Automations: 1 */
	public function deleteContact( ...$args ) {

		// hard-typed
		$funcName = 'deleteContact';

		// retrieve backtrace
		$backtrace = debug_backtrace( DEBUG_BACKTRACE_PROVIDE_OBJECT, 1 );
		$caller    = array_shift( $backtrace );

		// log to db, if logging
		$this->v3templogBacktrace( $funcName, $caller, $backtrace );

		// return, if available
		if ( method_exists( $this->contacts, $funcName ) ) {
			return call_user_func_array( array( $this->contacts, $funcName ), func_get_args() );
		}

		// ultimate fallback
		return false;
	}

	/* Advanced Segments: 4 */
	public function segmentBuildDirectOrClause( ...$args ) { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid,VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable,Squiz.Commenting.FunctionComment.WrongStyle
		// hard-typed
		$func_name = 'segmentBuildDirectOrClause';

		// retrieve backtrace
		$backtrace = debug_backtrace( DEBUG_BACKTRACE_PROVIDE_OBJECT, 1 ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace
		$caller    = array_shift( $backtrace );

		// log to db, if logging
		$this->v3templogBacktrace( $func_name, $caller, $backtrace );

		if ( method_exists( $this->segments, $func_name ) ) {
			return call_user_func_array( array( $this->segments, $func_name ), func_get_args() );
		}

		// ultimate fallback
		return false;
	}

	/*
	======================================================
	/ Middle Man funcs (until DAL3.0)
	======================================================
	*/
} // / DAL class

// Initialize the static mitigation cache.
zbsDAL::reset_mitigation_cache_for_issue_3504();
