<?php

/**
 * The definition for the contact class.
 *
 * @package CGContacts
 * @category Productivity
 * @author  calguy1000 <calguy1000@cmsmadesimple.org>
 * @copyright Copyright 2014 by Robert Campbell
 */

namespace CGContacts;

/**
 * A contact has numerous properties (full name, nickname, title, company, division, role
 * can belong to zero or more categories (which can be nested)
 * and can have one or more objects
 *    some objects (name) can only exist once
 *    other objects can exist multiple times
 *      these objects each have a label
 *      and can be preferred
 * names:
 *   if a fullname is set, it is used as the display name.
 *   else if nickname is set, it is used as the display name
 *   else if a name object exists, it is formatted and used as the display name
 * preferred:
 *   if no object of a certain type is set as 'preferred' then the first object
 *   of that type is returned.  Multiple objects of a certain type can be preferred
 *   but only the first one will be returned when asking for the preferred object of a type.
 * labels
 *   user defiend, but system may create numerous standard labels (i.e: home/work)
 *   labels cannot be empty.
 *
 * Note: This class (and it's dependent classes) use heavy use of exceptions.
 */
class contact
{
    const LABEL_NAME = 'name';
    const STATUS_DISABLED = 'disabled';

    /**
     * @ignore
     */
    private static $_contact_cache = array();

    /**
     * @ignore
     */
    private $_data = array('id'=>null,'status'=>null,'guid'=>null,'feu_uid'=>null,'fullname'=>null,'nickname'=>null,'company'=>null,'title'=>null,'role'=>null,'note'=>null,'image'=>null,'logo'=>null);

    /**
     * @ignore
     */
    private $_objects = array();

    /**
     * @ignore
     */
    private $_categories = array();

    /**
     * @ignore
     */
    public function __clone()
    {
        $this->_data['id'] = null;
        $this->_data['guid'] = null;
    }

    /**
     * Add an object to the contact
     *
     * @param contact_sub $object The object to add (must be a valid object)
     * @return void
     */
    public function add_object(contact_sub $object)
    {
        try {
            $class = get_class($object);
            $object->validate();

            // allow only one name object
            if( !$object->allows_multiple() && $this->count_objects($class) > 0 ) {
                throw new \Exception('Cannot add a second object of type '.$class);
            }

            if( $object->is_preferred() ) {
                // if this is a preferred object, set all the other objects of this class to be non preferred.
                if( is_array($this->_objects) && count($this->_objects) ) {
                    foreach( $this->_objects as $obj ) {
                        if( $object->get_class() == $class ) $obj->set_preferred(FALSE);
                    }
                }
            }

            $this->_objects[] = $object;
        }
        catch( \Exception $e ) {
            throw $e;
        }
    }

    /**
     * Count the number of objects of a certain class
     *
     * @access protected
     * @param string $class_name
     * @return int
     */
    public function count_objects($class_name)
    {
        $class_name = trim((string)$class_name);
        if( !$class_name ) throw new \CmsInvalidDataException('Class name cannot be empty');
        $out = 0;

        if( is_array($this->_objects) && count($this->_objects) ) {
            foreach( $this->_objects as $obj ) {
                if( $obj->get_class() == $class_name ) $out++;
            }
        }

        return $out;
    }

    /**
     * Get an object of a specific class with a specific label
     *
     * @access protected
     * @param string $class_name
     * @param string $label
     * @return contact_sub
     */
    public function get_object($class_name,$label)
    {
        $class_name = trim((string)$class_name);
        if( !$class_name ) throw new \CmsInvalidDataException('Class name cannot be empty');

        $label = trim((string)$label);
        if( !$label ) throw new \CmsInvalidDataException('Label cannot be empty');

        $label = trim((string)$label);
        if( is_array($this->_objects) && count($this->_objects) ) {
            foreach( $this->_objects as $obj ) {
                if( $obj->get_class() == $class_name && $obj->get_label() == $label ) return $obj;
            }
        }
        // todo: throw exception ??
    }

    /**
     * Retrieve all of the objects of a certain type
     *
     * @param string $class_name
     * @return contact_sub[]
     */
    protected function get_objects_by_type($class_name)
    {
        if( is_array($this->_objects) && count($this->_objects) ) {
            $out = array();
            foreach( $this->_objects as $obj ) {
                if( $obj->get_class() == $class_name ) $out[] = $obj;
            }
            if( count($out) ) return $out;
        }
    }

    /**
     * Remove an object from the list
     *
     * @param string $class_name
     * @param string $label
     * @return void
     */
    public function remove_object($class_name,$label)
    {
        $class_name = trim((string)$class_name);
        if( !$class_name ) throw new \CmsInvalidDataException('Class name cannot be empty');

        $label = trim((string)$label);
        if( !$label ) throw new \CmsInvalidDataException('Label cannot be empty');

        // disallow removing a 'name' object if we have one
        for( $i = 0; $i < count($this->_objects); $i++ ) {
            $obj = $this->_objects[$i];
            if( $obj->get_class() == $class_name && $obj->get_label() == $label ) array_splice($this->_objects,$i,1);
        }
    }

    /**
     * Get the preferred object of a certain class
     *
     * Multiple objects of a class can be preferred.  Or none.
     * If no objects of the specified class are preferred, the first object of the specified
     * class will be returned.
     *
     * @access protected
     * @param string $class_name
     * @return contact_sub
     */
    public function get_preferred_object($class_name)
    {
        $class_name = trim((string)$class_name);
        if( !$class_name ) throw new \CmsInvalidDataException('Class name cannot be empty');

        // get the preferred object of a specific type
        // we remember the index of the first instance of the specified class
        // if no objects are explicitly marked as preferred
        // then we return the first object of that class that we find.
        $idx_first = -1;
        for( $i = 0; $i < count($this->_objects); $i++ ) {
            $obj = $this->_objects[$i];
            if( $obj->get_class() == $class_name ) {
                if( $idx_first < 0 ) $idx_first = $i;
                if( $obj->is_preferred() ) return $obj;
            }
        }
        if( $idx_first > -1 ) return $this->_objects[$idx_first];
        // todo: throw exception ??
    }

    /**
     * Set the preferred flag on an object (identified by class and label) to true
     * automatically sets all other objects of that class to be non-preferred
     *
     * @param string $class_name
     * @param string $label
     * @return void
     */
    public function set_preferred($class_name,$label)
    {
        $class_name = trim((string)$class_name);
        if( !$class_name ) throw new \CmsInvalidDataException('Class name cannot be empty');

        $label = trim((string)$label);
        if( !$label ) throw new \CmsInvalidDataException('Label cannot be empty');

        // set all the objects with this class to be not preferred
        $class_name = trim((string)$class_name);
        $done = FALSE;
        for( $i = 0; $i < count($this->_objects); $i++ ) {
            $obj =& $this->_objects[$i];
            if( $obj->get_class() == $class_name ) {
                $obj->set_preferred(FALSE);
                if( !$done && $obj->get_label() == $label ) {
                    $obj->set_preferred(TRUE);
                    $done = TRUE;
                }
            }
        }

        $obj = $this->get_object($class_name,$label);
        if( $obj ) $obj->set_preferred();
    }

    //
    // basic direct accessors/setters
    //

    /**
     * Return the id of thsi contact
     * The id of a new (unsaved) object is null
     *
     * @return int
     */
    public function get_id()
    {
        return \cge_utils::get_param($this->_data,'id');
    }

    /**
     * Return the FEU uid attached to this contact (if any)
     *
     * @return int
     */
    public function get_feu_uid()
    {
        return \cge_utils::get_param($this->_data,'feu_uid');
    }

    /**
     * Associate an FEU uid with this contact.
     * A 0 or negative value will remove the association.
     *
     * @param int $uid
     */
    public function set_feu_uid($uid)
    {
        $uid = (int) $uid;
        if( $uid < 1 ) $uid = null;
        $this->_data['feu_uid'] = $uid;
    }

    /**
     * Return the guid for this contact
     * The guid is a unique, randomly generated string representing this contact.
     * Typically the guid is used in export formats like vcard
     *
     * @return string
     */
    public function get_guid()
    {
        return \cge_utils::get_param($this->_data,'guid');
    }

    /**
     * Set the GUID for this contact.
     * The GUID must be unique to all users.  If none is specified, on save
     * a uniqe GUID will be generated.
     *
     * @param string $str
     */
    public function set_guid($str)
    {
        $str = trim($str);
        $this->_data['guid'] = $str;
    }

    /**
     * Get the full name (if any) associated with this contact
     * The full name may not be set.
     *
     * @return string
     */
    public function get_fullname()
    {
        return \cge_utils::get_param($this->_data,'fullname');
    }

    /**
     * Set the full name for this contact
     *
     * @param string $val
     * @return void
     */
    public function set_fullname($val)
    {
        $this->_data['fullname'] = trim((string) $val);
    }

    /**
     * Get the image (if any) associated with this contact
     * The image is stored as a data uri
     *
     * @return string
     */
    public function get_image()
    {
        return \cge_utils::get_param($this->_data,'image');
    }

    /**
     * Set the image for this contact
     *
     * @param string $val A data uri to an image... the data uri must fit within 64k
     * @return void
     */
    public function set_image($val)
    {
        $val = trim($val);
        if( !$val ) {
            $this->_data['image'] = $val;
            return;
        }

        $test = substr($val,0,10);
        if( $test != 'data:image' ) throw new \CmsInvalidDataException('Image value specified is not a data URI: '.$test);
        if( strlen($val) > 65535 ) throw new \CmsInvalidDataException('Image value specified is too large');
        $this->_data['image'] = $val;
    }


    /**
     * Get the logo (if any) associated with this contact
     * The image is stored as a data uri.. maximum 64k
     *
     * @return string
     */
    public function get_logo()
    {
        return \cge_utils::get_param($this->_data,'logo');
    }

    /**
     * Get the logo (if any) associated with this contact
     * The logo is stored as a data uri.. maximum 64k
     *
     * @param string $val
     */
    public function set_logo($val)
    {
        $val = trim($val);
        if( !$val ) {
            $this->_data['logo'] = $val;
            return;
        }

        if( !startswith($val,'data:') ) throw new \CmsInvalidDataException('Logo value specified is not a data URI: '.$val);
        if( strlen($val) > 65535 ) throw new \CmsInvalidDataException('Logo value specified is too large');
        $this->_data['logo'] = $val;
    }

    /**
     * Return the nickname (if any) associated with this contact
     *
     * @return string
     */
    public function get_nickname()
    {
        return \cge_utils::get_param($this->_data,'nickname');
    }

    /**
     * Set the nickname (or erase it) for this contact
     *
     * @param string $val
     * @return void
     */
    public function set_nickname($val)
    {
        $this->_data['nickname'] = trim((string) $val);
    }

    /**
     * Return the company name (if any) associated with this contact
     *
     * @return string
     */
    public function get_company()
    {
        return \cge_utils::get_param($this->_data,'company');
    }

    /**
     * Set (or erase) the company name for this contact
     *
     * @param string $val
     * @return void
     */
    public function set_company($val)
    {
        $this->_data['company'] = trim((string) $val);
    }

    /**
     * Return the title (if any) associated with this contact
     *
     * @return string
     */
    public function get_title()
    {
        return \cge_utils::get_param($this->_data,'title');
    }

    /**
     * Set (or erase) the title associated with this contact
     *
     * @param string $val
     * @return void
     */
    public function set_title($val)
    {
        $this->_data['title'] = trim((string) $val);
    }

    /**
     * Get the role (if any) associated with this contact
     *
     * @return string
     */
    public function get_role()
    {
        return \cge_utils::get_param($this->_data,'role');
    }

    /**
     * Set (or erase) the role associated with this contact
     *
     * @param string $val
     */
    public function set_role($val)
    {
        $this->_data['role'] = trim((string) $val);
    }

    /**
     * Set the name associated with this contact
     *
     * @param string $prefix The prefix (i.e: Mr, Mrs. Dr, etc)
     * @param string $first  The first name
     * @param string $middle The middle name (optional)
     * @param string $last   The surname (optional)
     * @param string $suffix The suffix (i.e: PHD, B.S., P.Geo)
     * @return void
     */
    public function set_name($prefix,$first,$middle,$last,$suffix)
    {
        $adding = false;
        $tmp = $this->get_objects_by_type('contact_name');
        $name = null;
        if( count($tmp) ) {
            $name = $tmp[0];
        }
        else {
            $name = new contact_name($this);
            $adding = true;
        }
        $name->set_prefix($prefix);
        $name->set_first($first);
        $name->set_middle($middle);
        $name->set_last($last);
        $name->set_suffix($suffix);
        $name->set_preferred(TRUE);
        $name->set_label(self::LABEL_NAME);
        if( $adding ) $this->add_object($name);
    }

    /**
     * Return the name object (if any) associated with this contact
     *
     * @return contact_sub
     */
    public function get_name()
    {
        return $this->get_preferred_object('contact_name');
    }

    /**
     * Erase the name object (if any) associated with this contact
     *
     * @return void
     */
    public function remove_name()
    {
        $class_name = 'contact_name';
        for( $i = 0; $i < count($this->_objects); $i++ ) {
            if( $this->_objects[$i]->get_class() == $class_name ) unset($this->_objects[$i]);
        }
    }

    /**
     * Get a displayable name
     * If a name object exists, it's formatted value is returned
     * otherwise, the full name, or nickname is returned whichever occurred first
     *
     * @return string
     */
    public function get_display_name()
    {
        $fn = $this->get_fullname();
        if( $fn ) return $fn;
        $nn = $this->get_nickname();
        if( $nn ) return $nn;
        $obj = $this->get_name();
        if( $obj ) return $obj->get_formatted();
    }

    /**
     * alias for get_guid()
     *
     * @return string
     */
    public function get_uuid()
    {
        return $this->get_guid();
    }

    /**
     * Get the status (if any) for this contact
     * a non empty status indicates that the contact is disabled
     *
     * @return string
     */
    public function get_status()
    {
        return \cge_utils::get_param($this->_data,'status');
    }

    /**
     * Sets this contact to disabled.
     *
     * @return void
     */
    public function disable()
    {
        $this->_data['status'] = self::STATUS_DISABLED;
    }

    /**
     * Test if the contact is disabled
     *
     * @return bool
     */
    public function is_enabled()
    {
        if( $this->get_status() != self::STATUS_DISABLED ) return TRUE;
        return FALSE;
    }

    /**
     * Set (or erase) the note associated with this contact
     * Maximum size is 64k
     *
     * @param string $val
     * @return void
     */
    public function set_note($val)
    {
        $val = trim((string)$val);
        if( strlen($val) > 65535 ) throw new \CmsInvalidDataException('The maximum size of a note is 64k');
        $this->_data['note'] = $val;
    }

    /**
     * Return the note (if any) associated with this contact
     *
     * @return string
     */
    public function get_note()
    {
        return \cge_utils::get_param($this->_data,'note');
    }

    // email methods

    /**
     * Add an email address to this contact
     *
     * Multiple email objects with the same label are permitted.
     * and multiple preferred are permitted in the database.
     *
     * @param string $email The email address
     * @param string $label The label
     * @param bool $preferred Wether this is the preferred email address
     * @return void
     */
    public function add_email($email,$label,$preferred = FALSE)
    {
        $email = trim((string)$email);
        $label = trim((string)$label);
        if( !$email ) throw new \CmsInvalidDataException('Email address cannot be empty');
        if( !$label ) throw new \CmsInvalidDataException('Label cannot be empty');

        $obj = new contact_email($this);
        $obj->set_value($email);
        $obj->set_label($label);
        $obj->set_preferred((bool)$preferred);
        $this->add_object($obj);
    }

    /**
     * Set an email object with the specifiede label.
     * This method will first search email objects for the same label.
     * if a match is found, it's value and preferred flag are updated.
     * if no match is found, a new object is created.
     *
     * @param string $value
     * @param string $label
     * @param bool $preferred
     */
    public function insert_add_email($value,$label,$preferred = FALSE)
    {
        $label = trim((string)$label);
        if( !$label ) throw new \CmsInvalidDataException('Label cannot be empty');
        $value = trim((string)$value);
        if( !$value ) throw new \CmsInvalidDataException('Value cannot be empty');

        $class_name = 'contact_email';
        if( is_array($this->_objects) && count($this->_objects) ) {
            foreach( $this->_objects as &$obj ) {
                if( $obj->get_class() != $class_name ) continue;
                if( $obj->get_label() != $label ) continue;
                // found a match
                $obj->set_preferred((bool) $preferred);
                $obj->set_value($value);
                return;
            }
        }

        // no matches found
        $this->add_email($value,$label,$preferred);
    }

    /**
     * Get an email address associated with the specified label.
     *
     * @param string $label
     * @return string
     */
    public function get_email($label)
    {
        $label = trim((string)$label);
        if( !$label ) throw new \CmsInvalidDataException('Label cannot be empty');
        $obj = $this->get_object('contact_email',$label);
        if( $obj ) return $obj->get_address();
    }

    /**
     * Return the preferred email object (if any)
     *
     * @return contact_email
     */
    public function get_preferred_email_obj()
    {
        return $this->get_preferred_object('contact_email');
    }

    /**
     * Return the preferred email address (if any)
     *
     * @return string
     */
    public function get_preferred_email()
    {
        $obj = $this->get_preferred_object('contact_email');
        if( $obj ) return $obj->get_value();
    }

    /**
     * Return all of the email objects (if any)
     *
     * @return contact_email[]
     */
    public function get_email_objects()
    {
        return $this->get_objects_by_type('contact_email');
    }

    /**
     * Remove all the email addresses with the specified label
     *
     * @param string $label
     * @return void
     */
    public function remove_email($label)
    {
        $label = trim((string)$label);
        if( !$label ) throw new \CmsInvalidDataException('Label cannot be empty');
        $this->remove_object('contact_email',$label);
    }

    // url methods

    /**
     * Add a url to the contact
     *
     * @param string $url
     * @param string $label
     * @param bool $preferred
     * @return void
     */
    public function add_url($url,$label,$preferred = FALSE)
    {
        $url = trim((string)$url);
        if( !$url ) throw new \CmsInvalidDataException('URL cannot be empty');
        $label = trim((string)$label);
        if( !$label ) throw new \CmsInvalidDataException('Label cannot be empty');
        $obj = new contact_url($this);
        $obj->set_value($url);
        $obj->set_label($label);
        $obj->set_preferred((bool) $preferred);
        $this->add_object($obj);
    }

    /**
     * Set the first URL object in the list to the specified value.
     * If no URL objects are found, insert one as a preferred URL.
     *
     * @param string $value
     * @return void
     */
    public function insert_set_url($value)
    {
        $value = trim((string) $value);
        if( !$value ) throw new \CmsInvalidDataException('Value cannot be empty');

        $class_name = 'contact_url';
        if( is_array($this->_objects) && count($this->_objects) ) {
            foreach( $this->_objects as &$obj ) {
                if( $obj->get_class() != $class_name ) continue;
                $obj->set_value($value);
                return;
            }
        }

        $this->add_url($value,'unknown',1);
    }

    /**
     * Get the first url with the specified label (if any)
     *
     * @param string $label
     * @return string
     */
    public function get_url($label)
    {
        $label = trim((string)$label);
        if( !$label ) throw new \CmsInvalidDataException('Label cannot be empty');
        $obj = $this->get_object('contact_url',$label);
        if( $obj ) return $obj->get_value();
    }

    /**
     * Get the preferred url (if any) with the specified label
     *
     * @return string
     */
    public function get_preferred_url()
    {
        $obj = $this->get_preferred_object('contact_url');
        if( $obj ) return $obj->get_value();
    }

    /**
     * Get the URL objects associated with this contact
     *
     * @return contact_url[]
     */
    public function get_url_objects()
    {
        return $this->get_objects_by_type('contact_url');
    }

    /**
     * Remove all of the url objects with the specified label
     *
     * @param string $label
     * @return void
     */
    public function remove_url($label)
    {
        $this->remove_object('contact_url',$label);
    }

    // im methods

    /**
     * Add an IM object to the contact
     *
     * @param string $im (the IM value (i.e: facebook username))
     * @param string $label
     * @param bool   $preferred
     * @return bool
     */
    public function add_im($im,$label,$preferred = FALSE)
    {
        $im = trim((string)$im);
        if( !$im ) throw new \CmsInvalidDataException('IM value cannot be empty');
        $label = trim((string)$label);
        if( !$label ) throw new \CmsInvalidDataException('Label cannot be empty');

        $obj = new contact_im($this);
        $obj->set_contact($im);
        $obj->set_label($label);
        $obj->set_preferred((bool) $preferred);
        $this->add_object($obj);
    }

    /**
     * Return the (first) IM with the specified label (if any)
     *
     * @param string $label
     * @return string
     */
    public function get_im($label)
    {
        $label = trim((string)$label);
        if( !$label ) throw new \CmsInvalidDataException('Label cannot be empty');

        $obj = $this->get_object('contact_im',$label);
        if( $obj ) return $obj->get_value();
    }

    /**
     * Get the preferred IM object (if any)
     *
     * @return string
     */
    public function get_preferred_im()
    {
        $obj = $this->get_preferred_object('contact_im');
        if( $obj ) return $obj->get_value();
    }

    /**
     * Remove all IM objects with the specified label
     *
     * @param string $label
     * @return void
     */
    public function remove_im($label)
    {
        $this->remove_object('contact_im',$label);
    }

    // phone methods

    /**
     * Add a phone number object of the specified type
     *
     * @param string $type The type (one of voice,msg,fax,mobile,pager,video,satellite)
     * @param string $value
     * @param string $label
     * @param bool $preferred
     */
    public function add_phone($type,$value,$label,$preferred = FALSE)
    {
        $type = trim((string)$type);
        if( !$type ) throw new \CmsInvalidDataException('Phone type cannot be empty');
        $label = trim((string)$label);
        if( !$label ) throw new \CmsInvalidDataException('Label cannot be empty');
        $value = trim((string)$value);
        if( !$value ) throw new \CmsInvalidDataException('Value cannot be empty');

        $obj = new contact_phone($this);
        $obj->set_type($type);
        $obj->set_value($value);
        $obj->set_label($label);
        $obj->set_preferred((bool) $preferred);
        $this->add_object($obj);
    }

    /**
     * Set a phone object with the specifiede label and type.
     * This method will first search phone objects for the same label and type
     * if a match is found, it's value and preferred flag are updated.
     * if no match is found, a new object is created.
     *
     * @param string $type The type (one of voice,msg,fax,mobile,pager,video,satellite)
     * @param string $value
     * @param string $label
     * @param bool $preferred
     */
    public function insert_add_phone($type,$value,$label,$preferred = FALSE)
    {
        $type = trim((string)$type);
        if( !$type ) throw new \CmsInvalidDataException('Phone type cannot be empty');
        $label = trim((string)$label);
        if( !$label ) throw new \CmsInvalidDataException('Label cannot be empty');
        $value = trim((string)$value);
        if( !$value ) throw new \CmsInvalidDataException('Value cannot be empty');

        $class_name = 'contact_phone';
        if( is_array($this->_objects) && count($this->_objects) ) {
            foreach( $this->_objects as &$obj ) {
                if( $obj->get_class() != $class_name ) continue;
                if( $obj->get_type() != $type ) continue;
                if( $obj->get_label() != $label ) continue;
                // found a match
                $obj->set_preferred((bool) $preferred);
                $obj->set_value($value);
                return;
            }
        }

        // no matches found
        $this->add_phone($type,$value,$label,$preferred);
    }

    /**
     * Get the first phone object with the specified label (if any)
     *
     * @param string $label
     * @return contact_sub
     */
    public function get_phone_object($label)
    {
        $label = trim((string)$label);
        if( !$label ) throw new \CmsInvalidDataException('Label cannot be empty');
        return $this->get_object('contact_phone',$label);
    }

    /**
     * Get first phone object of the specified type
     *
     * @param string $type
     * @return contact_sub
     */
    public function get_phone_object_by_type($type)
    {
        $type = trim((string)$type);
        if( !$type ) throw new \CmsInvalidDataException('Type cannot be empty');

        $class_name = 'contact_phone';
        if( is_array($this->_objects) && count($this->_objects) ) {
            foreach( $this->_objects as $obj ) {
                if( $obj->get_class() == $class_name && $obj->get_type() == $type ) return $obj;
            }
        }
    }

    /**
     * Get the first phone number with the specified label
     * This is useful for getting a number, note the type is not returned
     *
     * @param string $label
     * @return string
     */
    public function get_phone($label)
    {
        $label = trim((string)$label);
        if( !$label ) throw new \CmsInvalidDataException('Label cannot be empty');

        $obj = $this->get_phone_object($label);
        if( $obj ) return $obj->get_value();
    }


    /**
     * Get the first phone number of the specified type.
     *
     * @param string $type
     * @return string
     */
    public function get_phone_by_type($type)
    {
        $obj = $this->get_phone_object_by_type($type);
        if( $obj ) return $obj->get_value();
    }

    /**
     * Get the type of the first phone object with the specified label.
     *
     * @param string $label
     * @return string
     */
    public function get_phone_type($label)
    {
        $label = trim((string)$label);
        if( !$label ) throw new \CmsInvalidDataException('Label cannot be empty');

        $obj = $this->get_phone_object($label);
        if( $obj ) return $obj->get_type();
    }

    /**
     * Get the first preferred phone object (if any)
     *
     * @return contact_sub
     */
    public function get_preferred_phone_obj()
    {
        return $this->get_preferred_object('contact_phone');
    }

    /**
     * Get the preferred phone number (if any)
     *
     * @return string
     */
    public function get_preferred_phone()
    {
        $obj = $this->get_preferred_phone_obj();
        if( $obj ) return $obj->get_value();
    }

    /**
     * Get all of the phone objects associated with this contact.
     */
    public function get_phone_objects()
    {
        return $this->get_objects_by_type('contact_phone');
    }

    /**
     * Remove all phone numbers with the specified label
     *
     * @param string $label
     * @return void
     */
    public function remove_phone($label)
    {
        $label = trim((string)$label);
        if( !$label ) throw new \CmsInvalidDataException('Label cannot be empty');

        $this->remove_object('contact_phone',$label);
    }

    // address methods

    /**
     * Add an address object manually.
     * Any of the values may be empty (except label).  However, at least one value must
     * be specified.
     *
     * @param string $street
     * @param string $pobox
     * @param string $city
     * @param string $state
     * @param string $zip
     * @param string $country
     * @param string $label
     * @param bool   $preferred
     */
    public function add_address($street,$pobox,$city,$state,$zip,$country,$label,$preferred = FALSE)
    {
        $obj = new contact_address($this);
        $obj->set_street($street);
        $obj->set_pobox($pobox);
        $obj->set_city($city);
        $obj->set_state($state);
        $obj->set_zip($zip);
        $obj->set_country($country);
        $obj->set_label($label);
        $obj->set_preferred($preferred);
        $this->add_object($obj);
    }

    /**
     * Update an address object with the specified label.
     * If no address object with that label exists, create a new address record.
     *
     *
     * @param string $street
     * @param string $pobox
     * @param string $city
     * @param string $state
     * @param string $zip
     * @param string $country
     * @param string $label
     * @param bool   $preferred
     */
    public function insert_add_address($street,$pobox,$city,$state,$zip,$country,$label,$preferred = FALSE)
    {
        $label = trim((string)$label);
        if( !$label ) throw new \CmsInvalidDataException('Label cannot be empty');

        $class_name = 'contact_address';
        if( is_array($this->_objects) && count($this->_objects) ) {
            foreach( $this->_objects as &$obj ) {
                if( $obj->get_class() != $class_name ) continue;
                if( $obj->get_label() != $label ) continue;
                // found a match
                $obj->set_street($street);
                $obj->set_pobox($pobox);
                $obj->set_city($city);
                $obj->set_state($state);
                $obj->set_zip($zip);
                $obj->set_country($country);
                $obj->set_label($label);
                $obj->set_preferred($preferred);
                return;
            }
        }

        // no matches found
        $this->add_address($street,$pobox,$city,$state,$zip,$country,$label,$preferred);
    }

    /**
     * Get an address with the specified label
     *
     * @param string $label
     * @return contact_sub
     */
    public function get_address($label)
    {
        return $this->get_object('contact_address',$label);
    }

    /**
     * Return the preferred address
     *
     * @return contact_sub
     */
    public function get_preferred_address()
    {
        return $this->get_preferred_object('contact_address');
    }

    /**
     * Get all of the address objects associated with this contact.
     *
     * @return contact_address[]
     */
    public function get_address_objects()
    {
        return $this->get_objects_by_type('contact_address');
    }

    /**
     * Return all addresses with the specified label
     *
     * @param string $label
     * @return void
     */
    public function remove_address($label)
    {
        $this->remove_object('contact_address',$label);
    }

    // coordinate methods

    /**
     * Add a coordinate set to the contact object
     *
     * @param float $lat  Latitude (must be between -180 and 180)
     * @param float $long Longitude (must be between -180 and 180)
     * @param string $label
     * @param bool $preferred
     * @return void
     */
    public function add_coords($lat,$long,$label,$preferred = FALSE)
    {
        $obj = new contact_coords($this);
        $obj->set_lat($lat);
        $obj->set_long($long);
        $obj->set_label($label);
        $obj->set_preferred((bool) $preferred);
        $this->add_object($obj);
    }

    /**
     * Get the coordinates with the specified label (if any)
     *
     * @param string $label
     * @return contact_sub
     */
    public function get_coords($label)
    {
        return $this->get_object('contact_coords',$label);
    }

    /**
     * Get the preferred coordinates object (if any)
     *
     * @return contact_sub
     */
    public function get_preferred_coords()
    {
        return $this->get_preferred_object('contact_coords',$label);
    }

    /**
     * Remove any coordinate objects wth the specified label
     *
     * @param string $label
     * @return void
     */
    public function remove_coords($label)
    {
        $this->remove_coords('contact_address',$label);
    }

    // date methods

    /**
     * Add a date object (birthday, or anniversary) to the contact object
     *
     * @param string $date
     * @param string $label
     * @param bool   $preferred
     * @return void
     */
    public function add_date($date,$label,$preferred = FALSE)
    {
        $obj = new contact_date($this);
        $obj->set_date($date);
        $obj->set_label($label);
        $obj->set_preferred((bool) $preferred);
        $this->add_object($obj);
    }

    /**
     * Get the date with the specified label (if any)
     *
     * @param string $label
     * @return string
     */
    public function get_date($label)
    {
        $obj = $this->get_object('contact_date',$label);
        if( $obj ) return $obj->get_t();
    }

    /**
     * Get the preferred date (if any)
     *
     * @return string
     */
    public function get_preferred_date()
    {
        $obj = $this->get_preferred_object('contact_date');
        if( $obj ) return $obj->get_value();
    }


    /**
     * Get all of the date objects associated with this contact.
     *
     * @return contact_date[]
     */
    public function get_date_objects()
    {
        return $this->get_objects_by_type('contact_date');
    }

    /**
     * Remove any date objects with the specified label
     *
     * @param string $label
     * @return void
     */
    public function remove_date($label)
    {
        $this->remove_object('contact_date',$label);
    }

    // category methods

    /**
     * @ignore
     */
    private function _get_category_id($cat)
    {
        $id = null;
        if( is_a($cat,'\CGContacts\category') ) {
            $id = $cat['id'];
        }
        else if( (int)$cat > 0 ) {
            // if it's a valid category, add it
            $cat = category::load($cat);
            $id = $cat['id'];
        }

        if( $id < 1 ) throw new \CmsInvalidDataException('Could not find the specified category');
        return $id;
    }

    /**
     * Add the the specified category to the contact object
     *
     * @param mixed $cat (Either an integer category id, or \CGContacts\cateogry object)
     * @return void
     */
    public function add_category($cat)
    {
        $id = $this->_get_category_id($cat);
        if( $id > 0 && !in_array($id, $this->_categories) ) $this->_categories[] = $id;
    }

    /**
     * Get the list of category ids that this contact belongs to (if any)
     *
     * @return int[]
     */
    public function get_categories()
    {
        if( count($this->_categories) ) return $this->_categories;
    }

    /**
     * Get the list of category objects that this contact belongs to (if any)
     *
     * @return \CGContacts\contact[]
     */
    public function get_categories_full()
    {
        if( !count($this->_categories) ) return;

        $out = array();
        foreach( $this->_categories as $id ) {
            $out[] = category::load($id);
        }
        return $out;
    }

    /**
     * De-associate the specified category from the contact
     *
     * @param mixed $cat (Either an integer category id, or \CGContacts\cateogry object)
     * @return void
     */
    public function remove_category($cat)
    {
        $id = $this->_get_category_id($cat);
        if( $id && in_array($id, $this->_categories) ) {
            $tmp = array();
            foreach( $this->_categories as $catid ) {
                if( $id != $catid ) $tmp[] = $catid;
            }
            $this->_categories = $tmp;
        }
    }

    /**
     * Return the timestamp of the time this contact was last modified.
     *
     * @return int
     */
    public function get_modified()
    {
        if( $this->get_id() > 0 ) return $this->_data['modified'];
    }
    //
    // js data
    //

    /**
     * Convert the contact object to an array suitable for conversion to json format
     *
     * @internal
     * @return array
     */
    public function to_data()
    {
        $out = $this->_data;
        $out['categories'] = $this->_categories;
        $out['objects'] = array();
        foreach( $this->_objects as $object ) {
            $out['objects'][] = $object->to_data();
        }

        // meta contains meta information.
        $out['meta'] = array();
        $out['meta']['display_name'] = $this->get_display_name();
        return $out;
    }

    /**
     * Populate this contact object given an array
     *
     * @internal
     * @return void
     */
    public function from_data($data)
    {
        $this->_data['id'] = \cge_utils::get_param($data,'id');
        $this->_data['status'] = \cge_utils::get_param($data,'status');
        $this->_data['guid'] = \cge_utils::get_param($data,'guid');
        $this->_data['feu_uid'] = \cge_utils::get_param($data,'feu_uid');
        $this->_data['fullname'] = \cge_utils::get_param($data,'fullname');
        $this->_data['nickname'] = \cge_utils::get_param($data,'nickname');
        $this->_data['company'] = \cge_utils::get_param($data,'company');
        $this->_data['title'] = \cge_utils::get_param($data,'title');
        $this->_data['role'] = \cge_utils::get_param($data,'role');
        $this->_data['note'] = \cge_utils::get_param($data,'note');
        $this->_data['modified'] = \cge_utils::get_param($data,'modified');
        $this->set_image(\cge_utils::get_param($data,'image'));
        $this->set_logo(\cge_utils::get_param($data,'logo'));

        $this->_categories = array();
        if( isset($data['categories']) ) $this->_categories = $data['categories'];

        $this->_objects = array();
        if( isset($data['objects']) && is_array($data['objects']) && count($data['objects']) ) {
            foreach( $data['objects'] as $obj_row ) {
                try {
                    if( !isset($obj_row['class']) ) continue;
                    $classname = "CGContacts\\".$obj_row['class'];
                    $obj = new $classname($this);
                    $obj->set_data($obj_row);
                    $obj->validate();
                    $this->add_object($obj);
                }
                catch( \Exception $e ) {
                    debug_to_log('error from data: '.$e->GetMessage());
                    // do nothing.
                }
            }
        }
    }

    //
    // begin database methods
    //

    /**
     * Load the specified contact object.
     * The loaded object is cached so that subsequent requests for that contact will not hit the database
     *
     * @param int $id The contact id
     * @return \CGcontacts\contact
     */
    public static function &load($id)
    {
        $id = (int)$id;
        if( $id < 1 ) throw new \CmsInvalidDataException('Invalid contact id specified');

        if( isset(self::$_contact_cache[$id]) ) return self::$_contact_cache[$id];

        // get the master row
        $db = \cge_utils::get_db();
        $query = 'SELECT id,status,guid,feu_uid,fullname,nickname,company,title,role,note,image,logo,modified FROM '.CGCONTACTS_TBL.' WHERE id = ?';
        $row = $db->GetRow($query,array($id));
        if( !is_array($row) ) throw new \CmsNotFoundException('Contact with id '.$id.' not found');

        // get the objects
        $query = 'SELECT contact_id,class,label,preferred,value FROM '.CGCONTACTS_SUB_TBL.' WHERE contact_id = ? ORDER BY class,label';
        $row['objects'] = $db->GetArray($query,array($id));

        // get the categories
        $query = 'SELECT DISTINCT cat_id FROM '.CGCONTACTS_ADR_CATS_TBL.' WHERE contact_id = ?';
        $row['categories'] = $db->GetCol($query,array($id));

        // put it all together
        $contact = new self();
        $contact->from_data($row);
        self::$_contact_cache[$id] = $contact;
        return $contact;
    }

    /**
     * Load the specified content object (by unique id)
     *
     * The loaded object is cached so that subsequent requests for that contact will not hit the database
     * @param string $uid The contact uid
     * @return \CGContacts\contact
     */
    public static function &load_by_guid($uid)
    {
        $db = \cge_utils::get_db();
        $query = 'SELECT id FROM '.CGCONTACTS_TBL.' WHERE guid = ?';
        $id = $db->GetOne($query,array($uid));
        if( $id ) return self::load($id);

        throw new \CmsNotFoundException('Contact witn guid '.$id.' not found');
    }

    /**
     * A quick (lightweight) convenience method to test if a contact with the specified id exists
     *
     * This method first checks the cache to see if the object exists, otherwise it does a simle
     * query to the database.
     *
     * @param int $id
     */
    public static function exists($id)
    {
        $id = (int)$id;
        if( $id < 1 ) throw new \CmsInvalidDataException('Invalid contact id specified');

        // if it's not in our cache... do a quick query
        if( isset(self::$_contact_cache[$id]) ) return TRUE;

        $db = \cge_utils::get_db();
        $query = 'SELECT id FROM '.CGCONTACTS_TBL.' WHERE id = ?';
        $tmp = $db->GetOne($query,array($id));
        if( $tmp == $id ) return TRUE;
        return FALSE;
    }

    /**
     * A method to save an existing contact object to the database
     *
     * @access protected
     * @return void
     */
    protected function _update()
    {
        if( $this->get_id() < 1 ) throw new \Exception('Cannot call '.__METHOD__.' without a valid contact id');
        if( $this->get_guid() == '' ) throw new \Exception('Cannot call '.__METHOD__.' without a valid guid');

        $this->_data['modified'] = time();
        $db = \cge_utils::get_db();

        try {
            $db->BeginTrans();

            $query = 'UPDATE '.CGCONTACTS_TBL.' SET status = ?, feu_uid = ?, fullname = ?, nickname = ?, company = ?, title = ?,
                      role = ?, note = ?, image = ?, logo = ?, modified = ? WHERE id = ?';
            $dbr = $db->Execute($query,array($this->_data['status'],$this->_data['feu_uid'],$this->_data['fullname'],$this->_data['nickname'],
                                             $this->_data['company'],$this->_data['title'],$this->_data['role'],$this->_data['note'],
                                             $this->_data['image'],$this->_data['logo'],
                                             $this->_data['modified'],$this->_data['id']));
            if( !$dbr ) throw new \Exception('SQL ERROR: '.$db->sql.' -- '.$db->ErrorMsg());

            $query = 'DELETE FROM '.CGCONTACTS_ADR_CATS_TBL.' WHERE contact_id = ?';
            $dbr = $db->Execute($query,array((int)$this->_data['id']));
            if( !$dbr ) throw new \Exception('SQL Error: '.$db->sql.' -- '.$db->ErrorMsg());

            $query = 'DELETE FROM '.CGCONTACTS_SUB_TBL.' WHERE contact_id = ?';
            $dbr = $db->Execute($query,array((int)$this->_data['id']));
            if( !$dbr ) throw new \Exception('SQL Error: '.$db->sql.' -- '.$db->ErrorMsg());

            // save categories
            if( is_array($this->_categories) && count($this->_categories) ) {
                $query = 'INSERT INTO '.CGCONTACTS_ADR_CATS_TBL.' (contact_id, cat_id) VALUES (?,?)';
                foreach( $this->_categories as $catid ) {
                    $dbr = $db->Execute($query,array($this->_data['id'],$catid));
                    if( !$dbr ) throw new \Exception('SQL Error: '.$db->sql.' -- '.$db->ErrorMsg());
                }
            }

            // save objects
            if( is_array($this->_objects) && count($this->_objects) ) {
                $query = 'INSERT INTO '.CGCONTACTS_SUB_TBL.' (contact_id, class, label, preferred, value) VALUES (?,?,?,?,?)';
                foreach( $this->_objects as $object ) {
                    $dbr = $db->Execute($query,array($this->_data['id'],$object->get_class(),$object->get_label(),$object->is_preferred(),$object->get_raw_value()));
                    if( !$dbr ) throw new \Exception('SQL Error: '.$db->sql.' -- '.$db->ErrorMsg());
                }
            }

            $db->CompleteTrans();
        }
        catch( \Exception $e ) {
            $db->RollbackTrans();
            debug_to_log($e->GetMessage());
            throw $e;
        }
    }

    /**
     * A method to save a new contact object to the database
     *
     * @access protected
     * @return void
     */
    protected function _insert()
    {
        if( $this->get_id() > 0 ) throw new \Exception('Cannot call '.__METHOD__.' with a valid contact id');

        if( $this->get_guid() == '' ) $this->_data['guid'] = utils::calc_guid();
        $this->_data['modified'] = time();
        $db = \cge_utils::get_db();

        try {
            $db->BeginTrans();

            $query = 'INSERT INTO '.CGCONTACTS_TBL.' (status,guid,feu_uid,fullname,nickname,company,title,role,note,image,logo,modified) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)';
            $dbr = $db->Execute($query,array($this->_data['status'],$this->_data['guid'],$this->_data['feu_uid'],$this->_data['fullname'],$this->_data['nickname'],
                                             $this->_data['company'],$this->_data['title'],$this->_data['role'],$this->_data['note'],
                                             $this->_data['image'],$this->_data['logo'],$this->_data['modified']));
            if( !$dbr ) throw new \Exception('SQL ERROR: '.$db->sql.' -- '.$db->ErrorMsg());
            $this->_data['id'] = $db->Insert_ID();

            // save categories
            if( is_array($this->_categories) && count($this->_categories) ) {
                $query = 'INSERT INTO '.CGCONTACTS_ADR_CATS_TBL.' (contact_id, cat_id) VALUES(?,?)';
                foreach( $this->_categories as $catid ) {
                    $dbr = $db->Execute($query,array($this->_data['id'],$catid));
                    if( !$dbr ) throw new \Exception('SQL Error: '.$db->sql.' -- '.$db->ErrorMsg());
                }
            }

            // save objects
            if( is_array($this->_objects) && count($this->_objects) ) {
                $query = 'INSERT INTO '.CGCONTACTS_SUB_TBL.' (contact_id, class, label, preferred, value) VALUES (?,?,?,?,?)';
                foreach( $this->_objects as $object ) {
                    $dbr = $db->Execute($query,array($this->_data['id'],$object->get_class(),$object->get_label(),$object->is_preferred(),$object->get_raw_value()));
                    if( !$dbr ) throw new \Exception('SQL Error: '.$db->sql.' -- '.$db->ErrorMsg());
                }
            }

            $db->CompleteTrans();
        }
        catch( \Exception $e ) {
            $db->RollbackTrans();
            debug_to_log($e->GetMessage());
            throw $e;
        }
    }

    /**
     * A method to validate the contact object (typically used before saving the object to the database)
     *
     * This method validates that there is some sort of displayable name, and that there is at least one valid object
     * exceptions are thrown on errors
     *
     * @return void
     */
    public function validate()
    {
        // we need some sort of name, and some sort of email, url, address, or phone number
        if( $this->get_display_name() == '') throw new \Exception(__CLASS__.' object is invalid (no name found)');

        if( count($this->_objects) == 0 ) throw new \Exception(__CLASS__.' object is invalid (no objects found)');

        $singles = array();
        for( $i = 0; $i < count($this->_objects); $i++ ) {
            $obj = $this->_objects[$i];
            $obj->validate();
            if( !$obj->allows_multiple() ) {
                $classname = get_class($obj);
                if( !isset($singles[$classname]) ) $singles[$classname] = 0;
                $singles[$classname]++;
                if( $singles[$classname] > 1 ) throw new \Exception('A contact can only have one instance of a '.$classname.' object');
            }
        }
    }

    /**
     * Save this object to the database
     *
     * First a validation check is performed, then depending upon if there is a valid contact id
     * either an update, or an insert is performed.  All static contact caches are cleared
     *
     * @return void
     */
    final public function save()
    {
        $this->validate();
        if( $this->get_id() > 0 ) {
            $this->_update();
        }
        else {
            $this->_insert();
        }
        self::$_contact_cache = array();
    }

    /**
     * Delete this contact object from the database
     *
     * The contact object is completely removed from the database, and the contact id
     * is erased, so that a subsequent save will result in a new insert
     * Additionally, all caches are cleared
     *
     * @return void
     */
    public function delete()
    {
        $cid = (int)$this->get_id();
        if( $cid < 1 ) return;

        $db = \cge_utils::get_db();
        try {
            self::$_contact_cache = array();

            $db->BeginTrans();

            $q = 'DELETE FROM '.CGCONTACTS_ADR_CATS_TBL.' WHERE contact_id = ?';
            $dbr = $db->Execute($q, array($cid));
            if( !$dbr ) throw new \Exception('SQL Error: '.$db->sql.' -- '.$db->ErrorMsg());

            $q = 'DELETE FROM '.CGCONTACTS_SUB_TBL.' WHERE contact_id = ?';
            $dbr = $db->Execute($q, array($cid));
            if( !$dbr ) throw new \Exception('SQL Error: '.$db->sql.' -- '.$db->ErrorMsg());

            $q = 'DELETE FROM '.CGCONTACTS_TBL.' WHERE id = ?';
            $dbr = $db->Execute($q, array($cid));
            if( !$dbr ) throw new \Exception('SQL Error: '.$db->sql.' -- '.$db->ErrorMsg());

            $db->CompleteTrans();
            $this->_data['id'] = null;
            self::$_contact_cache = array();
        }
        catch( \Exception $e ) {
            $db->RollbackTrans();
            throw $e;
        }
    }

    /**
     * Get as much information as possible from the contact into a cge_address object
     *
     * @return cge_address
     */
    public function get_cge_address()
    {
        $out = new \cge_address;
        $out->set_company($this->get_company());
        $out->set_email($this->get_preferred_email());
        $name = $this->get_name();
        if( is_object($name) ) {
            $out->set_firstname($name->get_first());
            $out->set_lastname($name->get_last());
        } else {
            $name = $this->get_display_name();
            $parts = explode(' ',$name,3);
            $out->set_firstname($parts[0]);
            if( isset($parts[1]) ) $out->set_lastname($parts[1]);
        }
        $address = $this->get_preferred_address();
        if( is_object($address) ) {
            $out->set_address1($address->get_street());
            $pobox = $address->get_pobox();
            if( $pobox ) $out->set_address2($pobox .= 'P.O. Box '.$pobox);
            $out->set_city($address->get_city());
            $out->set_country($address->get_country());
            $out->set_state($address->get_state());
            $out->set_postal($address->get_zip());
        }
        $out->set_phone($this->get_preferred_phone());
        $out->set_fax($this->get_phone_by_type('fax'));
        return $out;
    }

} // end of class

?>
