<?php
/*
 * Jetpack CRM
 * https://jetpackcrm.com
 * Copyright 2021 Automattic
 */

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

/*
	WIP:

		each placeholder record will have an array such as:

	{placeholder_model}:

		array(

			'description'     => 'Contact ID',
			'origin'          => 'Contact Object model',
			'replace_str'     => '##CONTACT-ID##',
			'aliases'         => array('##CID##'),
			'associated_type' => ZBS_TYPE_CONTACT,
			'expected_format' => 'str' // future proofing
			'available_in'    => array(

					// tooling allowed to use this, from:
					// if specified, otherwise areas may use type to identify which placeholders are available
					'system_email_templates',
					'mail_campaigns',
					'quote' e.g:
						'quote_templates',
						'quote_editor',
					'invoice' e.g:
						'invoice_editor',
					'single_email',

			)

		)

*/

/**
 * jpcrm_templating_placeholders is the placeholder layer in Jetpack CRM 4+
 *
 * @author   Woody Hayday <hello@jetpackcrm.com>
 * @version  4.0
 * @access   public
 * @see      https://kb.jetpackcrm.com
 */
class jpcrm_templating_placeholders {

		// default set of placeholders, to get amended on init (Custom fields etc.)
		private $placeholders = array();

	// stores common links e.g. contact fields in quotes
	// for now these repreresent the 1:1 relational links in the db
	// later, as the DAL is extended, this could take advantage of
	// a better mapping integral to the DAL, perhaps extending
	// the $linkedToObjectTypes system.
	// for now it's an exception, so hard-typing:
	private $available_in_links = array(

		'contact' => array(
			'quote',
			'invoice',
			'transaction',
			'event',
		),
		'company' => array(
			// 'quote',
			'invoice',
			'transaction',
			'event',
		),

	);

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

		// Build out list of placeholders
		$this->build_placeholders();
	}
	// ===========  / INIT ===========================================================
	// ===============================================================================

	/**
	 * Fills out default placeholders
	 * This is executed on init because we want to include the full translatable strings
	 * ... which we cannot set as constants because we want the __()
	 *
	 * @return array of all placeholders
	 */
	private function default_placeholders() {

		$this->placeholders = array(

			// CRM global placeholders, e.g. business name (from settings)
			'global'      => array(

				'biz-name'           => array(

					'description'     => __( 'Business name', 'zero-bs-crm' ),
					'origin'          => __( 'Global', 'zero-bs-crm' ),
					'expected_format' => 'str',
					'available_in'    => array(),
					'associated_type' => false,
					'replace_str'     => '##BIZ-NAME##',
					'aliases'         => array( '###BIZNAME###', '##BIZNAME##' ),
				),

				'biz-state'          => array(

					'description'     => __( 'Business state', 'zero-bs-crm' ),
					'origin'          => __( 'Global', 'zero-bs-crm' ),
					'expected_format' => 'str',
					'available_in'    => array(),
					'associated_type' => false,
					'replace_str'     => '##BIZ-STATE##',
					'aliases'         => array( '##BIZSTATE##' ),
				),

				'biz-logo'           => array(

					'description'     => __( 'Business logo', 'zero-bs-crm' ),
					'origin'          => __( 'Global', 'zero-bs-crm' ),
					'expected_format' => 'html',
					'available_in'    => array(),
					'associated_type' => false,
					'replace_str'     => '##BIZ-LOGO##',
				),

				'biz-info'           => array(

					'description'     => __( 'Table with your business information (HTML)', 'zero-bs-crm' ),
					'origin'          => __( 'Global', 'zero-bs-crm' ),
					'expected_format' => 'html',
					'available_in'    => array(),
					'associated_type' => false,
					'replace_str'     => '##BIZ-INFO##',
					'aliases'         => array( '###BIZINFOTABLE###', '###BIZ-INFO###', '###FOOTERBIZDEETS###', '##INVOICE-BIZ-INFO##' ),
				),

				'biz-your-name'      => array(

					'description'     => __( 'Business: Your Name', 'zero-bs-crm' ),
					'origin'          => __( 'Global', 'zero-bs-crm' ),
					'expected_format' => 'str',
					'available_in'    => array(),
					'associated_type' => false,
					'replace_str'     => '##BIZ-YOUR-NAME##',
					'aliases'         => array(),
				),

				'biz-your-email'     => array(

					'description'     => __( 'Business: Your Email', 'zero-bs-crm' ),
					'origin'          => __( 'Global', 'zero-bs-crm' ),
					'expected_format' => 'str',
					'available_in'    => array(),
					'associated_type' => false,
					'replace_str'     => '##BIZ-YOUR-EMAIL##',
					'aliases'         => array(),
				),

				'biz-your-url'       => array(

					'description'     => __( 'Business: Your URL', 'zero-bs-crm' ),
					'origin'          => __( 'Global', 'zero-bs-crm' ),
					'expected_format' => 'str',
					'available_in'    => array(),
					'associated_type' => false,
					'replace_str'     => '##BIZ-YOUR-URL##',
					'aliases'         => array(),
				),

				'biz-extra'          => array(

					'description'     => __( 'Business: Extra Info', 'zero-bs-crm' ),
					'origin'          => __( 'Global', 'zero-bs-crm' ),
					'expected_format' => 'str',
					'available_in'    => array(),
					'associated_type' => false,
					'replace_str'     => '##BIZ-EXTRA-INFO##',
					'aliases'         => array(),
				),

				'social-links'       => array(

					'description'     => __( 'Social links', 'zero-bs-crm' ),
					'origin'          => __( 'Global', 'zero-bs-crm' ),
					'expected_format' => 'html',
					'available_in'    => array(),
					'associated_type' => false,
					'replace_str'     => '##SOCIAL-LINKS##',
				),

				'unsub-line'         => array(

					'description'     => __( 'Unsubscribe line', 'zero-bs-crm' ),
					'origin'          => __( 'Global', 'zero-bs-crm' ),
					'expected_format' => 'html',
					'available_in'    => array(),
					'associated_type' => false,
					'replace_str'     => '##UNSUB-LINE##',
					'aliases'         => array( '###UNSUB-LINE###', '###UNSUB###', '###UNSUBSCRIBE###', '###FOOTERUNSUBDEETS###' ),
				),

				'powered-by'         => array(

					'description'     => __( 'Powered by', 'zero-bs-crm' ),
					'origin'          => __( 'Global', 'zero-bs-crm' ),
					'expected_format' => 'html',
					'available_in'    => array(),
					'associated_type' => false,
					'replace_str'     => '##POWERED-BY##',
					'aliases'         => array( '###POWEREDBYDEETS###' ),
				),

				'login-link'         => array(

					'description'     => __( 'CRM login link (HTML)', 'zero-bs-crm' ),
					'origin'          => __( 'Global', 'zero-bs-crm' ),
					'expected_format' => 'html',
					'available_in'    => array(),
					'associated_type' => false,
					'replace_str'     => '##LOGIN-LINK##',
					'aliases'         => array( '###LOGINLINK###' ),
				),

				'login-button'       => array(

					'description'     => __( 'CRM login link button (HTML)', 'zero-bs-crm' ),
					'origin'          => __( 'Global', 'zero-bs-crm' ),
					'expected_format' => 'html',
					'available_in'    => array(),
					'associated_type' => false,
					'replace_str'     => '##LOGIN-BUTTON##',
					'aliases'         => array( '###LOGINBUTTON###' ),
				),

				'login-url'          => array(

					'description'     => __( 'CRM login URL', 'zero-bs-crm' ),
					'origin'          => __( 'Global', 'zero-bs-crm' ),
					'expected_format' => 'str',
					'available_in'    => array(),
					'associated_type' => false,
					'replace_str'     => '##LOGIN-URL##',
					'aliases'         => array( '###LOGINURL###', '###ADMINURL###' ),
				),

				'portal-link'        => array(

					'description'     => __( 'Portal link (HTML)', 'zero-bs-crm' ),
					'origin'          => __( 'Global', 'zero-bs-crm' ),
					'expected_format' => 'html',
					'available_in'    => array(),
					'associated_type' => false,
					'replace_str'     => '##PORTAL-LINK##',
					'aliases'         => array( '###PORTALLINK###' ),
				),

				'portal-view-button' => array(

					'description'     => __( '"View in Portal" button (HTML)', 'zero-bs-crm' ),
					'origin'          => __( 'Global', 'zero-bs-crm' ),
					'expected_format' => 'html',
					'available_in'    => array(),
					'associated_type' => false,
					'replace_str'     => '##PORTAL-VIEW-BUTTON##',
					'aliases'         => array( '###VIEWINPORTAL###' ),
				),

				'portal-button'      => array(

					'description'     => __( 'Portal link button (HTML)', 'zero-bs-crm' ),
					'origin'          => __( 'Global', 'zero-bs-crm' ),
					'expected_format' => 'html',
					'available_in'    => array(),
					'associated_type' => false,
					'replace_str'     => '##PORTAL-BUTTON##',
					'aliases'         => array( '###PORTALBUTTON###' ),
				),

				'portal-url'         => array(

					'description'     => __( 'Portal URL', 'zero-bs-crm' ),
					'origin'          => __( 'Global', 'zero-bs-crm' ),
					'expected_format' => 'str',
					'available_in'    => array(),
					'associated_type' => false,
					'replace_str'     => '##PORTAL-URL##',
					'aliases'         => array( '###PORTALURL###' ),
				),

				'css'                => array(

					'description'     => __( 'CSS (restricted to HTML templates)', 'zero-bs-crm' ),
					'origin'          => __( 'Global', 'zero-bs-crm' ),
					'expected_format' => 'html',
					'available_in'    => array(),
					'associated_type' => false,
					'replace_str'     => '##CSS##',
					'aliases'         => array( '###CSS###' ),
				),

				'title'              => array(

					'description'     => __( 'Generally used to fill HTML title tags', 'zero-bs-crm' ),
					'origin'          => __( 'Global', 'zero-bs-crm' ),
					'expected_format' => 'str',
					'available_in'    => array(),
					'associated_type' => false,
					'replace_str'     => '##TITLE##',
					'aliases'         => array( '###TITLE###' ),
				),

				'msg-content'        => array(

					'description'     => __( 'Message content (restricted to some email templates)', 'zero-bs-crm' ),
					'origin'          => __( 'Global', 'zero-bs-crm' ),
					'expected_format' => 'html',
					'available_in'    => array(),
					'associated_type' => false,
					'replace_str'     => '##MSG-CONTENT##',
					'aliases'         => array( '###MSGCONTENT###' ),
				),

				'email'              => array(

					'description'     => __( 'Email (generally used to insert user email)', 'zero-bs-crm' ),
					'origin'          => __( 'Global', 'zero-bs-crm' ),
					'expected_format' => 'str',
					'available_in'    => array(),
					'associated_type' => false,
					'replace_str'     => '##EMAIL##',
					'aliases'         => array( '###EMAIL###' ),
				),

				'password'           => array(

					'description'     => __( 'Inserts a link where one can reset their password', 'zero-bs-crm' ),
					'origin'          => __( 'Global', 'zero-bs-crm' ),
					'expected_format' => 'str',
					'available_in'    => array(),
					'associated_type' => false,
					'replace_str'     => '##PASSWORD##',
					'aliases'         => array( '##PASSWORD-RESET-LINK##', '###PASSWORD###' ),
				),

				'email-from-name'    => array(

					'description'     => __( '"From" Name when sending email', 'zero-bs-crm' ),
					'origin'          => __( 'Global', 'zero-bs-crm' ),
					'expected_format' => 'email',
					'available_in'    => array(),
					'associated_type' => false,
					'replace_str'     => '##EMAIL-FROM-NAME##',
					'aliases'         => array( '###FROMNAME###' ),
				),

			),

			'contact'     => array(

				'contact-fullname' => array(

					'description'     => __( 'Contact full name', 'zero-bs-crm' ),
					'origin'          => __( 'Contact Information', 'zero-bs-crm' ),
					'available_in'    => array(),
					'associated_type' => ZBS_TYPE_CONTACT,
					'replace_str'     => '##CONTACT-FULLNAME##',
					'expected_format' => 'str',
					'aliases'         => array( '##CUSTOMERNAME##', '##CUSTOMER-FULLNAME##' ),
				),

			),
			'company'     => array(),
			'quote'       => array(

				'quote-content'  => array(

					'description'     => __( 'Quote content (HTML)', 'zero-bs-crm' ),
					'origin'          => __( 'Quote Builder', 'zero-bs-crm' ),
					'expected_format' => 'html',
					'available_in'    => array(),
					'associated_type' => ZBS_TYPE_QUOTE,
					'replace_str'     => '##QUOTE-CONTENT##',
					'aliases'         => array( '###QUOTECONTENT###' ),
				),

				'quote-title'    => array(

					'description'     => __( 'Quote title', 'zero-bs-crm' ),
					'origin'          => __( 'Quote Builder', 'zero-bs-crm' ),
					'expected_format' => 'str',
					'available_in'    => array(),
					'associated_type' => ZBS_TYPE_QUOTE,
					'replace_str'     => '##QUOTE-TITLE##',
					'aliases'         => array( '###QUOTETITLE###', '##QUOTETITLE##' ),
				),

				'quote-value'    => array(

					'description'     => __( 'Quote value', 'zero-bs-crm' ),
					'origin'          => __( 'Quote Builder', 'zero-bs-crm' ),
					'expected_format' => 'str',
					'available_in'    => array(),
					'associated_type' => ZBS_TYPE_QUOTE,
					'replace_str'     => '##QUOTE-VALUE##',
					'aliases'         => array( '###QUOTEVALUE###', '##QUOTEVALUE##' ),
				),

				'quote-date'     => array(

					'description'     => __( 'Quote date', 'zero-bs-crm' ),
					'origin'          => __( 'Quote Builder', 'zero-bs-crm' ),
					'expected_format' => 'str',
					'available_in'    => array(),
					'associated_type' => ZBS_TYPE_QUOTE,
					'replace_str'     => '##QUOTE-DATE##',
					'aliases'         => array( '###QUOTEDATE###', '##QUOTEDATE##' ),
				),

				'quote-url'      => array(

					'description'     => __( 'Quote URL', 'zero-bs-crm' ),
					'origin'          => __( 'Quote Builder', 'zero-bs-crm' ),
					'expected_format' => 'str',
					'available_in'    => array(),
					'associated_type' => ZBS_TYPE_QUOTE,
					'replace_str'     => '##QUOTE-URL##',
					'aliases'         => array( '###QUOTEURL###' ),
				),

				'quote-edit-url' => array(

					'description'     => __( 'Quote edit URL', 'zero-bs-crm' ),
					'origin'          => __( 'Quote Builder', 'zero-bs-crm' ),
					'expected_format' => 'str',
					'available_in'    => array(),
					'associated_type' => ZBS_TYPE_QUOTE,
					'replace_str'     => '##QUOTE-EDIT-URL##',
					'aliases'         => array( '###QUOTEEDITURL###' ),
				),

			),
			'invoice'     => array(

				'invoice-title'               => array(

					'description'     => __( 'Invoice title', 'zero-bs-crm' ),
					'origin'          => __( 'Invoice Builder', 'zero-bs-crm' ),
					'expected_format' => 'str',
					'available_in'    => array(),
					'associated_type' => ZBS_TYPE_INVOICE,
					'replace_str'     => '##INVOICE-TITLE##',
					'aliases'         => array( '###INVOICETITLE###' ),
				),

				'logo-class'                  => array(

					'description'     => __( 'Invoice logo CSS class', 'zero-bs-crm' ),
					'origin'          => __( 'Invoice Builder', 'zero-bs-crm' ),
					'expected_format' => 'str',
					'available_in'    => array(),
					'associated_type' => ZBS_TYPE_INVOICE,
					'replace_str'     => '##LOGO-CLASS##',
					'aliases'         => array( '###LOGOCLASS###' ),
				),

				'logo-url'                    => array(

					'description'     => __( 'Invoice logo URL', 'zero-bs-crm' ),
					'origin'          => __( 'Invoice Builder', 'zero-bs-crm' ),
					'expected_format' => 'str',
					'available_in'    => array(),
					'associated_type' => ZBS_TYPE_INVOICE,
					'replace_str'     => '##LOGO-URL##',
					'aliases'         => array( '###LOGOURL###' ),
				),

				'invoice-number'              => array(

					'description'     => __( 'Invoice number', 'zero-bs-crm' ),
					'origin'          => __( 'Invoice Builder', 'zero-bs-crm' ),
					'expected_format' => 'str',
					'available_in'    => array(),
					'associated_type' => ZBS_TYPE_INVOICE,
					'replace_str'     => '##INVOICE-NUMBER##',
					'aliases'         => array( '###INVNOSTR###' ),
				),

				'invoice-date'                => array(

					'description'     => __( 'Invoice date', 'zero-bs-crm' ),
					'origin'          => __( 'Invoice Builder', 'zero-bs-crm' ),
					'expected_format' => 'str',
					'available_in'    => array(),
					'associated_type' => ZBS_TYPE_INVOICE,
					'replace_str'     => '##INVOICE-DATE##',
					'aliases'         => array( '###INVDATESTR###' ),
				),

				'invoice-id-styles'           => array(

					'description'     => __( 'Invoice ID styles', 'zero-bs-crm' ),
					'origin'          => __( 'Invoice Builder', 'zero-bs-crm' ),
					'expected_format' => 'str',
					'available_in'    => array(),
					'associated_type' => ZBS_TYPE_INVOICE,
					'replace_str'     => '##INVOICE-ID-STYLES##',
					'aliases'         => array( '###INVIDSTYLES###' ),
				),

				'invoice-ref'                 => array(

					'description'     => __( 'Invoice reference', 'zero-bs-crm' ),
					'origin'          => __( 'Invoice Builder', 'zero-bs-crm' ),
					'expected_format' => 'str',
					'available_in'    => array(),
					'associated_type' => ZBS_TYPE_INVOICE,
					'replace_str'     => '##INVOICE-REF##',
					'aliases'         => array( '###REF###' ),
				),

				'invoice-due-date'            => array(

					'description'     => __( 'Invoice due date', 'zero-bs-crm' ),
					'origin'          => __( 'Invoice Builder', 'zero-bs-crm' ),
					'expected_format' => 'str',
					'available_in'    => array(),
					'associated_type' => ZBS_TYPE_INVOICE,
					'replace_str'     => '##INVOICE-DUE-DATE##',
					'aliases'         => array( '###DUEDATE###' ),
				),

				'invoice-biz-class'           => array(

					'description'     => __( 'CSS class for table with your business info', 'zero-bs-crm' ),
					'origin'          => __( 'Invoice Builder', 'zero-bs-crm' ),
					'expected_format' => 'str',
					'available_in'    => array(),
					'associated_type' => ZBS_TYPE_INVOICE,
					'replace_str'     => '##INVOICE-BIZ-CLASS##',
					'aliases'         => array( '###BIZCLASS###' ),
				),

				'invoice-customer-info'       => array(

					'description'     => __( 'Table with assigned contact information (HTML)', 'zero-bs-crm' ),
					'origin'          => __( 'Invoice Builder', 'zero-bs-crm' ),
					'expected_format' => 'html',
					'available_in'    => array(),
					'associated_type' => ZBS_TYPE_INVOICE,
					'replace_str'     => '##INVOICE-CUSTOMER-INFO##',
					'aliases'         => array( '###CUSTINFOTABLE###' ),
				),

				'invoice-from-name'           => array(

					'description'     => __( 'Name of company issuing the invoice', 'zero-bs-crm' ),
					'origin'          => __( 'Statement Builder', 'zero-bs-crm' ),
					'expected_format' => 'str',
					'available_in'    => array(),
					'associated_type' => ZBS_TYPE_INVOICE,
					'replace_str'     => '##INVOICE-FROM-NAME##',
					'aliases'         => array(),
				),

				'invoice-table-headers'       => array(

					'description'     => __( 'Table headers for invoice line items (HTML)', 'zero-bs-crm' ),
					'origin'          => __( 'Invoice Builder', 'zero-bs-crm' ),
					'expected_format' => 'html',
					'available_in'    => array(),
					'associated_type' => ZBS_TYPE_INVOICE,
					'replace_str'     => '##INVOICE-TABLE-HEADERS##',
					'aliases'         => array( '###TABLEHEADERS###' ),
				),

				'invoice-line-items'          => array(

					'description'     => __( 'Invoice line items (HTML)', 'zero-bs-crm' ),
					'origin'          => __( 'Invoice Builder', 'zero-bs-crm' ),
					'expected_format' => 'html',
					'available_in'    => array(),
					'associated_type' => ZBS_TYPE_INVOICE,
					'replace_str'     => '##INVOICE-LINE-ITEMS##',
					'aliases'         => array( '###LINEITEMS###' ),
				),

				'invoice-totals-table'        => array(

					'description'     => __( 'Invoice totals table (HTML)', 'zero-bs-crm' ),
					'origin'          => __( 'Invoice Builder', 'zero-bs-crm' ),
					'expected_format' => 'html',
					'available_in'    => array(),
					'associated_type' => ZBS_TYPE_INVOICE,
					'replace_str'     => '##INVOICE-TOTALS-TABLE##',
					'aliases'         => array( '###TOTALSTABLE###' ),
				),

				'pre-invoice-payment-details' => array(

					'description'     => __( 'Text before invoice payment details', 'zero-bs-crm' ),
					'origin'          => __( 'Invoice Builder', 'zero-bs-crm' ),
					'expected_format' => 'html',
					'available_in'    => array(),
					'associated_type' => ZBS_TYPE_INVOICE,
					'replace_str'     => '##PRE-INVOICE-PAYMENT-DETAILS##',
					'aliases'         => array( '###PREPARTIALS###' ),
				),

				'invoice-payment-details'     => array(

					'description'     => __( 'Invoice payment details', 'zero-bs-crm' ),
					'origin'          => __( 'Invoice Builder', 'zero-bs-crm' ),
					'expected_format' => 'html',
					'available_in'    => array(),
					'associated_type' => ZBS_TYPE_INVOICE,
					'replace_str'     => '##INVOICE-PAYMENT-DETAILS##',
					'aliases'         => array( '###PAYMENTDEETS###', '###PAYDETAILS###' ),
				),

				'invoice-partials-table'      => array(

					'description'     => __( 'Invoice partials table (HTML)', 'zero-bs-crm' ),
					'origin'          => __( 'Invoice Builder', 'zero-bs-crm' ),
					'expected_format' => 'html',
					'available_in'    => array(),
					'associated_type' => ZBS_TYPE_INVOICE,
					'replace_str'     => '##INVOICE-PARTIALS-TABLE##',
					'aliases'         => array( '###PARTIALSTABLE###' ),
				),

				'invoice-label-inv-number'    => array(

					'description'     => __( 'Label for invoice number', 'zero-bs-crm' ),
					'origin'          => __( 'Invoice Builder', 'zero-bs-crm' ),
					'expected_format' => 'str',
					'available_in'    => array(),
					'associated_type' => ZBS_TYPE_INVOICE,
					'replace_str'     => '##INVOICE-LABEL-INV-NUMBER##',
					'aliases'         => array( '###LANGINVNO###' ),
				),

				'invoice-label-inv-date'      => array(

					'description'     => __( 'Label for invoice date', 'zero-bs-crm' ),
					'origin'          => __( 'Invoice Builder', 'zero-bs-crm' ),
					'expected_format' => 'str',
					'available_in'    => array(),
					'associated_type' => ZBS_TYPE_INVOICE,
					'replace_str'     => '##INVOICE-LABEL-INV-DATE##',
					'aliases'         => array( '###LANGINVDATE###' ),
				),

				'invoice-label-inv-ref'       => array(

					'description'     => __( 'Label for invoice reference', 'zero-bs-crm' ),
					'origin'          => __( 'Invoice Builder', 'zero-bs-crm' ),
					'expected_format' => 'str',
					'available_in'    => array(),
					'associated_type' => ZBS_TYPE_INVOICE,
					'replace_str'     => '##INVOICE-LABEL-INV-REF##',
					'aliases'         => array( '###LANGINVREF###' ),
				),

				'invoice-label-from'          => array(

					'description'     => __( 'Label for name of company issuing invoice', 'zero-bs-crm' ),
					'origin'          => __( 'Invoice Builder', 'zero-bs-crm' ),
					'expected_format' => 'str',
					'available_in'    => array(),
					'associated_type' => ZBS_TYPE_INVOICE,
					'replace_str'     => '##INVOICE-LABEL-FROM##',
					'aliases'         => array( '###LANGFROM###' ),
				),

				'invoice-label-to'            => array(

					'description'     => __( 'Label for name of contact receiving invoice', 'zero-bs-crm' ),
					'origin'          => __( 'Invoice Builder', 'zero-bs-crm' ),
					'expected_format' => 'str',
					'available_in'    => array(),
					'associated_type' => ZBS_TYPE_INVOICE,
					'replace_str'     => '##INVOICE-LABEL-TO##',
					'aliases'         => array( '###LANGTO###' ),
				),

				'invoice-label-due-date'      => array(

					'description'     => __( 'Label for invoice due date', 'zero-bs-crm' ),
					'origin'          => __( 'Invoice Builder', 'zero-bs-crm' ),
					'expected_format' => 'str',
					'available_in'    => array(),
					'associated_type' => ZBS_TYPE_INVOICE,
					'replace_str'     => '##INVOICE-LABEL-DUE-DATE##',
					'aliases'         => array( '###LANGDUEDATE###' ),
				),

				'invoice-label-status'        => array(

					'description'     => __( 'Label for invoice status', 'zero-bs-crm' ),
					'origin'          => __( 'Invoice Builder', 'zero-bs-crm' ),
					'expected_format' => 'str',
					'available_in'    => array(),
					'associated_type' => ZBS_TYPE_INVOICE,
					'replace_str'     => '##INVOICE-LABEL-STATUS##',
					'aliases'         => array(),
				),

				'invoice-html-status'         => array(

					'description'     => __( 'Invoice status (HTML)', 'zero-bs-crm' ),
					'origin'          => __( 'Invoice Builder', 'zero-bs-crm' ),
					'expected_format' => 'html',
					'available_in'    => array(),
					'associated_type' => ZBS_TYPE_INVOICE,
					'replace_str'     => '##INVOICE-HTML-STATUS##',
					'aliases'         => array( '###TOPSTATUS###' ),
				),

				'invoice-pay-button'          => array(

					'description'     => __( 'Invoice pay button (HTML)', 'zero-bs-crm' ),
					'origin'          => __( 'Invoice Builder', 'zero-bs-crm' ),
					'expected_format' => 'html',
					'available_in'    => array(),
					'associated_type' => ZBS_TYPE_INVOICE,
					'replace_str'     => '##INVOICE-PAY-BUTTON##',
					'aliases'         => array( '###PAYPALBUTTON###' ),
				),

				'invoice-pay-thanks'          => array(

					'description'     => __( 'Invoice payment thanks message (HTML)', 'zero-bs-crm' ),
					'origin'          => __( 'Invoice Builder', 'zero-bs-crm' ),
					'expected_format' => 'html',
					'available_in'    => array(),
					'associated_type' => ZBS_TYPE_INVOICE,
					'replace_str'     => '##INVOICE-PAY-THANKS##',
					'aliases'         => array( '###PAYTHANKS###' ),
				),

				'invoice-pay-terms'           => array(

					'description'     => __( 'Invoice payment terms (HTML)', 'zero-bs-crm' ),
					'origin'          => __( 'Invoice Builder', 'zero-bs-crm' ),
					'expected_format' => 'html',
					'available_in'    => array(),
					'associated_type' => ZBS_TYPE_INVOICE,
					'replace_str'     => '##INVOICE-PAY-TERMS##',
					'aliases'         => array( '###PAYMENTTERMS###' ),
				),

				'invoice-statement-html'      => array(

					'description'     => __( 'Invoice statement (HTML)', 'zero-bs-crm' ),
					'origin'          => __( 'Statement Builder', 'zero-bs-crm' ),
					'expected_format' => 'html',
					'available_in'    => array(),
					'associated_type' => ZBS_TYPE_INVOICE,
					'replace_str'     => '##INVOICE-STATEMENT-HTML##',
					'aliases'         => array( '###STATEMENTHTML###' ),
				),

				'invoice-ref-styles'          => array(

					'description'     => __( 'CSS attributes applied to invoice reference label', 'zero-bs-crm' ),
					'origin'          => __( 'Statement Builder', 'zero-bs-crm' ),
					'expected_format' => 'str',
					'available_in'    => array(),
					'associated_type' => ZBS_TYPE_INVOICE,
					'replace_str'     => '##INVOICE-REF-STYLES##',
					'aliases'         => array( '###INVREFSTYLES###' ),
				),

				'invoice-custom-fields'       => array(

					'description'     => __( 'Any custom fields associated with invoice (if enabled in Invoice Settings)', 'zero-bs-crm' ),
					'origin'          => __( 'Invoice Builder', 'zero-bs-crm' ),
					'expected_format' => 'str',
					'available_in'    => array(),
					'associated_type' => ZBS_TYPE_INVOICE,
					'replace_str'     => '##INV-CUSTOM-FIELDS##',
					'aliases'         => array(),
				),

			),
			'transaction' => array(),
			'event'       => array(

				'task-title'       => array(

					'description'     => __( 'Task title', 'zero-bs-crm' ),
					'origin'          => __( 'Task Scheduler', 'zero-bs-crm' ),
					'expected_format' => 'str',
					'available_in'    => array(),
					'associated_type' => ZBS_TYPE_TASK,
					'replace_str'     => '##TASK-TITLE##',
					'aliases'         => array( '###EVENTTITLE###', '##EVENT-TITLE##' ),
				),

				'task-link'        => array(

					'description'     => __( 'Task link (HTML)', 'zero-bs-crm' ),
					'origin'          => __( 'Task Scheduler', 'zero-bs-crm' ),
					'expected_format' => 'html',
					'available_in'    => array(),
					'associated_type' => ZBS_TYPE_TASK,
					'replace_str'     => '##TASK-LINK##',
					'aliases'         => array( '###EVENTLINK###' ),
				),

				'task-link-button' => array(

					'description'     => __( 'Task link button (HTML)', 'zero-bs-crm' ),
					'origin'          => __( 'Task Scheduler', 'zero-bs-crm' ),
					'expected_format' => 'html',
					'available_in'    => array(),
					'associated_type' => ZBS_TYPE_TASK,
					'replace_str'     => '##TASK-LINK-BUTTON##',
					'aliases'         => array( '###EVENTLINKBUTTON###' ),
				),

				'task-body'        => array(

					'description'     => __( 'Task content (HTML)', 'zero-bs-crm' ),
					'origin'          => __( 'Task Scheduler', 'zero-bs-crm' ),
					'expected_format' => 'html',
					'available_in'    => array(),
					'associated_type' => ZBS_TYPE_TASK,
					'replace_str'     => '##TASK-BODY##',
					'aliases'         => array( '###EVENTBODY###' ),
				),

			),

			// probably not req. yet:
			/*
			'address',
			'form',
			'segment',
			'log',
			'lineitem',
			'eventreminder',
			'quotetemplate',
			*/

		);

		return $this->placeholders;
	}

	/**
	 * Builds initial list of placeholders using defaults, object-models, custom fields, and filters
	 *
	 * @return array of all placeholders
	 */
	public function build_placeholders( $include_custom_fields = true ) {

		global $zbs;

		// any hard-typed defaults
		$placeholders = $this->default_placeholders();

		// load fields from DAL object models
		$placeholders = $this->load_from_object_models( $placeholders, $include_custom_fields );

		// some backward compat tweaks:
		if ( isset( $placeholders['quote']['title'] ) ) {
			$placeholders['quote']['title']['aliases'] = array( '##QUOTE-TITLE##', '##QUOTETITLE##' );
		}
		if ( isset( $placeholders['quote']['value'] ) ) {
			$placeholders['quote']['value']['aliases'] = array( '##QUOTEVALUE##' );
		}
		if ( isset( $placeholders['quote']['date'] ) ) {
			$placeholders['quote']['date']['aliases'] = array( '##QUOTEDATE##' );
		}

		// add setting dependent placeholders
		$placeholders = $this->add_setting_dependent_placeholders( $placeholders );

		// here we fill in any 'available-in' crossovers
		// e.g. you can reference contacts in quotes.
		$placeholders = $this->add_available_in( $placeholders );

		// filter (to allow extensions to ammend)
		$this->placeholders = apply_filters( 'jpcrm_templating_placeholders', $placeholders );

		// return
		return $this->placeholders;
	}

	/**
	 * Add setting-dependent placeholders
	 *
	 * @return array of all placeholders
	 */
	public function add_setting_dependent_placeholders( $placeholders = array() ) {

		// remove tooling where inactive modules
		$placeholders = $this->strip_inactive_tooling( $placeholders );

		// if ownership model enabled, add 'owner' fields
		// this is simplistic for now and appears outwardly to only encompasses contacts
		// ... in fact it works for all object types (provided the object is passed to replace_placeholders())
		// as per #1531 this could later be expanded to encompass all objects, perhaps in sites & teams extension
		$using_ownership = zeroBSCRM_getSetting( 'perusercustomers' );
		if ( $using_ownership ) {

			if ( isset( $placeholders['global'] ) ) {

				// note, these are auto-populated using the first `owner` field that our replacement function comes across in $replacement_objects
				$placeholders['global']['assigned-to-name'] = array(

					'description'     => __( 'Where available, the display name of the WordPress user who owns the object', 'zero-bs-crm' ),
					'origin'          => __( 'Global', 'zero-bs-crm' ),
					'expected_format' => 'str',
					'available_in'    => array(),
					'associated_type' => false,
					'replace_str'     => '##ASSIGNED-TO-NAME##',
					'aliases'         => array( '##ASSIGNED-TO-SIGNATURE##' ),
				);

				$placeholders['global']['assigned-to-email'] = array(

					'description'     => __( 'Where available, the email of the WordPress user who owns the object', 'zero-bs-crm' ),
					'origin'          => __( 'Global', 'zero-bs-crm' ),
					'expected_format' => 'email',
					'available_in'    => array(),
					'associated_type' => false,
					'replace_str'     => '##ASSIGNED-TO-EMAIL##',
					'aliases'         => array(),
				);

				$placeholders['global']['assigned-to-username'] = array(

					'description'     => __( 'Where available, the username of the WordPress user who owns the object', 'zero-bs-crm' ),
					'origin'          => __( 'Global', 'zero-bs-crm' ),
					'expected_format' => 'email',
					'available_in'    => array(),
					'associated_type' => false,
					'replace_str'     => '##ASSIGNED-TO-USERNAME##',
					'aliases'         => array(),
				);

				$placeholders['global']['assigned-to-mob'] = array(

					'description'     => __( 'Where available, the mobile phone of the WordPress user who owns the object', 'zero-bs-crm' ),
					'origin'          => __( 'Global', 'zero-bs-crm' ),
					'expected_format' => 'tel',
					'available_in'    => array(),
					'associated_type' => false,
					'replace_str'     => '##ASSIGNED-TO-MOB##',
					'aliases'         => array(),
				);

			}
		}

		return $placeholders;
	}

	/**
	 * Remove object types where that module is not active, e.g. invoices
	 * This needs to fire before this->add_available_in in the build queue
	 *
	 * @return array of all placeholders
	 */
	public function strip_inactive_tooling( $placeholders = array() ) {

		$setting_map = array(
			'form'        => 'feat_forms',
			'quote'       => 'feat_quotes',
			'invoice'     => 'feat_invs',
			'event'       => 'feat_calendar',
			'transaction' => 'feat_transactions',
		);

		// make a quick state list for simplicity
		$tooling_states = array();
		foreach ( $setting_map as $tooling_area => $setting_key ) {

			$tooling_states[ $tooling_area ] = ( zeroBSCRM_getSetting( $setting_key ) == '1' ) ? true : false;

			// remove from placeholder list
			if ( ! $tooling_states[ $tooling_area ] && isset( $placeholders[ $tooling_area ] ) ) {

				// remove
				unset( $placeholders[ $tooling_area ] );
			}
		}

		// remove from $available_in_links too
		$available_in = array();
		foreach ( $this->available_in_links as $object_type_key => $tooling_area_array ) {

			if ( ! isset( $tooling_states[ $object_type_key ] ) || $tooling_states[ $object_type_key ] ) {

				$tooling_available = array();

				foreach ( $tooling_area_array as $tooling_area ) {

					// add back if not in the list
					if ( ! array_key_exists( $tooling_area, $tooling_states ) || $tooling_states[ $tooling_area ] ) {

						$tooling_available[] = $tooling_area;

					}
				}

				$available_in[ $object_type_key ] = $tooling_available;

			}
		}
		$this->available_in_links = $available_in;

		// return
		return $placeholders;
	}

	/**
	 * Appends to passed placeholder model based on DAL object models
	 * (optionally) including custom fields where applicable
	 *
	 * @param bool include_custom_fields
	 *
	 * @return int count
	 */
	private function load_from_object_models(
		$placeholders,
		$include_custom_fields = false,
		$excluded_slugs = array(

			// global
			'zbs_site',
			'zbs_team',
			'zbs_owner',
			'id_override',
			'parent',
			'send_attachments',
			'hash',

			// contact
			'alias',

			// quote
			'template',
			'acceptedsigned',
			'acceptedip',

			// invoice
			'pay_via',
			'allow_tip',
			'allow_partial',
			'hash_viewed',
			'hash_viewed_count',
			'portal_viewed',
			'portal_viewed_count',
			'pdf_template',
			'portal_template',
			'email_template',
			'invoice_frequency',
			'address_to_objtype',

			// event
			'show_on_cal',
			'show_on_portal',
			'title',

		)
	) {

		global $zbs;

		// retrieve object types
		$object_types         = $zbs->DAL->get_object_types_by_index();
		$second_address_label = zeroBSCRM_getSetting( 'secondaddresslabel' );
		if ( empty( $second_address_label ) ) {
			$second_address_label = __( 'Second Address', 'zero-bs-crm' );
		}

		// cycle through them, adding where they have $include_in_templating
		foreach ( $object_types as $object_type_index => $object_type_key ) {

			$object_layer = $zbs->DAL->getObjectLayerByType( $object_type_index );
			$object_label = $zbs->DAL->typeStr( $object_type_index );

			// if there is an object layer available, and it's included in templating:
			if ( is_object( $object_layer ) && $object_layer->is_included_in_templating() ) {

				// (optionally) include custom field references
				if ( $include_custom_fields ) {

					$object_model = $object_layer->objModelIncCustomFields();

				} else {

					$object_model = $object_layer->objModel();

				}

				// cycle through object model & add fields
				foreach ( $object_model as $field_index => $field_info ) {

					// deal with exclusions
					if ( ! in_array( $field_index, $excluded_slugs ) ) {

						// catching legacy secondary address contact field issues
						$secondary_address_array = array( 'secaddr1', 'secaddr2', 'seccity', 'seccounty', 'secpostcode', 'seccountry' );
						if ( 'contact' === $object_type_key && in_array( $field_index, $secondary_address_array, true ) ) {
							$field_index = str_replace( 'sec', 'secaddr_', $field_index );
							$new_key     = $object_type_key . '-' . $field_index;
						} else {
							$new_key = str_replace( '_', '-', $object_type_key . '-' . $field_index );
						}

						$expected_format = '';

						// add if not present
						// e.g. $placeholders['contact']['ID']
						if ( ! isset( $placeholders[ $object_type_key ][ $new_key ] ) ) {

							// prettify these
							$description = $object_label . ' ' . $field_index;
							switch ( $field_index ) {

								case 'created':
									$description = sprintf( __( 'Date %s was created', 'zero-bs-crm' ), $object_label );
									break;
								case 'lastupdated':
									$description = sprintf( __( 'Date %s was last updated', 'zero-bs-crm' ), $object_label );
									break;
								case 'lastcontacted':
									$description = sprintf( __( 'Date %s was last contacted', 'zero-bs-crm' ), $object_label );
									break;
								case 'tw':
									$description = sprintf( __( 'Twitter handle for %s', 'zero-bs-crm' ), $object_label );
									break;
								case 'li':
									$description = sprintf( __( 'LinkedIn handle for %s', 'zero-bs-crm' ), $object_label );
									break;
								case 'fb':
									$description = sprintf( __( 'Facebook page ID for %s', 'zero-bs-crm' ), $object_label );
									break;
								case 'wpid':
									$description = sprintf( __( 'WordPress ID for %s', 'zero-bs-crm' ), $object_label );
									break;

							}

							if ( isset( $field_info['label'] ) ) {
								$description = $object_label . ' ' . __( $field_info['label'], 'zero-bs-crm' );
							}

							if ( ! empty( $field_info['area'] ) && $field_info['area'] == 'Second Address' ) {
								$description .= ' (' . esc_html( $second_address_label ) . ')';
							}

							// if it's a custom field we can infer format (and label):
							if ( isset( $field_info['custom-field'] ) && $field_info['custom-field'] == 1 ) {
								$field_info['format'] = $field_info[0];
								if ( $field_info['format'] === 'date' ) {
									$field_info['format'] = 'uts';
								}
								$description .= ' (' . __( 'Custom Field', 'zero-bs-crm' ) . ')';

							}

							// add {placeholder_model}
							$placeholders[ $object_type_key ][ $new_key ] = array(

								'description'     => $description,
								'origin'          => sprintf( __( '%s object model', 'zero-bs-crm' ), $object_label ),
								'available_in'    => array(),
								'associated_type' => $object_type_index,
								'replace_str'     => '##' . strtoupper( $object_type_key ) . '-' . strtoupper( $field_index ) . '##',
								'expected_format' => $expected_format,

							);

							// trying to future proof, added a few helper attributes:
							if ( isset( $field_info['format'] ) ) {
								$placeholders[ $object_type_key ][ $new_key ]['expected_format'] = $field_info['format'];
								if ( $field_info['format'] === 'uts' && empty( $field_info['autoconvert'] ) ) {
									$placeholders[ $object_type_key ][ $new_key . '_datetime_str' ] = array(

										'description'     => $description . ' (' . __( 'DateTime string', 'zero-bs-crm' ) . ')',
										'origin'          => sprintf( __( '%s object model', 'zero-bs-crm' ), $object_label ),
										'available_in'    => array(),
										'associated_type' => $object_type_index,
										'replace_str'     => '##' . strtoupper( $object_type_key ) . '-' . strtoupper( $field_index ) . '_DATETIME_STR##',
										'expected_format' => 'str',

									);
									$placeholders[ $object_type_key ][ $new_key . '_date_str' ] = array(

										'description'     => $description . ' (' . __( 'Date string', 'zero-bs-crm' ) . ')',
										'origin'          => sprintf( __( '%s object model', 'zero-bs-crm' ), $object_label ),
										'available_in'    => array(),
										'associated_type' => $object_type_index,
										'replace_str'     => '##' . strtoupper( $object_type_key ) . '-' . strtoupper( $field_index ) . '_DATE_STR##',
										'expected_format' => 'str',

									);
								}
							}
						}
					}
				}
			}
		}

		return $placeholders;
	}

	/**
	 * Adds `available_in` links to allow tooling to get all applicable fields
	 * e.g. Contact fields will be available in Quote builder
	 *
	 * @param array $placeholders placeholder array
	 *
	 * @return array $placeholders placeholder array
	 */
	private function add_available_in( $placeholders ) {

		// any to add?
		if ( is_array( $this->available_in_links ) ) {

			foreach ( $this->available_in_links as $object_type => $tooling_area_array ) {

				// $object_type = 'contact', $tooling_area = where to point to
				if ( isset( $placeholders[ $object_type ] ) ) {

					foreach ( $placeholders[ $object_type ] as $object_type_placeholder_key => $object_type_placeholder ) {

						// setup if not set
						if ( ! is_array( $placeholders[ $object_type ][ $object_type_placeholder_key ]['available_in'] ) ) {

							$placeholders[ $object_type ][ $object_type_placeholder_key ]['available_in'] = array();

						}

						// add if not present
						foreach ( $tooling_area_array as $tooling_area ) {

							if ( ! in_array( $tooling_area, $placeholders[ $object_type ][ $object_type_placeholder_key ]['available_in'] ) ) {

								$placeholders[ $object_type ][ $object_type_placeholder_key ]['available_in'][] = $tooling_area;

							}
						}
					}
				}
			}
		}

		return $placeholders;
	}

	/**
	 * Returns full list of viable placeholders
	 *
	 * @param bool separate_categories - return in multi-dim array split out by categories
	 *
	 * @return array of all placeholders
	 */
	public function get_placeholders( $separate_categories = true ) {

		global $zbs;

		// if asked to return without categories, do that
		if ( ! $separate_categories ) {

			$no_cat_list = array();
			foreach ( $this->placeholders as $placeholder_group_key => $placeholder_group ) {

				foreach ( $placeholder_group as $placeholder_key => $placeholder ) {

					$no_cat_list[ $placeholder['replace_str'] ] = $placeholder;

					// any aliases too
					if ( isset( $placeholder['aliases'] ) && is_array( $placeholder['aliases'] ) ) {

						foreach ( $placeholder['aliases'] as $alias ) {

							$no_cat_list[ $alias ] = $placeholder;

						}
					}
				}
			}

			return $no_cat_list;

		}

		return $this->placeholders;
	}

	/**
	 * Returns flattened list of viable placeholders
	 *
	 * @return array of all placeholders
	 */
	public function get_placeholders_shorthand() {

		global $zbs;

		$shorthand_list = array();
		foreach ( $this->placeholders as $placeholder_group_key => $placeholder_group ) {

			$placeholder_group_prefix = '';

			// all objtypes basically
			if ( $zbs->DAL->isValidObjTypeID( $zbs->DAL->objTypeID( $placeholder_group_key ) ) ) {

				$placeholder_group_prefix = $placeholder_group_key . '-';

			}

			foreach ( $placeholder_group as $placeholder_key => $placeholder ) {

				$shorthand_list[] = '##' . strtoupper( $placeholder_group_prefix . $placeholder_key ) . '##';

				// any aliases too
				if ( isset( $placeholder['aliases'] ) && is_array( $placeholder['aliases'] ) ) {

					foreach ( $placeholder['aliases'] as $alias ) {

						$shorthand_list[] = $alias;

					}
				}
			}
		}

		return $shorthand_list;
	}

	/**
	 * Returns list of viable placeholders for specific tooling/area, or group of areas, or object types, e.g. system emails or contact
	 *
	 * @param array|string tooling area, object type, or group of
	 * @param bool hydrate_aliases - if true Aliases will be included as full records
	 * @param bool split_by_category - if true return will be split by category as per get_placeholders
	 *
	 * @return array placeholders
	 */
	public function get_placeholders_for_tooling( $tooling = array( 'global' ), $hydrate_aliases = false, $split_by_category = false ) {

		global $zbs;

		$applicable_placeholders = array();

		// we allow array or string input here, so if a string, wrap for below
		if ( is_string( $tooling ) ) {
			$tooling = array( $tooling );
		}

		// for MVP we let this get the whole lot then filter down, if this proves unperformant we could optimise here
		// .. or cache.
		$placeholders = $this->get_placeholders();

		// cycle through all looking at the `available_in` attribute.
		// alternatively if an object type is passed, it'll return all fields for that type
		if ( is_array( $placeholders ) ) {

			foreach ( $placeholders as $placeholder_group_key => $placeholder_group ) {

				$placeholder_group_prefix = '';

				// all objtypes basically
				if ( $zbs->DAL->isValidObjTypeID( $zbs->DAL->objTypeID( $placeholder_group_key ) ) ) {

					$placeholder_group_prefix = $placeholder_group_key . '-';

				}

				foreach ( $placeholder_group as $placeholder_key => $placeholder ) {

					// if in object type group:
					if ( in_array( $placeholder_group_key, $tooling )
						||
						isset( $placeholder['available_in'] ) && count( array_intersect( $tooling, $placeholder['available_in'] ) ) > 0
					) {

						// here we've flattened the array to actual placeholders (no tooling group)
						// so use the placeholder str as key and add original id to array
						$key = '##' . strtoupper( $placeholder_group_prefix . $placeholder_key ) . '##';
						if ( isset( $placeholder['replace_str'] ) ) {

							// this overrides if set (will always be the same?)
							$key = $placeholder['replace_str'];

						}

						// if return in categories
						if ( $split_by_category ) {

							if ( ! isset( $applicable_placeholders[ $placeholder_group_key ] ) || ! is_array( $applicable_placeholders[ $placeholder_group_key ] ) ) {

								$applicable_placeholders[ $placeholder_group_key ] = array();

							}

							$applicable_placeholders[ $placeholder_group_key ][ $key ] = $placeholder;

							// add original key to arr
							$applicable_placeholders[ $placeholder_group_key ][ $key ]['key'] = $placeholder_key;

						} else {

							$applicable_placeholders[ $key ] = $placeholder;

							// add original key to arr
							$applicable_placeholders[ $key ]['key'] = $placeholder_key;

						}

						// aliases
						if ( $hydrate_aliases && isset( $placeholder['aliases'] ) && is_array( $placeholder['aliases'] ) ) {

							foreach ( $placeholder['aliases'] as $alias ) {

								// if return in categories
								if ( $split_by_category ) {

									$applicable_placeholders[ $placeholder_group_key ][ $alias ] = $placeholder;

								} else {

									$applicable_placeholders[ $alias ] = $placeholder;

								}
							}
						}
					}
				}
			}
		}

		return $applicable_placeholders;
	}

	/**
	 * Returns a single placeholder info array based on a key
	 *
	 * @param string placeholder key
	 *
	 * @return array placeholder info
	 */
	public function get_single_placeholder_info( $placeholder = '' ) {

		// cycle through placeholders and return our match
		foreach ( $this->placeholders as $placeholder_area => $placeholders ) {

			foreach ( $placeholders as $key => $placeholder_info ) {

				if ( $key == $placeholder ) {

					return $placeholder_info;

				}
			}
		}

		return false;
	}

	/*
	* Returns a template-friendly placeholder array of generic replacements
	*/
	public function get_generic_replacements() {

		global $zbs;

		// vars
		$login_url      = admin_url( 'admin.php?page=' . $zbs->slugs['dash'] );
		$portal_url     = zeroBS_portal_link();
		$biz_name       = zeroBSCRM_getSetting( 'businessname' );
		$biz_your_name  = zeroBSCRM_getSetting( 'businessyourname' );
		$biz_your_email = zeroBSCRM_getSetting( 'businessyouremail' );
		$biz_your_url   = zeroBSCRM_getSetting( 'businessyoururl' );
		$biz_extra      = zeroBSCRM_getSetting( 'businessextra' );
		$biz_info       = zeroBSCRM_invoicing_generateInvPart_bizTable(
			array(
				'zbs_biz_name'      => $biz_name,
				'zbs_biz_yourname'  => $biz_your_name,
				'zbs_biz_extra'     => $biz_extra,
				'zbs_biz_youremail' => $biz_your_email,
				'zbs_biz_yoururl'   => $biz_your_url,
				'template'          => 'pdf',
			)
		);
		$social_links   = show_social_links();

		// return
		return array(

			// login
			'login-link'      => '<a href="' . $login_url . '">' . __( 'Go to CRM', 'zero-bs-crm' ) . '</a>',
			'login-button'    => '<div style="text-align:center;margin:1em;margin-top:2em">' . zeroBSCRM_mailTemplate_emailSafeButton( $login_url, __( 'Go to CRM', 'zero-bs-crm' ) ) . '</div>',
			'login-url'       => $login_url,

			// portal
			'portal-link'     => '<a href="' . $portal_url . '">' . $portal_url . '</a>',
			'portal-button'   => '<div style="text-align:center;margin:1em;margin-top:2em">' . zeroBSCRM_mailTemplate_emailSafeButton( $portal_url, __( 'View Portal', 'zero-bs-crm' ) ) . '</div>',
			'portal-url'      => $portal_url,

			// biz stuff
			'biz-name'        => $biz_name,
			'biz-your-name'   => $biz_your_name,
			'biz-your-email'  => $biz_your_email,
			'biz-your-url'    => $biz_your_url,
			'biz-info'        => $biz_info,
			'biz-extra'       => $biz_extra,
			'biz-logo'        => jpcrm_business_logo_img( '150px' ),

			// general
			'powered-by'      => zeroBSCRM_mailTemplate_poweredByHTML(),
			'email-from-name' => zeroBSCRM_mailDelivery_defaultFromname(),
			'password'        => '<a href="' . wp_lostpassword_url() . '" title="' . __( 'Lost Password', 'zero-bs-crm' ) . '">' . __( 'Set Your Password', 'zero-bs-crm' ) . '</a>',

			// social
			'social-links'    => $social_links,
		);
	}

	/**
	 * Enacts single string replacement on a passed string based on passed tooling/areas
	 * Note: It's recommended to use this function even though str_replace would be approximate
	 * - Using this accounts for multiple aliases, e.g. ###MSG-CONTENT### and ##MSG-CONTENT##, and keeps things centralised
	 *
	 * @param string string of placeholder to replace, e.g. 'msg-content'
	 * @param string string to apply replacements to
	 * @param string string to replace placeholder with
	 *
	 * @return string modified string
	 */
	public function replace_single_placeholder( $placeholder_key = '', $replace_with = '', $string = '' ) {

		// get info
		$placeholder_info = $this->get_single_placeholder_info( $placeholder_key );

		// exists?
		if ( is_array( $placeholder_info ) ) {

			// auto-gen
			$main_placeholder_key = $this->make_placeholder_str( $placeholder_key );

			// if replace_str is set, use that
			if ( isset( $placeholder_info['replace_str'] ) ) {

				$main_placeholder_key = $placeholder_info['replace_str'];

			}

			// replace any and all variants (e.g. aliases)
			// we do these first, as often in our case the variants are backward-compat
			// and so `###BIZ-INFO###` needs replacing before `##BIZ-INFO##`
			if ( isset( $placeholder_info['aliases'] ) && is_array( $placeholder_info['aliases'] ) ) {

				$string = str_replace( $placeholder_info['aliases'], $replace_with, $string );

			}

			// replace
			$string = str_replace( $main_placeholder_key, $replace_with, $string );

		} else {

			// not in index, replace requested placeholder:
			$string = str_replace( $this->make_placeholder_str( $placeholder_key ), $replace_with, $string );

		}

		return $string;
	}

	/**
	 * Enacts replacements on a string based on passed tooling/areas
	 * Note: Using this method allows non-passed values to be emptied,
	 * e.g. if 'global' tooling method, and no 'unsub-line' value passed,
	 * any mentions of '##UNSUB-LINE##' will be taken out of $string
	 *
	 * @param array|string tooling - tooling area, object type, or group of
	 * @param string string - to apply replacements to
	 * @param array replacements - array of replacement values
	 * @param array|false replacement_objects - an array of objects to replace fields from (e.g. contact) Should be keyed by object type, in the format array[ZBS_TYPE_CONTACT] = contact array
	 * @param bool retain_unset_placeholders - bool whether or not to retain empty-valued placeholders (e.g. true = leaves ##BIZ-LOGO## in string if no value for 'biz-logo')
	 *
	 * @return string modified string
	 */
	public function replace_placeholders(
		$tooling = array( 'global' ),
		$string = '',
		$replacements = array(),
		$replacement_objects = false,
		$retain_unset_placeholders = false,
		$keys_staying_unrendered = array()
	) {

		// retrieve replacements for this tooling
		$to_replace = $this->get_placeholders_for_tooling( $tooling );

		// cycle through replacements and replace where possible
		if ( is_array( $to_replace ) ) {

			foreach ( $to_replace as $replace_string => $replacement_info ) {

				// ##BIZ-STATE## -> biz-state
				$key = str_replace( '#', '', strtolower( $replace_string ) );
				if ( isset( $replacement_info['key'] ) && ! empty( $replacement_info['key'] ) ) {

					$key = $replacement_info['key'];

				}

				// attempt to find value in $replacements
				$replace_with = '';
				if ( isset( $replacements[ $key ] ) ) {

					$replace_with = $replacements[ $key ];

				}

				// attempt to find value in $replacement_objects
				// ... if $replacements[value] not already set (that overrides)
				// here $key will be 'contact-prefix'
				// .. which would map to $replacement_objects[ZBS_TYPE_CONTACT]['prefix']
				if ( empty( $replace_with ) ) {

					// attempt to pluck relative value
					$potential_value = $this->pick_from_replacement_objects( $key, $replacement_objects );

					if ( ! empty( $potential_value ) ) {

						$replace_with = $potential_value;

					}
				}

				// if $key is 'assigned-to-name', 'assigned-to-email', 'assigned-to-mob'  - seek out an owner.
				// ... if $replacements[assigned-to-name] not already set (that overrides)
				if ( empty( $replace_with ) && in_array( $key, array( 'assigned-to-name', 'assigned-to-email', 'assigned-to-username', 'assigned-to-mob' ) ) && is_array( $replacement_objects ) ) {

					$owner_value = '';
					$owner_info  = $this->owner_from_replacement_objects( $replacement_objects );

					if ( $owner_info ) {

						switch ( $key ) {

							case 'assigned-to-name':
								$owner_value = $owner_info->display_name;
								break;
							case 'assigned-to-username':
								$owner_value = $owner_info->user_login;
								break;
							case 'assigned-to-email':
								$owner_value = $owner_info->user_email;
								break;
							case 'assigned-to-mob':
								$owner_value = zeroBS_getWPUsersMobile( $owner_info->ID );
								break;

						}

						// got value?
						if ( ! empty( $owner_value ) ) {

							$replace_with = $owner_value;

						}
					}
				}

				// replace (if not empty + retain_unset_placeholders)
				if ( ! $retain_unset_placeholders || ( $retain_unset_placeholders && ! empty( $replace_with ) ) ) {

					// here we hit a problem, we've been wild with our ### use, so now we
					// have a mixture of ## and ### references.
					// ... wherever we have ### we must replace them first,
					// ... to avoid ###A### being pre-replaced by ##A##
					// for now, replacing aliases first solves this.

					// aliases
					if ( isset( $replacement_info['aliases'] ) && is_array( $replacement_info['aliases'] ) ) {

						$string = str_replace( $replacement_info['aliases'], $replace_with, $string );

					}

					// If this is a Quote date key and is not set (Quote accepted or last viewed), let's print out a message saying the quote isn't accepted or viewed.
					if (
						in_array( $key, array( 'quote-accepted', 'quote-lastviewed' ), true ) &&
						! in_array( $replacement_info['key'], $keys_staying_unrendered, true ) &&
						( empty( $replace_with ) || jpcrm_date_str_to_uts( $replace_with ) === 0 )
						) {

						if ( $key === 'quote-accepted' ) {
							$replace_with = __( 'Quote not yet accepted', 'zero-bs-crm' );
							$string       = str_replace( '##QUOTE-ACCEPTED_DATETIME_STR##', $replace_with, $string );
							$string       = str_replace( '##QUOTE-ACCEPTED_DATE_STR##', $replace_with, $string );
						} else {
							$replace_with = __( 'Quote not yet viewed', 'zero-bs-crm' );
							$string       = str_replace( '##QUOTE-LASTVIEWED_DATETIME_STR##', $replace_with, $string );
							$string       = str_replace( '##QUOTE-LASTVIEWED_DATE_STR##', $replace_with, $string );
						}
					}

					// Replace main key.
					if ( empty( $keys_staying_unrendered ) || ! in_array( $replacement_info['key'], $keys_staying_unrendered, true ) ) {

						$string = str_replace( $replace_string, $replace_with, $string );

					}
				}
			}
		}

		return $string;
	}

	/**
	 * Takes an array of replacement_objects and a target string
	 * ... and returns a value if it can pick one from replacement_objects
	 * ... e.g. target_string = 'contact-fname'
	 * ...      =
	 * ...      replacement_objects[ZBS_TYPE_CONTACT]['fname']
	 *
	 * @param string target string e.g. `contact-fname`
	 * @param array|bool replacement objects, keyed to object type - (e.g. contact) Should be in the format array[ZBS_TYPE_CONTACT] = contact array
	 *
	 * @return string valid placeholder string
	 */
	private function pick_from_replacement_objects( $target_string = '', $replacement_objects = array() ) {

		global $zbs;

		// got string and replacement objects?
		if ( ! empty( $target_string ) && is_array( $replacement_objects ) && count( $replacement_objects ) > 0 ) {

			// retrieve object-type from $target_string (where possible)
			$target_exploded = explode( '-', $target_string, 2 );
			if ( is_array( $target_exploded ) && count( $target_exploded ) > 1 ) {

				// convert `contact` to ZBS_TYPE_CONTACT (1)
				$object_type_id = $zbs->DAL->objTypeID( $target_exploded[0] );

				// validate obj type id and check if it's passed in replacement_objects
				if ( $zbs->DAL->isValidObjTypeID( $object_type_id ) && isset( $replacement_objects[ $object_type_id ] ) ) {

					// at this point we turn `contact-fname` into `fname` and we pluck it from `$replacement_objects[ ZBS_TYPE_CONTACT ]['fname']` if it's set
					array_shift( $target_exploded );
					$field_name = strtolower( $target_exploded[0] );

					if ( isset( $replacement_objects[ $object_type_id ][ $field_name ] ) && ! empty( $replacement_objects[ $object_type_id ][ $field_name ] ) ) {

						// successful find
						return $replacement_objects[ $object_type_id ][ $field_name ];

					}

					// check for potential fallback fields

					if ( preg_match( '/_datetime_str$/', $field_name ) ) {

						$potential_uts_field = str_replace( '_datetime_str', '', $field_name );
						if ( isset( $replacement_objects[ $object_type_id ][ $potential_uts_field ] ) ) {
							$potential_uts_value = $replacement_objects[ $object_type_id ][ $potential_uts_field ];
							if ( jpcrm_is_int( $potential_uts_value ) ) {
								return jpcrm_uts_to_datetime_str( $potential_uts_value );
							} else {
								// use original value as fallback
								return $potential_uts_value;
							}
						}
					} elseif ( preg_match( '/_date_str$/', $field_name ) ) {

						$potential_uts_field = str_replace( '_date_str', '', $field_name );
						if ( isset( $replacement_objects[ $object_type_id ][ $potential_uts_field ] ) ) {
							$potential_uts_value = $replacement_objects[ $object_type_id ][ $potential_uts_field ];
							if ( jpcrm_is_int( $potential_uts_value ) ) {
								return jpcrm_uts_to_date_str( $potential_uts_value );
							} else {
								// use original value as fallback
								return $potential_uts_value;
							}
						}
					}
				}
			}
		}

		return false;
	}

	/**
	 * Takes an array of replacement_objects and returns WordPress user info
	 * for the first owner it finds in $replacement_objects
	 *
	 * @param array|bool replacement objects, keyed to object type - (e.g. contact) Should be in the format array[ZBS_TYPE_CONTACT] = contact array
	 *
	 * @return string WordPress user info (get_userdata)
	 */
	private function owner_from_replacement_objects( $replacement_objects = array() ) {

		// got string and replacement objects?
		if ( is_array( $replacement_objects ) && count( $replacement_objects ) > 0 ) {

			foreach ( $replacement_objects as $replacement_obj_type => $replacement_object ) {

				// is `owner` set?
				if ( isset( $replacement_object['owner'] ) && ! empty( $replacement_object['owner'] ) ) {

					// one of the passed replacement objects has an owner... first passed first served
					$user_info = get_userdata( $replacement_object['owner'] );

					if ( $user_info !== false ) {

						return $user_info;

					}
				}
			}
		}

		return false;
	}

	/**
	 * Takes a key (e.g. msg-content) and returns in valid placeholder
	 * format. (e.g. ##MSG-CONTENT##)
	 *
	 * @param string placeholder key
	 *
	 * @return string valid placeholder string
	 */
	public function make_placeholder_str( $placeholder_key = '' ) {

		return '##' . str_replace( '_', '-', trim( strtoupper( $placeholder_key ) ) ) . '##';
	}

	/**
	 * Draws WYSIWYG Typeahead (Bloodhound JS)
	 *
	 * @param string select - HTML ID to give to the placeholder select
	 * @param string insert_target_id - HTML ID which the placeholder should insert into when a placeholder is selected
	 * @param array|string tooling - placeholder array of tooling areas to include, if 'all' string passed, all placeholders will be shown
	 * @param array return - return or echo?
	 *
	 * @return string valid placeholder string
	 */
	public function placeholder_selector( $id = '', $insert_target_id = '', $tooling = array( 'global' ), $return = false, $extra_classes = '' ) {

		$placeholder_list = array();

		// Simpler <select>
		if ( is_array( $tooling ) ) {

			// retrieve placeholder list (divided by category)
			$placeholder_list = $this->get_placeholders_for_tooling( $tooling, false, true );

		} else {

			// retrieve placeholder list (divided by category)
			$placeholder_list = $this->get_placeholders( true );

		}

		$html  = '<div class="jpcrm-placeholder-select-wrap ' . $extra_classes . '">';
		$html .= '<select id="' . $id . '" data-target="' . $insert_target_id . '" class="jpcrm-placeholder-select ui compact selection dropdown">';

			// blank option
			$html .= '<option value="-1">' . __( 'Insert Placeholder', 'zero-bs-crm' ) . '</option>';
			$html .= '<option value="-1" disabled="disabled">====================</option>';

			// cycle through categories of placeholders:
		if ( is_array( $placeholder_list ) && count( $placeholder_list ) > 0 ) {

			foreach ( $placeholder_list as $placeholder_group_key => $placeholder_group ) {

				$html .= '<optgroup label="' . ucwords( $placeholder_group_key ) . '">';

				foreach ( $placeholder_group as $placeholder_key => $placeholder_info ) {

					// value
					// if no replace_str attr...
					$placeholder_str = $this->make_placeholder_str( $placeholder_group_key . '-' . $placeholder_key );
					if ( isset( $placeholder_info['replace_str'] ) ) {

						$placeholder_str = $placeholder_info['replace_str'];

					}

					// label
					$placeholder_label = ( isset( $placeholder_info['description'] ) && ! empty( $placeholder_info['description'] ) ) ? $placeholder_info['description'] . ' (' . $placeholder_str . ')' : $placeholder_str;

					$html .= '<option value="' . $placeholder_str . '">' . $placeholder_label . '</option>';

				}

				$html .= '</optgroup>';

			}
		} else {

			$html .= '<option value="-1">' . __( 'No Placeholders Available', 'zero-bs-crm' ) . '</option>';

		}

		$html .= '</select><i class="code icon"></i></div>';

		if ( ! $return ) {

			echo $html;

		}

		return $html;
	}

	/**
	 * Collates and tidies the full output of placeholders into a simpler array (for placeholder selector typeahead primarily)
	 *
	 * @param array placeholders - array of placeholders, probably from $this->get_placeholders or $this->get_placeholders_for_tooling (Note this requires non-categorised array)
	 *
	 * @return array placeholders - simplified array
	 */
	public function simplify_placeholders( $placeholders = array() ) {

		$return = array();

		// cycle through and simplify
		foreach ( $placeholders as $key => $info ) {

			$placeholder = $info;

			// unset non-key
			unset( $placeholder['available_in'], $placeholder['associated_type'] );

			// if return_str not set and key is, add it
			if ( ! isset( $placeholder['replace_str'] ) ) {

				$placeholder['replace_str'] = $key;

			}

			$return[] = $placeholder;

		}

		return $return;
	}

	/**
	 * Collates and tidies the full output of placeholders into a simpler array (designed for wysiwyg select insert - e.g. quotebuilder)
	 *
	 * @param array placeholders - array of placeholders, probably from $this->get_placeholders or $this->get_placeholders_for_tooling (Note this requires non-categorised array)
	 *
	 * @return array placeholders - simplified array (array[{text:desc,value:placeholder}])
	 */
	public function simplify_placeholders_for_wysiwyg( $placeholders = array() ) {

		$return = array();

		// cycle through and simplify
		foreach ( $placeholders as $placeholder_key => $placeholder_info ) {

			// label
			$placeholder_label = ( isset( $placeholder_info['description'] ) && ! empty( $placeholder_info['description'] ) ) ? $placeholder_info['description'] . ' (' . $placeholder_key . ')' : $placeholder_key;

			$return[] = array(
				'text'  => $placeholder_label,
				'value' => $placeholder_key,
			);

		}

		return $return;
	}
}
