I already created the below docs:
Endpoints
1. Get All Tickets

Endpoint: /tickets/all
Method: GET
Description: Retrieves a list of all tickets with pagination support.

Parameters:

    page (optional, integer): The page number to retrieve. Defaults to 1 if not provided.
    limit (optional, integer): The number of tickets per page. Defaults to 20.

Request Example:

GET /tickets/all?page=2&limit=50 HTTP/1.1
Host: your-domain.com
Session-ID: your-session-token

Response Example:

{
  "tickets": [
    {
      "id": 123,
      "number": "TCKT-000123",
      "subject": "Issue with Login",
      "status": "Open",
      "priority": "High",
      "created": "2023-10-01T12:34:56Z",
      "updated": "2023-10-02T08:21:30Z",
      "assigned_to": "Agent Name",
      "department": "Support"
    },
    // ... more tickets
  ],
  "pagination": {
    "current_page": 2,
    "per_page": 50,
    "total_pages": 10,
    "total_tickets": 500
  }
}

Error Responses:

    401 Unauthorized: If the Session-ID is missing or invalid.
    400 Bad Request: If pagination parameters are invalid.

2. Get Ticket Details

Endpoint: /tickets/{id}
Method: GET
Description: Retrieves detailed information about a specific ticket.

Parameters:

    id (required, integer): The unique ID of the ticket.

Request Example:

GET /tickets/123 HTTP/1.1
Host: your-domain.com
Session-ID: your-session-token

Response Example:

{
  "id": 123,
  "number": "TCKT-000123",
  "subject": "Issue with Login",
  "status": "Open",
  "priority": "High",
  "created": "2023-10-01T12:34:56Z",
  "updated": "2023-10-02T08:21:30Z",
  "assigned_to": "Agent Name",
  "department": "Support",
  "user": {
    "id": 45,
    "name": "John Doe",
    "email": "john.doe@example.com",
    "phone": "+1234567890"
  },
  "messages": [
    {
      "id": 1,
      "author": "John Doe",
      "content": "I'm unable to log in with my credentials.",
      "created": "2023-10-01T12:35:00Z"
    },
    // ... more messages
  ],
  "attachments": [
    {
      "id": 10,
      "filename": "screenshot.png",
      "url": "https://your-domain.com/attachments/10/download"
    },
    // ... more attachments
  ]
}

Error Responses:

    401 Unauthorized: If the Session-ID is missing or invalid.
    404 Not Found: If the ticket with the specified ID does not exist.

3. Create a New Ticket

Endpoint: /tickets/create
Method: POST
Description: Creates a new ticket in the system.

Parameters:

    email (required, string): The email address of the user creating the ticket.
    subject (required, string): The subject of the ticket.
    message (required, string): The body content of the ticket.
    department (optional, integer): The ID of the department to assign the ticket to.
    priority (optional, string): Priority level (Low, Medium, High, Urgent).
    attachments (optional, file[]): Files to attach to the ticket.

Request Example:

POST /tickets/create HTTP/1.1
Host: your-domain.com
Content-Type: multipart/form-data
Session-ID: your-session-token

--boundary
Content-Disposition: form-data; name="email"

john.doe@example.com
--boundary
Content-Disposition: form-data; name="subject"

Cannot Reset Password
--boundary
Content-Disposition: form-data; name="message"

I'm having trouble resetting my password using the link provided.
--boundary
Content-Disposition: form-data; name="department"

2
--boundary
Content-Disposition: form-data; name="priority"

High
--boundary
Content-Disposition: form-data; name="attachments[]"; filename="error_log.txt"
Content-Type: text/plain

<file content>
--boundary--

Response Example:

{
  "id": 124,
  "number": "TCKT-000124",
  "subject": "Cannot Reset Password",
  "status": "Open",
  "priority": "High",
  "created": "2023-10-03T09:15:30Z",
  "updated": "2023-10-03T09:15:30Z",
  "assigned_to": null,
  "department": "Support",
  "user": {
    "id": 45,
    "name": "John Doe",
    "email": "john.doe@example.com",
    "phone": "+1234567890"
  },
  "messages": [
    {
      "id": 2,
      "author": "John Doe",
      "content": "I'm having trouble resetting my password using the link provided.",
      "created": "2023-10-03T09:15:30Z"
    }
  ],
  "attachments": [
    {
      "id": 11,
      "filename": "error_log.txt",
      "url": "https://your-domain.com/attachments/11/download"
    }
  ]
}

Error Responses:

    400 Bad Request: If required parameters are missing or invalid.
    401 Unauthorized: If the Session-ID is missing or invalid.
    404 Not Found: If the specified department does not exist.

4. Update a Ticket

Endpoint: /tickets/{id}/update
Method: POST
Description: Updates the details of an existing ticket.

Parameters:

    id (required, integer): The unique ID of the ticket to update.
    subject (optional, string): New subject for the ticket.
    message (optional, string): Additional message or note.
    department (optional, integer): New department ID.
    priority (optional, string): New priority level.
    status (optional, string): Update the status (Open, Closed, etc.).
    attachments (optional, file[]): New files to attach.

Request Example:

POST /tickets/124/update HTTP/1.1
Host: your-domain.com
Content-Type: multipart/form-data
Session-ID: your-session-token

--boundary
Content-Disposition: form-data; name="status"

Closed
--boundary
Content-Disposition: form-data; name="message"

Issue has been resolved.
--boundary--

Response Example:

{
  "id": 124,
  "number": "TCKT-000124",
  "subject": "Cannot Reset Password",
  "status": "Closed",
  "priority": "High",
  "created": "2023-10-03T09:15:30Z",
  "updated": "2023-10-04T10:20:45Z",
  "assigned_to": "Agent Smith",
  "department": "Support",
  "user": {
    "id": 45,
    "name": "John Doe",
    "email": "john.doe@example.com",
    "phone": "+1234567890"
  },
  "messages": [
    {
      "id": 2,
      "author": "John Doe",
      "content": "I'm having trouble resetting my password using the link provided.",
      "created": "2023-10-03T09:15:30Z"
    },
    {
      "id": 3,
      "author": "Agent Smith",
      "content": "Issue has been resolved.",
      "created": "2023-10-04T10:20:45Z"
    }
  ],
  "attachments": [
    {
      "id": 11,
      "filename": "error_log.txt",
      "url": "https://your-domain.com/attachments/11/download"
    }
  ]
}

Error Responses:

    400 Bad Request: If parameters are invalid.
    401 Unauthorized: If the Session-ID is missing or invalid.
    404 Not Found: If the specified ticket does not exist.

5. Delete a Ticket

Endpoint: /tickets/{id}/delete
Method: DELETE
Description: Deletes a specific ticket from the system.

Parameters:

    id (required, integer): The unique ID of the ticket to delete.

Request Example:

DELETE /tickets/124/delete HTTP/1.1
Host: your-domain.com
Session-ID: your-session-token

Response Example:

{
  "message": "Ticket TCKT-000124 has been successfully deleted."
}

Error Responses:

    401 Unauthorized: If the Session-ID is missing or invalid.
    404 Not Found: If the specified ticket does not exist.
    403 Forbidden: If the user does not have permission to delete the ticket.

6. Get All Users

Endpoint: /users/all
Method: GET
Description: Retrieves a list of all users with pagination support.

Parameters:

    page (optional, integer): The page number to retrieve. Defaults to 1.
    limit (optional, integer): Number of users per page. Defaults to 20.

Request Example:

GET /users/all?page=1&limit=20 HTTP/1.1
Host: your-domain.com
Session-ID: your-session-token

Response Example:

{
  "users": [
    {
      "id": 45,
      "name": "John Doe",
      "email": "john.doe@example.com",
      "phone": "+1234567890",
      "organization": "Example Corp",
      "created": "2023-09-15T08:30:00Z",
      "updated": "2023-10-01T12:00:00Z"
    },
    // ... more users
  ],
  "pagination": {
    "current_page": 1,
    "per_page": 20,
    "total_pages": 5,
    "total_users": 100
  }
}

Error Responses:

    401 Unauthorized: If the Session-ID is missing or invalid.
    400 Bad Request: If pagination parameters are invalid.

7. Create a New User

Endpoint: /users/create
Method: POST
Description: Creates a new user in the system.

Parameters:

    name (required, string): Full name of the user.
    email (required, string): Email address of the user.
    phone (optional, string): Contact phone number.
    organization (optional, integer): ID of the organization the user belongs to.
    password (optional, string): Password for the user account.
    permissions (optional, array): Array of permissions to assign.

Request Example:

POST /users/create HTTP/1.1
Host: your-domain.com
Content-Type: application/json
Session-ID: your-session-token

{
  "name": "Jane Smith",
  "email": "jane.smith@example.com",
  "phone": "+0987654321",
  "organization": 3,
  "password": "SecureP@ssw0rd",
  "permissions": ["user.view", "user.edit"]
}

Response Example:

{
  "id": 46,
  "name": "Jane Smith",
  "email": "jane.smith@example.com",
  "phone": "+0987654321",
  "organization": "Example Corp",
  "created": "2023-10-05T14:45:00Z",
  "updated": "2023-10-05T14:45:00Z"
}

Error Responses:

    400 Bad Request: If required parameters are missing or invalid.
    401 Unauthorized: If the Session-ID is missing or invalid.
    409 Conflict: If the email address is already in use.

8. Update User Information

Endpoint: /users/{id}/update
Method: POST
Description: Updates the information of an existing user.

Parameters:

    id (required, integer): The unique ID of the user to update.
    name (optional, string): New full name.
    email (optional, string): New email address.
    phone (optional, string): New contact phone number.
    organization (optional, integer): New organization ID.
    password (optional, string): New password.
    permissions (optional, array): Updated array of permissions.

Request Example:

POST /users/46/update HTTP/1.1
Host: your-domain.com
Content-Type: application/json
Session-ID: your-session-token

{
  "phone": "+1122334455",
  "password": "NewSecureP@ssw0rd",
  "permissions": ["user.view", "user.edit", "user.delete"]
}

Response Example:

{
  "id": 46,
  "name": "Jane Smith",
  "email": "jane.smith@example.com",
  "phone": "+1122334455",
  "organization": "Example Corp",
  "created": "2023-10-05T14:45:00Z",
  "updated": "2023-10-06T09:20:00Z"
}

Error Responses:

    400 Bad Request: If parameters are invalid.
    401 Unauthorized: If the Session-ID is missing or invalid.
    404 Not Found: If the specified user does not exist.
    409 Conflict: If the new email address is already in use by another user.

9. Delete a User

Endpoint: /users/{id}/delete
Method: DELETE
Description: Deletes a user from the system.

Parameters:

    id (required, integer): The unique ID of the user to delete.

Request Example:

DELETE /users/46/delete HTTP/1.1
Host: your-domain.com
Session-ID: your-session-token

Response Example:

{
  "message": "User Jane Smith has been successfully deleted."
}

Error Responses:

    401 Unauthorized: If the Session-ID is missing or invalid.
    404 Not Found: If the specified user does not exist.
    403 Forbidden: If the user has active tickets or lacks permission to delete.

Common Error Structure

All error responses follow a standardized JSON structure to provide clarity.

Example:

{
  "error": {
    "code": 400,
    "message": "Invalid email address provided."
  }
}
However it misses a lot of stuff like assigning tickets, merge tickets, creating deparmenets and bunch of different stuff.
osticket/scp/ajax.php:
<?php
/*********************************************************************
    ajax.php

    Ajax utils interface.

    Peter Rotich <peter@osticket.com>
    Copyright (c)  2006-2013 osTicket
    http://www.osticket.com

    Released under the GNU General Public License WITHOUT ANY WARRANTY.
    See LICENSE.TXT for details.

    vim: expandtab sw=4 ts=4 sts=4:
**********************************************************************/
# Override staffLoginPage() defined in staff.inc.php to return an
# HTTP/Forbidden status rather than the actual login page.
# XXX: This should be moved to the AjaxController class
function staffLoginPage($msg='Unauthorized') {
    Http::response(403,'Must login: '.Format::htmlchars($msg));
    exit;
}

define('AJAX_REQUEST', 1);
require('staff.inc.php');

//Clean house...don't let the world see your crap.
ini_set('display_errors', '0'); // Set by installer
ini_set('display_startup_errors', '0'); // Set by installer

//TODO: disable direct access via the browser? i,e All request must have REFER?
if(!defined('INCLUDE_DIR'))	Http::response(500, 'Server configuration error');

require_once INCLUDE_DIR.'/class.dispatcher.php';
require_once INCLUDE_DIR.'/class.ajax.php';
$dispatcher = patterns('',
    url('^/kb/', patterns('ajax.kbase.php:KbaseAjaxAPI',
        # Send ticket-id as a query arg => canned-response/33?ticket=83
        url_get('^canned-response/(?P<id>\d+).(?P<format>json|txt)', 'cannedResp'),
        url('^faq/(?P<id>\d+)/access', 'manageFaqAccess'),
        url_get('^faq/(?P<id>\d+)$', 'faq')
    )),
    url('^/content/', patterns('ajax.content.php:ContentAjaxAPI',
        url_get('^log/(?P<id>\d+)', 'syslog'),
        url_get('^context$', 'context'),
        url_get('^ticket_variables', 'ticket_variables'),
        url_get('^signature/(?P<type>\w+)(?:/(?P<id>\d+))?$', 'getSignature'),
        url_get('^(?P<id>\d+)/(?:(?P<lang>\w+)/)?manage$', 'manageContent'),
        url_get('^(?P<id>[\w-]+)/(?:(?P<lang>\w+)/)?manage$', 'manageNamedContent'),
        url_post('^(?P<id>\d+)(?:/(?P<lang>\w+))?$', 'updateContent')
    )),
    url('^/config/', patterns('ajax.config.php:ConfigAjaxAPI',
        url_get('^scp', 'scp'),
        url_get('^links', 'templateLinks'),
        url_get('^date-format', 'dateFormat')
    )),
    url('^/form/', patterns('ajax.forms.php:DynamicFormsAjaxAPI',
        url_get('^help-topic/(?P<id>\d+)$', 'getFormsForHelpTopic'),
        url_get('^field-config/(?P<id>\d+)$', 'getFieldConfiguration'),
        url_post('^field-config/(?P<id>\d+)$', 'saveFieldConfiguration'),
        url_delete('^answer/(?P<entry>\d+)/(?P<field>\d+)$', 'deleteAnswer'),
        url_post('^upload/(\d+)?$', 'upload'),
        url_post('^upload/(\w+)?$', 'attach'),
        url_post('^upload/(?P<object>ticket|task)/(\w+)$', 'attach'),
        url_get('^(?P<id>\d+)/fields/view$', 'getAllFields')
    )),
    url('^/filter/', patterns('ajax.filter.php:FilterAjaxAPI',
        url_get('^action/(?P<type>\w+)/config$', 'getFilterActionForm')
    )),
    url('^/schedule/', patterns('ajax.schedule.php:ScheduleAjaxAPI',
       url('^add$', 'add'),
       url('^(?P<id>\d+)/clone$', 'cloneSchedule'),
       url('^(?P<id>\d+)/diagnostic$', 'diagnostic'),
       url_post('^(?P<id>\d+)/delete-entries$', 'deleteEntries'),
       url('^(?P<id>\d+)/entry/add$', 'addEntry'),
       url('^(?P<sid>\d+)/entry/(?P<eid>\d+)/update$', 'updateEntry')
    )),
    url('^/list/', patterns('ajax.forms.php:DynamicFormsAjaxAPI',
        url_get('^(?P<list>\w+)/items$', 'getListItems'),
        url_get('^(?P<list>\w+)/items/search$', 'searchListItems'),
        url_get('^(?P<list>\w+)/item/(?P<id>\d+)/update$', 'getListItem'),
        url_post('^(?P<list>\w+)/item/(?P<id>\d+)/update$', 'saveListItem'),
        url_get('^(?P<list>\w+)/items/(?P<id>\d+)/preview$', 'previewListItem'),
        url('^(?P<list>\w+)/item/add$', 'addListItem'),
        url('^(?P<list>\w+)/import$', 'importListItems'),
        url('^(?P<list>\w+)/manage$', 'massManageListItems'),
        url_post('^(?P<list>\w+)/delete$', 'deleteItems'),
        url_post('^(?P<list>\w+)/disable$', 'disableItems'),
        url_post('^(?P<list>\w+)/enable$', 'undisableItems')
    )),
    url('^/plugins/', patterns('ajax.plugins.php:PluginsAjaxAPI',
        url_get('^(?P<id>\d+)/instances$', 'getInstances'),
        url('^(?P<id>\d+)/instances/(?P<iid>\d+)/update$', 'updateInstance'),
        url('^(?P<id>\d+)/instances/add$', 'addInstance'),
        url_post('^(?P<id>\d+)/instances/(\w+)$', 'actions')
    )),
    url('^/report/overview/', patterns('ajax.reports.php:OverviewReportAjaxAPI',
        # Send
        url_get('^graph$', 'getPlotData'),
        url_get('^table/groups$', 'enumTabularGroups'),
        url_get('^table/export$', 'downloadTabularData'),
        url_get('^table$', 'getTabularData')
    )),
    url('^/users', patterns('ajax.users.php:UsersAjaxAPI',
        url_get('^$', 'search'),
        url_get('^/local$', 'search', array('local')),
        url_get('^/remote$', 'search', array('remote')),
        url_get('^/(?P<id>\d+)$', 'getUser'),
        url_post('^/(?P<id>\d+)$', 'updateUser'),
        url_get('^/(?P<id>\d+)/preview$', 'preview'),
        url_get('^/(?P<id>\d+)/edit$', 'editUser'),
        url('^/lookup$', 'getUser'),
        url_get('^/lookup/form$', 'lookup'),
        url_post('^/lookup/form$', 'addUser'),
        url_get('^/add$', 'addUser'),
        url('^/import$', 'importUsers'),
        url_get('^/select$', 'selectUser'),
        url_get('^/select/(?P<id>\d+)$', 'selectUser'),
        url_get('^/select/auth:(?P<bk>[\w.]+):(?P<id>.+)$', 'addRemoteUser'),
        url_get('^/(?P<id>\d+)/register$', 'register'),
        url_post('^/(?P<id>\d+)/register$', 'register'),
        url_get('^/(?P<id>\d+)/delete$', 'delete'),
        url_post('^/(?P<id>\d+)/delete$', 'delete'),
        url_get('^/(?P<id>\d+)/manage(?:/(?P<target>\w+))?$', 'manage'),
        url_post('^/(?P<id>\d+)/manage(?:/(?P<target>\w+))?$', 'manage'),
        url_get('^/(?P<id>\d+)/org(?:/(?P<orgid>\d+))?$', 'updateOrg'),
        url_post('^/(?P<id>\d+)/org$', 'updateOrg'),
        url_get('^/staff$', 'searchStaff'),
        url_post('^/(?P<id>\d+)/note$', 'createNote'),
        url_get('^/(?P<id>\d+)/forms/manage$', 'manageForms'),
        url_post('^/(?P<id>\d+)/forms/manage$', 'updateForms'),
        url('^/(?P<id>\d+)/tickets/export$', 'exportTickets')
    )),
    url('^/orgs', patterns('ajax.orgs.php:OrgsAjaxAPI',
        url_get('^$', 'search'),
        url_get('^/search$', 'search'),
        url_get('^/(?P<id>\d+)$', 'getOrg'),
        url_post('^/(?P<id>\d+)$', 'updateOrg'),
        url_post('^/(?P<id>\d+)/profile$', 'updateOrg', array(true)),
        url('^/(?P<id>\d+)/tickets/export$', 'exportTickets'),
        url_get('^/(?P<id>\d+)/edit$', 'editOrg'),
        url_get('^/lookup/form$', 'lookup'),
        url_post('^/lookup$', 'lookup'),
        url_get('^/add$', 'addOrg'),
        url_post('^/add$', 'addOrg'),
        url_get('^/select$', 'selectOrg'),
        url_get('^/select/(?P<id>\d+)$', 'selectOrg'),
        url_get('^/(?P<id>\d+)/add-user(?:/(?P<userid>\d+))?$', 'addUser'),
        url_get('^/(?P<id>\d+)/add-user(?:/auth:(?P<userid>.+))?$', 'addUser', array(true)),
        url_post('^/(?P<id>\d+)/add-user$', 'addUser'),
        url('^/(?P<id>\d+)/import-users$', 'importUsers'),
        url_get('^/(?P<id>\d+)/delete$', 'delete'),
        url_delete('^/(?P<id>\d+)/delete$', 'delete'),
        url_post('^/(?P<id>\d+)/note$', 'createNote'),
        url_get('^/(?P<id>\d+)/forms/manage$', 'manageForms'),
        url_post('^/(?P<id>\d+)/forms/manage$', 'updateForms')
    )),
    url('^/lock/', patterns('ajax.tickets.php:TicketsAjaxAPI',
        url_post('^ticket/(?P<tid>\d+)$', 'acquireLock'),
        url_post('^(?P<id>\d+)/ticket/(?P<tid>\d+)/renew', 'renewLock'),
        url_post('^(?P<id>\d+)/release', 'releaseLock')
    )),
    url('^/tickets/', patterns('ajax.tickets.php:TicketsAjaxAPI',
        url_get('^(?P<tid>\d+)/change-user$', 'changeUserForm'),
        url_post('^(?P<tid>\d+)/change-user$', 'changeUser'),
        url_get('^recent$', 'recent'),
        url_get('^(?P<tid>\d+)/user$', 'viewUser'),
        url_post('^(?P<tid>\d+)/user$', 'updateUser'),
        url_get('^(?P<tid>\d+)/preview', 'previewTicket'),
        url_get('^(?P<tid>\d+)/forms/manage$', 'manageForms'),
        url_get('^all$', 'allTickets'),
        url_post('^(?P<tid>\d+)/forms/manage$', 'updateForms'),
        url_get('^(?P<tid>\d+)/merge$', 'mergeTickets'),
        url_post('^(?P<tid>\d+)/merge$', 'updateMerge'),
        url_get('^(?P<tid>\d+)/link', 'mergeTickets'),
        url_post('^(?P<tid>\d+)/link', 'updateMerge'),
        url_get('^(?P<tid>\d+)/merge/preview$', 'previewMerge'),
        url_get('^(?P<tid>\d+)/relations', 'relations'),
        url_get('^(?P<tid>\d+)/canned-resp/(?P<cid>\w+).(?P<format>json|txt)', 'cannedResponse'),
        url_get('^(?P<tid>\d+)/status/(?P<status>\w+)(?:/(?P<sid>\d+))?$', 'changeTicketStatus'),
        url_post('^(?P<tid>\d+)/status$', 'setTicketStatus'),
        url('^(?P<tid>\d+)/thread/(?P<thread_id>\d+)/(?P<action>\w+)$', 'triggerThreadAction'),
        url_get('^status/(?P<status>\w+)(?:/(?P<sid>\d+))?$', 'changeSelectedTicketsStatus'),
        url_post('^status/(?P<state>\w+)$', 'setSelectedTicketsStatus'),
        url_get('^(?P<tid>\d+)/tasks$', 'tasks'),
        url('^(?P<tid>\d+)/add-task$', 'addTask'),
        url_get('^(?P<tid>\d+)/tasks/(?P<id>\d+)/view$', 'task'),
        url_post('^(?P<tid>\d+)/tasks/(?P<id>\d+)$', 'task'),
        url_get('^lookup', 'lookup'),
        url_get('^number-lookup', 'lookupByNumber'),
        url('^mass/(?P<action>\w+)(?:/(?P<what>\w+))?', 'massProcess'),
        url('^(?P<tid>\d+)/transfer$', 'transfer'),
        url('^(?P<tid>\d+)/field/(?P<fid>\d+)/edit$', 'editField'),
        url('^(?P<tid>\d+)/field/(?P<field>\w+)/edit$', 'editField'),
        url('^(?P<tid>\d+)/field/(?P<fid>\d+)/view$', 'viewField'),
        url('^(?P<tid>\d+)/field/(?P<field>\w+)/view$', 'viewField'),
        url('^(?P<tid>\d+)/assign(?:/(?P<to>\w+))?$', 'assign'),
        url('^(?P<tid>\d+)/release$', 'release'),
        url('^(?P<tid>\d+)/mark/(?P<action>\w+)$', 'markAs'),
        url('^(?P<tid>\d+)/refer(?:/(?P<to>\w+))?$', 'refer'),
        url('^(?P<tid>\d+)/referrals$', 'referrals'),
        url('^(?P<tid>\d+)/claim$', 'claim'),
        url('^export/(?P<id>\d+)$', 'export'),
        url('^export/adhoc,(?P<key>[\w=/+]+)$', 'export'),
        url('^search', patterns('ajax.search.php:SearchAjaxAPI',
            url_get('^$', 'getAdvancedSearchDialog'),
            url_post('^$', 'doSearch'),
            url_get('^/(?P<id>\d+)$', 'editSearch'),
            url_get('^/adhoc,(?P<key>[\w=/+]+)$', 'getAdvancedSearchDialog'),
            url_get('^/create$', 'createSearch'),
            url_post('^/(?P<id>\d+)/save$', 'saveSearch'),
            url_post('^/save$', 'saveSearch'),
            url_delete('^/(?P<id>\d+)$', 'deleteSearch'),
            url_get('^/field/(?P<id>[\w_!:]+)$', 'addField'),
            url('^/column/edit/(?P<id>\d+)$', 'editColumn'),
            url('^/sort/edit/(?P<id>\d+)$', 'editSort'),
            url_post('^(?P<id>\d+)/delete$', 'deleteQueues'),
            url_post('^(?P<id>\d+)/disable$', 'disableQueues'),
            url_post('^(?P<id>\d+)/enable$', 'undisableQueues')
        ))
    )),
    url('^/tasks/', patterns('ajax.tasks.php:TasksAjaxAPI',
        url_get('^(?P<tid>\d+)/preview$', 'preview'),
        url_get('^(?P<tid>\d+)/edit', 'edit'),
        url_post('^(?P<tid>\d+)/edit$', 'edit'),
        url('^(?P<tid>\d+)/field/(?P<fid>\d+)/edit$', 'editField'),
        url('^(?P<tid>\d+)/field/(?P<field>\w+)/edit$', 'editField'),
        url_get('^(?P<tid>\d+)/transfer', 'transfer'),
        url_post('^(?P<tid>\d+)/transfer$', 'transfer'),
        url('^(?P<tid>\d+)/assign(?:/(?P<to>\w+))?$', 'assign'),
        url('^(?P<tid>\d+)/claim$', 'claim'),
        url_get('^(?P<tid>\d+)/delete', 'delete'),
        url_post('^(?P<tid>\d+)/delete$', 'delete'),
        url('^(?P<tid>\d+)/close', 'close'),
        url('^(?P<tid>\d+)/reopen', 'reopen'),
        url_get('^(?P<tid>\d+)/view$', 'task'),
        url_post('^(?P<tid>\d+)$', 'task'),
        url('^(?P<tid>\d+)/thread/(?P<thread_id>\d+)/(?P<action>\w+)$', 'triggerThreadAction'),
        url('^add$', 'add'),
        url('^(?P<tid>\d+)/add', 'add'),
        url('^lookup', 'lookup'),
        url('^mass/(?P<action>\w+)(?:/(?P<what>\w+))?', 'massProcess')
    )),
    url('^/thread/', patterns('ajax.thread.php:ThreadAjaxAPI',
        url_get('^(?P<tid>\d+)/collaborators/(?P<manage>\d+)/preview$', 'previewCollaborators'),
        url_get('^(?P<tid>\d+)/collaborators/(?P<manage>\d+)$', 'showCollaborators'),
        url_post('^(?P<tid>\d+)/collaborators$', 'updateCollaborators'),
        url_get('^(?P<tid>\d+)/add-collaborator/(?P<type>\w+)/(?P<uid>\d+)$', 'addCollaborator'),
        url_get('^(?P<tid>\d+)/add-collaborator/(?P<type>\w+)/auth:(?P<bk>\w+):(?P<id>.+)$', 'addRemoteCollaborator'),
        url('^(?P<tid>\d+)/add-collaborator/(?P<type>\w+)$', 'addCollaborator'),
        url_get('^(?P<tid>\d+)/collaborators/(?P<cid>\d+)/view$', 'viewCollaborator'),
        url_post('^(?P<tid>\d+)/collaborators/(?P<cid>\d+)$', 'updateCollaborator')
    )),
    url('^/draft/', patterns('ajax.draft.php:DraftAjaxAPI',
        url_post('^(?P<id>\d+)$', 'updateDraft'),
        url_delete('^(?P<id>\d+)$', 'deleteDraft'),
        url_post('^(?P<id>\d+)/attach$', 'uploadInlineImage'),
        url_post('^(?P<namespace>[\w.]+)/attach$', 'uploadInlineImageEarly'),
        url_get('^(?P<namespace>[\w.]+)$', 'getDraft'),
        url_post('^(?P<namespace>[\w.]+)$', 'createDraft'),
        url_get('^images/browse$', 'getFileList')
    )),
    url('^/export/', patterns('ajax.export.php:ExportAjaxAPI',
        url('^(?P<id>\w+)/check$', 'check')
    )),
    url('^/note/', patterns('ajax.note.php:NoteAjaxAPI',
        url_get('^(?P<id>\d+)$', 'getNote'),
        url_post('^(?P<id>\d+)$', 'updateNote'),
        url_delete('^(?P<id>\d+)$', 'deleteNote'),
        url_post('^attach/(?P<ext_id>\w\d+)$', 'createNote')
    )),
    url('^/sequence/', patterns('ajax.sequence.php:SequenceAjaxAPI',
        url_get('^(?P<id>\d+)$', 'current'),
        url_get('^manage$', 'manage'),
        url_post('^manage$', 'manage')
    )),
    url_post('^/upgrader', array('ajax.upgrader.php:UpgraderAjaxAPI', 'upgrade')),
    url('^/help/', patterns('ajax.tips.php:HelpTipAjaxAPI',
        url_get('^tips/(?P<namespace>[\w_.]+)$', 'getTipsJson'),
        url_get('^(?P<lang>[\w_]+)?/tips/(?P<namespace>[\w_.]+)$', 'getTipsJsonForLang')
    )),
    url('^/i18n/', patterns('ajax.i18n.php:i18nAjaxAPI',
        url_get('^langs/all$', 'getConfiguredLanguages'),
        url_get('^langs$', 'getSecondaryLanguages'),
        url_get('^translate/(?P<tag>\w+)$', 'getTranslations'),
        url_post('^translate/(?P<tag>\w+)$', 'updateTranslations'),
        url_get('^(?P<lang>[\w_]+)/(?P<tag>\w+)$', 'getLanguageFile')
    )),
    url('^/admin', patterns('ajax.admin.php:AdminAjaxAPI',
        url('^/quick-add', patterns('ajax.admin.php:AdminAjaxAPI',
            url('^/department$', 'addDepartment'),
            url('^/team$', 'addTeam'),
            url('^/role$', 'addRole'),
            url('^/staff$', 'addStaff'),
            url('^/queue-column$', 'addQueueColumn'),
            url('^/queue-sort$', 'addQueueSort')
        )),
        url_get('^/role/(?P<id>\d+)/perms', 'getRolePerms')
    )),
    url('^/staff', patterns('ajax.staff.php:StaffAjaxAPI',
        url('^/(?P<id>\d+)/set-password$', 'setPassword'),
        url('^/(?P<id>\d+)/change-password$', 'changePassword'),
        url_get('^/(?P<id>\d+)/perms', 'getAgentPerms'),
        url('^/reset-permissions', 'resetPermissions'),
        url('^/change-department', 'changeDepartment'),
        url('^/(?P<id>\d+)/avatar/change', 'setAvatar'),
        url('^/(?P<id>\d+)/2fa/configure(?:/(?P<mfid>.+))?$', 'configure2FA'),
        url('^/(?P<id>\d+)/reset-2fa', 'reset2fA')
    )),
    url('^/queue/', patterns('ajax.search.php:SearchAjaxAPI',
        url('^(?P<id>\d+/)?preview$', 'previewQueue'),
        url_get('^(?P<id>\d+)$', 'getQueue'),
        url_get('^addColumn$', 'addColumn'),
        url_get('^condition/add$', 'addCondition'),
        url_get('^condition/addProperty$', 'addConditionProperty'),
        url_get('^counts$', 'collectQueueCounts'),
        url('^(?P<id>\d+)/delete$', 'deleteQueue')
    )),
    url('^/email', patterns('ajax.email.php:EmailAjaxAPI',
        url_post('^/(?P<id>\d+)/stash$', 'stashFormData'),
        url_post('^/(?P<id>\d+)/auth/config/(?P<type>\w+)/delete$', 'deleteToken'),
        url('^/(?P<id>\d+)/auth/config/(?P<type>\w+)/(?P<auth>.+)$', 'configureAuth'),
    ))
);

Signal::send('ajax.scp', $dispatcher);

# Call the respective function
$rv = $dispatcher->resolve(Osticket::get_path_info());

// Indicate JSON response content-type
if (is_string($rv) && $rv[0] == '{')
    Http::response(200, $rv, 'application/json');

print $rv;
?>

/include/class.ticket.php:
<?php
/*********************************************************************
    class.ticket.php

    The most important class! Don't play with fire please.

    Peter Rotich <peter@osticket.com>
    Copyright (c)  2006-2013 osTicket
    http://www.osticket.com

    Released under the GNU General Public License WITHOUT ANY WARRANTY.
    See LICENSE.TXT for details.

    vim: expandtab sw=4 ts=4 sts=4:
**********************************************************************/
include_once(INCLUDE_DIR.'class.thread.php');
include_once(INCLUDE_DIR.'class.staff.php');
include_once(INCLUDE_DIR.'class.client.php');
include_once(INCLUDE_DIR.'class.team.php');
include_once(INCLUDE_DIR.'class.email.php');
include_once(INCLUDE_DIR.'class.dept.php');
include_once(INCLUDE_DIR.'class.topic.php');
include_once(INCLUDE_DIR.'class.lock.php');
include_once(INCLUDE_DIR.'class.file.php');
include_once(INCLUDE_DIR.'class.export.php');
include_once(INCLUDE_DIR.'class.attachment.php');
include_once(INCLUDE_DIR.'class.banlist.php');
include_once(INCLUDE_DIR.'class.template.php');
include_once(INCLUDE_DIR.'class.variable.php');
include_once(INCLUDE_DIR.'class.priority.php');
include_once(INCLUDE_DIR.'class.sla.php');
include_once(INCLUDE_DIR.'class.canned.php');
require_once(INCLUDE_DIR.'class.dynamic_forms.php');
require_once(INCLUDE_DIR.'class.user.php');
require_once(INCLUDE_DIR.'class.collaborator.php');
require_once(INCLUDE_DIR.'class.task.php');
require_once(INCLUDE_DIR.'class.faq.php');

class Ticket extends VerySimpleModel
implements RestrictedAccess, Threadable, Searchable {
    static $meta = array(
        'table' => TICKET_TABLE,
        'pk' => array('ticket_id'),
        'select_related' => array('topic', 'staff', 'user', 'team', 'dept',
            'sla', 'thread', 'child_thread', 'user__default_email', 'status'),
        'joins' => array(
            'user' => array(
                'constraint' => array('user_id' => 'User.id'),
                'null' => true,
            ),
            'status' => array(
                'constraint' => array('status_id' => 'TicketStatus.id')
            ),
            'lock' => array(
                'constraint' => array('lock_id' => 'Lock.lock_id'),
                'null' => true,
            ),
            'dept' => array(
                'constraint' => array('dept_id' => 'Dept.id'),
                'null' => true,
            ),
            'sla' => array(
                'constraint' => array('sla_id' => 'Sla.id'),
                'null' => true,
            ),
            'staff' => array(
                'constraint' => array('staff_id' => 'Staff.staff_id'),
                'null' => true,
            ),
            'tasks' => array(
                'reverse' => 'Task.ticket',
            ),
            'team' => array(
                'constraint' => array('team_id' => 'Team.team_id'),
                'null' => true,
            ),
            'topic' => array(
                'constraint' => array('topic_id' => 'Topic.topic_id'),
                'null' => true,
            ),
            'thread' => array(
                'reverse' => 'TicketThread.ticket',
                'list' => false,
                'null' => true,
            ),
            'child_thread' => array(
                'constraint' => array(
                    'ticket_id'  => 'TicketThread.object_id',
                    "'C'" => 'TicketThread.object_type',
                ),
                'searchable' => false,
                'null' => true,
            ),
            'cdata' => array(
                'reverse' => 'TicketCData.ticket',
                'list' => false,
            ),
            'entries' => array(
                'constraint' => array(
                    "'T'" => 'DynamicFormEntry.object_type',
                    'ticket_id' => 'DynamicFormEntry.object_id',
                ),
                'list' => true,
            ),
        )
    );

    const PERM_CREATE   = 'ticket.create';
    const PERM_EDIT     = 'ticket.edit';
    const PERM_ASSIGN   = 'ticket.assign';
    const PERM_RELEASE  = 'ticket.release';
    const PERM_TRANSFER = 'ticket.transfer';
    const PERM_REFER    = 'ticket.refer';
    const PERM_MERGE    = 'ticket.merge';
    const PERM_LINK     = 'ticket.link';
    const PERM_REPLY    = 'ticket.reply';
    const PERM_MARKANSWERED = 'ticket.markanswered';
    const PERM_CLOSE    = 'ticket.close';
    const PERM_DELETE   = 'ticket.delete';

    const FLAG_COMBINE_THREADS     = 0x0001;
    const FLAG_SEPARATE_THREADS    = 0x0002;
    const FLAG_LINKED              = 0x0008;
    const FLAG_PARENT              = 0x0010;

    static protected $perms = array(
            self::PERM_CREATE => array(
                'title' =>
                /* @trans */ 'Create',
                'desc'  =>
                /* @trans */ 'Ability to open tickets on behalf of users'),
            self::PERM_EDIT => array(
                'title' =>
                /* @trans */ 'Edit',
                'desc'  =>
                /* @trans */ 'Ability to edit tickets'),
            self::PERM_ASSIGN => array(
                'title' =>
                /* @trans */ 'Assign',
                'desc'  =>
                /* @trans */ 'Ability to assign tickets to agents or teams'),
            self::PERM_RELEASE => array(
                'title' =>
                /* @trans */ 'Release',
                'desc'  =>
                /* @trans */ 'Ability to release ticket assignment'),
            self::PERM_TRANSFER => array(
                'title' =>
                /* @trans */ 'Transfer',
                'desc'  =>
                /* @trans */ 'Ability to transfer tickets between departments'),
            self::PERM_REFER => array(
                'title' =>
                /* @trans */ 'Refer',
                'desc'  =>
                /* @trans */ 'Ability to manage ticket referrals'),
            self::PERM_MERGE => array(
                'title' =>
                /* @trans */ 'Merge',
                'desc'  =>
                /* @trans */ 'Ability to merge tickets'),
            self::PERM_LINK => array(
                'title' =>
                /* @trans */ 'Link',
                'desc'  =>
                /* @trans */ 'Ability to link tickets'),
            self::PERM_REPLY => array(
                'title' =>
                /* @trans */ 'Post Reply',
                'desc'  =>
                /* @trans */ 'Ability to post a ticket reply'),
            self::PERM_MARKANSWERED => array(
                'title' =>
                /* @trans */ 'Mark as Answered',
                'desc'  =>
                /* @trans */ 'Ability to mark a ticket as Answered/Unanswered'),
            self::PERM_CLOSE => array(
                'title' =>
                /* @trans */ 'Close',
                'desc'  =>
                /* @trans */ 'Ability to close tickets'),
            self::PERM_DELETE => array(
                'title' =>
                /* @trans */ 'Delete',
                'desc'  =>
                /* @trans */ 'Ability to delete tickets'),
            );

    // Ticket Sources
    static protected $sources =  array(
            'Phone' =>
            /* @trans */ 'Phone',
            'Email' =>
            /* @trans */ 'Email',

            'Web' =>
            /* @trans */ 'Web',
            'API' =>
            /* @trans */ 'API',
            'Other' =>
            /* @trans */ 'Other',
            );

    var $lastMsgId;
    var $last_message;

    var $owner;     // TicketOwner
    var $_user;      // EndUser
    var $_answers;
    var $collaborators;
    var $active_collaborators;
    var $recipients;
    var $lastrespondent;
    var $lastuserrespondent;
    var $_children;

    function loadDynamicData($force=false) {
        if (!isset($this->_answers) || $force) {
            $this->_answers = array();
            foreach (DynamicFormEntryAnswer::objects()
                ->filter(array(
                    'entry__object_id' => $this->getId(),
                    'entry__object_type' => 'T'
                )) as $answer
            ) {
                $tag = mb_strtolower($answer->field->name)
                    ?: 'field.' . $answer->field->id;
                    $this->_answers[$tag] = $answer;
            }
        }
        return $this->_answers;
    }

    function getAnswer($field, $form=null) {
        // TODO: Prefer CDATA ORM relationship if already loaded
        $this->loadDynamicData();
        return $this->_answers[$field];
    }

    function getId() {
        return $this->ticket_id;
    }

    function getPid() {
        return $this->ticket_pid;
    }

    function getChildren() {
        if (!isset($this->_children) && $this->isParent())
            $this->_children = self::getChildTickets($this->getId());

        return $this->_children ?: array();
    }

    static function getMergeTypeByFlag($flag) {
        if (($flag & self::FLAG_COMBINE_THREADS) != 0)
            return 'combine';
        if (($flag & self::FLAG_SEPARATE_THREADS) != 0)
            return 'separate';
        else
            return 'visual';
        return 'visual';
    }

    function getMergeType() {
        if ($this->hasFlag(self::FLAG_COMBINE_THREADS))
            return 'combine';
        if ($this->hasFlag(self::FLAG_SEPARATE_THREADS))
            return 'separate';
        else
            return 'visual';
        return 'visual';
    }

    function isMerged() {
        if (!is_null($this->getPid()) || $this->isParent())
            return true;

        return false;
    }

    function isParent() {
        if ($this->hasFlag(self::FLAG_PARENT))
            return true;

        return false;
    }

    static function isParentStatic($flag=false) {
        if (is_numeric($flag) && ($flag & self::FLAG_PARENT) != 0)
            return true;

        return false;
    }

    function hasFlag($flag) {
        return ($this->get('flags', 0) & $flag) != 0;
    }

    function isChild($pid=false) {
        return ($this->getPid() ? true : false);
    }

    function hasState($state) {
        return  strcasecmp($this->getState(), $state) == 0;
    }

    function isOpen() {
        return $this->hasState('open');
    }

    function isReopened() {
        return null !== $this->getReopenDate();
    }

    function isReopenable() {
        return ($this->getStatus()->isReopenable() && $this->getDept()->allowsReopen()
        && ($this->getTopic() ? $this->getTopic()->allowsReopen() : true));
    }

    function isClosed() {
         return $this->hasState('closed');
    }

    function isCloseable() {
        global $cfg;

        if ($this->isClosed())
            return true;

        $warning = null;
        if (self::getMissingRequiredFields($this)) {
            $warning = sprintf(
                    __( '%1$s is missing data on %2$s one or more required fields %3$s and cannot be closed'),
                    __('This ticket'),
                    '', '');
        } elseif (($num=$this->getNumOpenTasks())) {
            $warning = sprintf(__('%1$s has %2$d open tasks and cannot be closed'),
                    __('This ticket'), $num);
        } elseif ($cfg->requireTopicToClose() && !$this->getTopicId()) {
            $warning = sprintf(
                    __( '%1$s is missing a %2$s and cannot be closed'),
                    __('This ticket'), __('Help Topic'), '');
        }

        return $warning ?: true;
    }

    function isArchived() {
         return $this->hasState('archived');
    }

    function isDeleted() {
         return $this->hasState('deleted');
    }

    function isAssigned($to=null) {
        if (!$this->isOpen())
            return false;

        if (is_null($to))
            return ($this->getStaffId() || $this->getTeamId());

        switch (true) {
        case $to instanceof Staff:
            return ($to->getId() == $this->getStaffId() ||
                    $to->isTeamMember($this->getTeamId()));
            break;
        case $to instanceof Team:
            return ($to->getId() == $this->getTeamId());
            break;
        }

        return false;
    }

    function isOverdue() {
        return $this->ht['isoverdue'];
    }

    function isAnswered() {
       return $this->ht['isanswered'];
    }

    function isLocked() {
        return null !== $this->getLock();
    }

    function getRole($staff) {
        if (!$staff instanceof Staff)
            return null;

        return $staff->getRole($this->getDept(), $this->isAssigned($staff));
    }

    function checkStaffPerm($staff, $perm=null) {

        // Must be a valid staff
        if ((!$staff instanceof Staff) && !($staff=Staff::lookup($staff)))
            return false;

        // check department access first
        if (!$staff->canAccessDept($this->getDept())
                // check assignment
                && !$this->isAssigned($staff)
                // check referral
                && !$this->getThread()->isReferred($staff))
            return false;

        // At this point staff has view access unless a specific permission is
        // requested
        if ($perm === null)
            return true;

        // Permission check requested -- get role if any
        if (!($role=$this->getRole($staff)))
            return false;

        // Check permission based on the effective role
        return $role->hasPerm($perm);
    }

    function checkUserAccess($user) {
        if (!$user || !($user instanceof EndUser))
            return false;

        // Ticket Owner
        if ($user->getId() == $this->getUserId())
            return true;

        // Organization
        if ($user->canSeeOrgTickets()
            && ($U = $this->getUser())
            && ($U->getOrgId() == $user->getOrgId())
        ) {
            // The owner of this ticket is in the same organization as the
            // user in question, and the organization is configured to allow
            // the user in question to see other tickets in the
            // organization.
            return true;
        }

        // Collaborator?
        // 1) If the user was authorized via this ticket.
        if ($user->getTicketId() == $this->getId()
            && !strcasecmp($user->getUserType(), 'collaborator')
        ) {
            return true;
        }
        // 2) Query the database to check for expanded access...
        if (Collaborator::lookup(array(
            'user_id' => $user->getId(),
            'thread_id' => $this->getThreadId()))
        ) {
            return true;
        }
        // 3) If the ticket is a child of a merge
        if ($this->isParent() && $this->getMergeType() != 'visual') {
            $children = Ticket::objects()
                    ->filter(array('ticket_pid'=>$this->getId()))
                    ->order_by('sort');

            foreach ($children as $child)
                if ($child->checkUserAccess($user))
                    return true;
        }

        return false;
    }

    // Getters
    function getNumber() {
        return $this->number;
    }

    function getOwnerId() {
        return $this->user_id;
    }

    function getOwner() {
        if (!isset($this->owner)) {
            $this->owner = new TicketOwner(new EndUser($this->user), $this);
        }
        return $this->owner;
    }

    function getEmail() {
        if ($o = $this->getOwner()) {
            return $o->getEmail();
        }
        return null;
    }

    function getReplyToEmail() {
        //TODO: Determine the email to use (once we enable multi-email support)
        return $this->getEmail();
    }

    // Deprecated
    function getOldAuthToken() {
        # XXX: Support variable email address (for CCs)
        return md5($this->getId() . strtolower($this->getEmail()) . SECRET_SALT);
    }

    function getName(){
        if ($o = $this->getOwner()) {
            return $o->getName();
        }
        return null;
    }

    function getSubject() {
        return (string) $this->getAnswer('subject');
    }

    /* Help topic title  - NOT object -> $topic */
    function getHelpTopic() {
        if ($this->topic)
            return $this->topic->getFullName();
    }

    function getCreateDate() {
        return $this->created;
    }

    function getOpenDate() {
        return $this->getCreateDate();
    }

    function getReopenDate() {
        return $this->reopened;
    }

    function getUpdateDate() {
        return $this->updated;
    }

    function getEffectiveDate() {
        return $this->lastupdate;
    }

    function getDueDate() {
        return $this->duedate;
    }

    function getSLADueDate($recompute=false) {
        global $cfg;

        if (!$recompute && $this->est_duedate)
            return $this->est_duedate;

        if (($sla = $this->getSLA()) && $sla->isActive()) {
            $schedule = $this->getDept()->getSchedule();
            $tz = new DateTimeZone($cfg->getDbTimezone());
            $dt = new DateTime($this->getReopenDate() ?:
                    $this->getCreateDate(), $tz);
            $dt = $sla->addGracePeriod($dt, $schedule);
            // Make sure time is in DB timezone
            $dt->setTimezone($tz);
            return $dt->format('Y-m-d H:i:s');
        }
    }

    function updateEstDueDate($clearOverdue=true) {
        if ($this->isOverdue() && $clearOverdue)
            $this->clearOverdue(false);

        $this->est_duedate = $this->getSLADueDate(true) ?: null;

        return $this->save();
    }

    function getEstDueDate() {
        // Real due date or  sla due date (If ANY)
        return $this->getDueDate() ?: $this->getSLADueDate();
    }


    function getCloseDate() {
        return $this->closed;
    }

    function getStatusId() {
        return $this->status_id;
    }

    /**
     * setStatusId
     *
     * Forceably set the ticket status ID to the received status ID. No
     * checks are made. Use ::setStatus() to change the ticket status
     */
    // XXX: Use ::setStatus to change the status. This can be used as a
    //      fallback if the logic in ::setStatus fails.
    function setStatusId($id) {
        $this->status_id = $id;
        return $this->save();
    }

    function getStatus() {
        return $this->status;
    }

    function getState() {
        if (!$this->getStatus()) {
            return '';
        }
        return $this->getStatus()->getState();
    }

    function getDeptId() {
       return $this->dept_id;
    }

    function getDeptName() {
        if ($this->dept instanceof Dept)
            return $this->dept->getFullName();
    }

    function getPriorityId() {
        global $cfg;

        if (($priority = $this->getPriority()))
            return $priority->getId();

        return $cfg->getDefaultPriorityId();
    }

    function getPriority() {
        if (($a = $this->getAnswer('priority')))
            return $a->getValue();

        return null;
    }

    function getPriorityField() {
        if (($a = $this->getAnswer('priority')))
            return $a->getField();

        return TicketForm::getInstance()->getField('priority');
    }

    function getPhoneNumber() {
        return (string)$this->getOwner()->getPhoneNumber();
    }

    function getSource() {
        $sources = $this->getSources();
        return $sources[$this->source] ?: $this->source;
    }

    function getIP() {
        return $this->ip_address;
    }

    function getHashtable() {
        return $this->ht;
    }

    function getUpdateInfo() {
        global $cfg;

        return array(
            'source'    => $this->source,
            'topicId'   => $this->getTopicId(),
            'slaId'     => $this->getSLAId(),
            'user_id'   => $this->getOwnerId(),
            'duedate'   => Misc::db2gmtime($this->getDueDate()),
        );
    }

    function getLock() {
        $lock = $this->lock;
        if ($lock && !$lock->isExpired())
            return $lock;
    }

    function acquireLock($staffId, $lockTime=null) {
        global $cfg;

        if (!isset($lockTime))
            $lockTime = $cfg->getLockTime();

        if (!$staffId or !$lockTime) //Lockig disabled?
            return null;

        // Check if the ticket is already locked.
        if (($lock = $this->getLock()) && !$lock->isExpired()) {
            if ($lock->getStaffId() != $staffId) //someone else locked the ticket.
                return null;

            //Lock already exits...renew it
            $lock->renew($lockTime); //New clock baby.

            return $lock;
        }
        // No lock on the ticket or it is expired
        $this->lock = Lock::acquire($staffId, $lockTime); //Create a new lock..

        if ($this->lock) {
            $this->save();
        }

        // load and return the newly created lock if any!
        return $this->lock;
    }

    function releaseLock($staffId=false) {
        if (!($lock = $this->getLock()))
            return false;

        if ($staffId && $lock->staff_id != $staffId)
            return false;

        if (!$lock->delete())
            return false;

        $this->lock = null;
        return $this->save();
    }

    function getDept() {
        global $cfg;

        return $this->dept ?: $cfg->getDefaultDept();
    }

    function getUserId() {
        return $this->getOwnerId();
    }

    function getUser() {
        if (!isset($this->_user) && $this->user) {
            $this->_user = new EndUser($this->user);
        }
        return $this->_user;
    }

    function getStaffId() {
        return $this->staff_id;
    }

    function getStaff() {
        return $this->staff;
    }

    function getTeamId() {
        return $this->team_id;
    }

    function getTeam() {
        return $this->team;
    }

    function getAssigneeId() {

        if (!($assignee=$this->getAssignee()))
            return null;

        $id = '';
        if ($assignee instanceof Staff)
            $id = 's'.$assignee->getId();
        elseif ($assignee instanceof Team)
            $id = 't'.$assignee->getId();

        return $id;
    }

    function getAssignee() {

        if (!$this->isOpen() || !$this->isAssigned())
            return false;

        if ($this->staff)
            return $this->staff;

        if ($this->team)
            return $this->team;

        return null;
    }

    function getAssignees() {

        $assignees = array();
        if ($staff = $this->getStaff())
            $assignees[] = $staff->getName();

        if ($team = $this->getTeam())
            $assignees[] = $team->getName();

        return $assignees;
    }

    function getAssigned($glue='/') {
        $assignees = $this->getAssignees();
        return $assignees ? implode($glue, $assignees) : '';
    }

    function getTopicId() {
        return $this->topic_id;
    }

    function getTopic() {
        return $this->topic;
    }


    function getSLAId() {
        return $this->sla_id;
    }

    function getSLA() {
        return $this->sla;
    }

    function getLastRespondent() {
        if (!isset($this->lastrespondent)) {
            if (!$this->getThread() || !$this->getThread()->entries)
                return $this->lastrespondent = false;
            $this->lastrespondent = Staff::objects()
                ->filter(array(
                'staff_id' => $this->getThread()->entries
                    ->filter(array(
                        'type' => 'R',
                        'staff_id__gt' => 0,
                    ))
                    ->values_flat('staff_id')
                    ->order_by('-id')
                    ->limit('1,1')
                ))
                ->first()
                ?: false;
        }
        return $this->lastrespondent;
    }

    function getLastUserRespondent() {
        if (!isset($this->$lastuserrespondent)) {
            if (!$this->getThread() || !$this->getThread()->entries)
                return $this->$lastuserrespondent = false;
            $this->$lastuserrespondent = User::objects()
                ->filter(array(
                'id' => $this->getThread()->entries
                    ->filter(array(
                        'user_id__gt' => 0,
                    ))
                    ->values_flat('user_id')
                    ->order_by('-id')
                    ->limit(1)
                ))
                ->first()
                ?: false;
        }
        return $this->$lastuserrespondent;
    }

    function getLastMessageDate() {
        return $this->getThread()->lastmessage;
    }

    function getLastMsgDate() {
        return $this->getLastMessageDate();
    }

    function getLastResponseDate() {
        return $this->getThread()->lastresponse;
    }

    function getLastRespDate() {
        return $this->getLastResponseDate();
    }

    function getLastMsgId() {
        return $this->lastMsgId;
    }

    function getLastMessage() {
        if (!isset($this->last_message)) {
            if ($this->getLastMsgId())
                $this->last_message = MessageThreadEntry::lookup(
                    $this->getLastMsgId(), $this->getThreadId());

            if (!$this->last_message)
                $this->last_message = $this->getThread() ? $this->getThread()->getLastMessage() : '';
        }
        return $this->last_message;
    }

    function getNumTasks() {
        // FIXME: Implement this after merging Tasks
        return count($this->tasks);
    }

    function getNumOpenTasks() {
        return count($this->tasks->filter(array(
                        'flags__hasbit' => TaskModel::ISOPEN)));
    }


    function getThreadId() {
        if ($this->getThread())
            return $this->getThread()->getId();
    }

    function getThread() {
        if (is_null($this->thread) && $this->child_thread)
            return $this->child_thread;

        return $this->thread;
    }

    function getThreadCount() {
        return $this->getClientThread()->count();
    }

    function getNumMessages() {
        return $this->getThread()->getNumMessages();
    }

    function getNumResponses() {
        return $this->getThread()->getNumResponses();
    }

    function getNumNotes() {
        return $this->getThread()->getNumNotes();
    }

    function getMessages() {
        return $this->getThreadEntries(array('M'));
    }

    function getResponses() {
        return $this->getThreadEntries(array('R'));
    }

    function getNotes() {
        return $this->getThreadEntries(array('N'));
    }

    function getClientThread() {
        return $this->getThreadEntries(array('M', 'R'));
    }

    function getThreadEntry($id) {
        return $this->getThread()->getEntry($id);
    }

    function getThreadEntries($type=false) {
        if ($this->getThread()) {
            $entries = $this->getThread()->getEntries();
            if ($type && is_array($type))
                $entries->filter(array('type__in' => $type));
        }

        return $entries;
    }

    // MailingList of participants  (owner + collaborators)
    function getRecipients($who='all', $whitelist=array(), $active=true) {
        $list = new MailingList();
        switch (strtolower($who)) {
            case 'user':
                $list->addTo($this->getOwner());
                break;
            case 'all':
                $list->addTo($this->getOwner());
                // Fall-trough
            case 'collabs':
                if (($collabs = $active ?  $this->getActiveCollaborators() :
                    $this->getCollaborators())) {
                    foreach ($collabs as $c)
                        if (!$whitelist || in_array($c->getUserId(),
                                    $whitelist))
                            $list->addCc($c);
                }
                break;
            default:
                return null;
        }
        return $list;
    }

    function getCollaborators() {
        return $this->getThread() ? $this->getThread()->getCollaborators() : '';
    }

    function getNumCollaborators() {
        return $this->getThread() ? $this->getThread()->getNumCollaborators() : '';
    }

    function getActiveCollaborators() {
        return $this->getThread() ? $this->getThread()->getActiveCollaborators() : '';
    }

    function getNumActiveCollaborators() {
        return $this->getThread() ? $this->getThread()->getNumActiveCollaborators() : '';
    }

    function getAssignmentForm($source=null, $options=array()) {
        global $thisstaff;

        $prompt = $assignee = '';
        // Possible assignees
        $dept = $this->getDept();
        switch (strtolower($options['target'])) {
            case 'agents':
                if (!$source && $this->isOpen() && $this->staff)
                    $assignee = sprintf('s%d', $this->staff->getId());
                $prompt = __('Select an Agent');
                break;
            case 'teams':
                if (!$source && $this->isOpen() && $this->team)
                    $assignee = sprintf('t%d', $this->team->getId());
                $prompt = __('Select a Team');
                break;
        }

        // Default to current assignee if source is not set
        if (!$source)
            $source = array('assignee' => array($assignee));

        $form = AssignmentForm::instantiate($source, $options);

        if (($refer = $form->getField('refer'))) {
            if (!$assignee) {
                $visibility = new VisibilityConstraint(
                        new Q(array()), VisibilityConstraint::HIDDEN);
                $refer->set('visibility', $visibility);
            } else {
                $refer->configure('desc', sprintf(__('Maintain referral access to %s'),
                        $this->getAssigned()));
            }
        }

        // Field configurations
        if ($f=$form->getField('assignee')) {
            $f->configure('dept', $dept);
            $f->configure('staff', $thisstaff);
            if ($prompt)
                $f->configure('prompt', $prompt);
            if ($options['target'])
                $f->configure('target', $options['target']);
        }

        return $form;
    }

    function getReferralForm($source=null, $options=array()) {
        global $thisstaff;

        $form = ReferralForm::instantiate($source, $options);
        $dept = $this->getDept();
        // Agents
        $staff = Staff::objects()->filter(array(
         'isactive' => 1,
         ))
         ->filter(Q::not(array('dept_id' => $dept->getId())));

        $staff = Staff::nsort($staff);
        $agents = array();
        foreach ($staff as $s)
          $agents[$s->getId()] = $s;
        $form->setChoices('agent', $agents);
        // Teams
        $form->setChoices('team', Team::getActiveTeams());
        // Depts
        $form->setChoices('dept', Dept::getActiveDepartments());

        // Field configurations
        if ($f=$form->getField('agent')) {
            $f->configure('dept', $dept);
            $f->configure('staff', $thisstaff);
        }

        if ($f = $form->getField('dept'))
            $f->configure('hideDisabled', true);

        return $form;
    }

    function getClaimForm($source=null, $options=array()) {
        global $thisstaff;

        $id = sprintf('s%d', $thisstaff->getId());
        if(!$source)
            $source = array('assignee' => array($id));

        $form = ClaimForm::instantiate($source, $options);
        $form->setAssignees(array($id => $thisstaff->getName()));

        return $form;

    }

    function getTransferForm($source=null) {
        global $thisstaff;

        if (!$source)
            $source = array('dept' => array($this->getDeptId()),
                    'refer' => false);

        $form = TransferForm::instantiate($source);

        $form->hideDisabled();

        return $form;
    }

    function getField($fid) {

        if (is_numeric($fid))
            return $this->getDynamicFieldById($fid);

        // Special fields
        switch ($fid) {
        case 'priority':
            return $this->getPriorityField();
            break;
        case 'sla':
            return SLAField::init(array(
                        'id' => $fid,
                        'name' => "{$fid}_id",
                        'label' => __('SLA Plan'),
                        'default' => $this->getSLAId(),
                        'choices' => SLA::getSLAs()
                        ));
            break;
        case 'topic':
            $current = array();
            if ($topic = $this->getTopic())
                $current = array($topic->getId());
            $choices = Topic::getHelpTopics(false, $topic ? (Topic::DISPLAY_DISABLED) : false, true, $current);
            return TopicField::init(array(
                        'id' => $fid,
                        'name' => "{$fid}_id",
                        'label' => __('Help Topic'),
                        'default' => $this->getTopicId(),
                        'choices' => $choices
                        ));
            break;
        case 'source':
            return ChoiceField::init(array(
                        'id' => $fid,
                        'name' => 'source',
                        'label' => __('Ticket Source'),
                        'default' => $this->source,
                        'choices' => Ticket::getSources()
                        ));
            break;
        case 'duedate':

            $hint = sprintf(__('Setting a %s will override %s'),
                    __('Due Date'), __('SLA Plan'));
            return DateTimeField::init(array(
                'id' => $fid,
                'name' => $fid,
                'default' => Misc::db2gmtime($this->getDueDate()),
                'label' => __('Due Date'),
                'hint' => $hint,
                'configuration' => array(
                    'min' => Misc::gmtime(),
                    'time' => true,
                    'gmt' => false,
                    'future' => true,
                    )
                ));
        }
    }

    function getDynamicFieldById($fid) {
        foreach (DynamicFormEntry::forTicket($this->getId()) as $form) {
            foreach ($form->getFields() as $field)
                if ($field->getId() == $fid) {
                    // This is to prevent SimpleForm using index name as
                    // field name when one is not set.
                    if (!$field->get('name'))
                        $field->set('name', "field_$fid");

                    return $field;
                }
        }
    }

    function getDynamicFields($criteria=array()) {

        $fields = DynamicFormField::objects()->filter(array(
                    'id__in' => $this->entries
                    ->filter($criteria)
                ->values_flat('answers__field_id')));

        return ($fields && count($fields)) ? $fields : array();
    }

    function hasClientEditableFields() {
        $forms = DynamicFormEntry::forTicket($this->getId());
        foreach ($forms as $form) {
            foreach ($form->getFields() as $field) {
                if ($field->isEditableToUsers())
                    return true;
            }
        }
    }

    //if ids passed, function returns only the ids of fields disabled by help topic
    static function getMissingRequiredFields($ticket, $ids=false) {
        // Check for fields disabled by Help Topic
        $disabled = array();
        foreach (($ticket->getTopic() ? $ticket->getTopic()->forms : $ticket->entries) as $f) {
            $extra = JsonDataParser::decode($f->extra);

            if (!empty($extra['disable']))
                $disabled[] = $extra['disable'];
        }

        $disabled = !empty($disabled) ? call_user_func_array('array_merge', $disabled) : NULL;

        if ($ids)
          return $disabled;

        $criteria = array(
                    'answers__field__flags__hasbit' => DynamicFormField::FLAG_ENABLED,
                    'answers__field__flags__hasbit' => DynamicFormField::FLAG_CLOSE_REQUIRED,
                    'answers__value__isnull' => true,
                    );

        // If there are disabled fields then exclude them
        if ($disabled)
            array_push($criteria, Q::not(array('answers__field__id__in' => $disabled)));

        return $ticket->getDynamicFields($criteria);
    }

    function getMissingRequiredField() {
        $fields = self::getMissingRequiredFields($this);
        return $fields ? $fields[0] : null;
    }

    function addCollaborator($user, $vars, &$errors, $event=true) {

        if ($user && $user->getId() == $this->getOwnerId())
            $errors['err'] = __('Ticket Owner cannot be a Collaborator');

        if ($user && !$errors
                && ($c = $this->getThread()->addCollaborator($user, $vars,
                        $errors, $event))) {
            $c->setCc($c->active);
            $this->collaborators = null;
            $this->recipients = null;
            return $c;
        }

        return null;
    }

    function addCollaborators($users, $vars, &$errors, $event=true) {

        if (!$users || !is_array($users))
            return null;

        $collabs = $this->getCollaborators();
        $new = array();
        foreach ($users as $user) {
            if (!($user instanceof User)
                    && !($user = User::lookup($user)))
                continue;
            if ($collabs->findFirst(array('user_id' => $user->getId())))
                continue;
            if ($user->getId() == $this->getOwnerId())
                continue;

            if ($c=$this->addCollaborator($user, $vars, $errors, $event))
                $new[] = $c;
        }
        return $new;
    }

    //XXX: Ugly for now
    function updateCollaborators($vars, &$errors) {
        global $thisstaff;

        if (!$thisstaff) return;

        //Deletes
        if($vars['del'] && ($ids=array_filter($vars['del']))) {
            $collabs = array();
            foreach ($ids as $k => $cid) {
                if (($c=Collaborator::lookup($cid))
                        && $c->getTicketId() == $this->getId()
                        && $c->delete())
                     $collabs[] = (string) $c;
            }
            $this->logEvent('collab', array('del' => $collabs));
        }

        //statuses
        $cids = null;
        if($vars['cid'] && ($cids=array_filter($vars['cid']))) {
            $this->getThread()->collaborators->filter(array(
                'thread_id' => $this->getThreadId(),
                'id__in' => $cids
            ))->update(array(
                'updated' => SqlFunction::NOW(),
            ));
        }

        if ($cids) {
            $this->getThread()->collaborators->filter(array(
                'thread_id' => $this->getThreadId(),
                Q::not(array('id__in' => $cids))
            ))->update(array(
                'updated' => SqlFunction::NOW(),
            ));
        }

        unset($this->active_collaborators);
        $this->collaborators = null;

        return true;
    }

    function getAuthToken($user, $algo=1) {

        //Format: // <user type><algo id used>x<pack of uid & tid><hash of the algo>
        $authtoken = sprintf('%s%dx%s',
                ($user->getId() == $this->getOwnerId() ? 'o' : 'c'),
                $algo,
                Base32::encode(pack('VV',$user->getId(), $this->getId())));

        switch($algo) {
            case 1:
                $authtoken .= substr(base64_encode(
                            md5($user->getId().$this->getCreateDate().$this->getId().SECRET_SALT, true)), 8);
                break;
            default:
                return null;
        }

        return $authtoken;
    }

    function sendAccessLink($user) {
        global $ost;

        if (!($email = $ost->getConfig()->getDefaultEmail())
            || !($content = Page::lookupByType('access-link')))
            return;

        $vars = array(
            'url' => $ost->getConfig()->getBaseUrl(),
            'ticket' => $this,
            'user' => $user,
            'recipient' => $user,
            // Get ticket link, with authcode, directly to bypass collabs
            // check
            'recipient.ticket_link' => $user->getTicketLink(),
        );

        $lang = $user->getLanguage(UserAccount::LANG_MAILOUTS);
        $msg = $ost->replaceTemplateVariables(array(
            'subj' => $content->getLocalName($lang),
            'body' => $content->getLocalBody($lang),
        ), $vars);

        $email->send($user, Format::striptags($msg['subj']),
            $msg['body']);
    }


    /* -------------------- Setters --------------------- */
    public function setFlag($flag, $val) {

        if ($val)
            $this->flags |= $flag;
        else
            $this->flags &= ~$flag;
    }

    function setMergeType($combine=false, $parent=false) {
        //for $combine, 0 = separate, 1 = combine, 2 = link, 3 = regular ticket
        $flags = array(Ticket::FLAG_SEPARATE_THREADS, Ticket::FLAG_COMBINE_THREADS, Ticket::FLAG_LINKED);
        foreach ($flags as $key => $flag) {
            if ($combine == $key)
                $this->setFlag($flag, true);
            else
                $this->setFlag($flag, false);
        }
        if ($parent)
            $this->setFlag(Ticket::FLAG_PARENT, true);
        else
            $this->setFlag(Ticket::FLAG_PARENT, false);

        $this->save();
    }

    function setPid($pid) {
        return $this->ticket_pid = $this->getId() != $pid ? $pid : NULL;
    }

    function setSort($sort) {
        return $this->sort=$sort;
    }

    function setLastMsgId($msgid) {
        return $this->lastMsgId=$msgid;
    }
    function setLastMessage($message) {
        $this->last_message = $message;
        $this->setLastMsgId($message->getId());
    }

    //DeptId can NOT be 0. No orphans please!
    function setDeptId($deptId) {
        // Make sure it's a valid department
        if ($deptId == $this->getDeptId() || !($dept=Dept::lookup($deptId))) {
            return false;
        }
        $this->dept = $dept;
        return $this->save();
    }

    // Set staff ID...assign/unassign/release (id can be 0)
    function setStaffId($staffId) {
        if (!is_numeric($staffId))
            return false;

        $this->staff = Staff::lookup($staffId);
        return $this->save();
    }

    function setSLAId($slaId) {
        if ($slaId == $this->getSLAId())
            return true;

        $sla = null;
        if ($slaId && !($sla = Sla::lookup($slaId)))
            return false;

        $this->sla = $sla;
        return $this->save();
    }
    /**
     * Selects the appropriate service-level-agreement plan for this ticket.
     * When tickets are transfered between departments, the SLA of the new
     * department should be applied to the ticket. This would be useful,
     * for instance, if the ticket is transferred to a different department
     * which has a shorter grace period, the ticket should be considered
     * overdue in the shorter window now that it is owned by the new
     * department.
     *
     * $trump - if received, should trump any other possible SLA source.
     *          This is used in the case of email filters, where the SLA
     *          specified in the filter should trump any other SLA to be
     *          considered.
     */
    function selectSLAId($trump=null) {
        global $cfg;
        # XXX Should the SLA be overridden if it was originally set via an
        #     email filter? This method doesn't consider such a case
        if ($trump && is_numeric($trump)) {
            $slaId = $trump;
        } elseif ($this->getDept() && $this->getDept()->getSLAId()) {
            $slaId = $this->getDept()->getSLAId();
        } elseif ($this->getTopic() && $this->getTopic()->getSLAId()) {
            $slaId = $this->getTopic()->getSLAId();
        } else {
            $slaId = $cfg->getDefaultSLAId();
        }

        return ($slaId && $this->setSLAId($slaId)) ? $slaId : false;
    }

    //Set team ID...assign/unassign/release (id can be 0)
    function setTeamId($teamId) {
        if (!is_numeric($teamId))
            return false;

        $this->team = Team::lookup($teamId);
        return $this->save();
    }

    // Ticket Status helper.
    function setStatus($status, $comments='', &$errors=array(), $set_closing_agent=true, $force_close=false) {
        global $cfg, $thisstaff;

        if ($thisstaff && !($role=$this->getRole($thisstaff)))
            return false;

        if ((!$status instanceof TicketStatus)
                && !($status = TicketStatus::lookup($status)))
            return false;

        // Double check permissions (when changing status)
        if ($role && $this->getStatusId()) {
            switch ($status->getState()) {
            case 'closed':
                if (!($role->hasPerm(Ticket::PERM_CLOSE)))
                    return false;
                break;
            case 'deleted':
                // XXX: intercept deleted status and do hard delete TODO: soft deletes
                if ($role->hasPerm(Ticket::PERM_DELETE))
                    return $this->delete($comments);
                // Agent doesn't have permission to delete  tickets
                return false;
                break;
            }
        }

        $hadStatus = $this->getStatusId();
        if ($this->getStatusId() == $status->getId())
            return true;

        // Perform checks on the *new* status, _before_ the status changes
        $ecb = $refer = null;
        switch ($status->getState()) {
            case 'closed':
                // Check if ticket is closeable
                $closeable = $force_close ? true : $this->isCloseable();
                if ($closeable !== true)
                    $errors['err'] = $closeable ?: sprintf(__('%s cannot be closed'), __('This ticket'));

                if ($errors)
                    return false;

                $refer = $this->staff ?: $thisstaff;
                $this->closed = $this->lastupdate = SqlFunction::NOW();
                if ($thisstaff && $set_closing_agent)
                    $this->staff = $thisstaff;
                // Clear overdue flags & due dates
                $this->clearOverdue(false);

                $ecb = function($t) use ($status) {
                    $t->logEvent('closed', array('status' => array($status->getId(), $status->getName())), null, 'closed');
                    $t->deleteDrafts();
                };
                break;
            case 'open':
                if ($this->isClosed() && $this->isReopenable()) {
                    // Auto-assign to closing staff or the last respondent if the
                    // agent is available and has access. Otherwise, put the ticket back
                    // to unassigned pool.
                    $dept = $this->getDept();
                    $staff = $this->getStaff() ?: $this->getLastRespondent();
                    $autoassign = (!$dept->disableReopenAutoAssign());
                    if ($autoassign
                            && $staff
                            // Is agent on vacation ?
                            && $staff->isAvailable()
                            // Does the agent have access to dept?
                            && $staff->canAccessDept($dept))
                        $this->setStaffId($staff->getId());
                    else
                        $this->setStaffId(0); // Clear assignment
                }

                if ($this->isClosed()) {
                    $this->closed = null;
                    $this->lastupdate = $this->reopened = SqlFunction::NOW();
                    $ecb = function ($t) {
                        $t->logEvent('reopened', false, null, 'closed');
                        // Set new sla duedate if any
                        $t->updateEstDueDate();
                    };
                }

                // If the ticket is not open then clear answered flag
                if (!$this->isOpen())
                    $this->isanswered = 0;
                break;
            default:
                return false;

        }

        $this->status = $status;
        if (!$this->save(true))
            return false;

        // Refer thread to previously assigned or closing agent
        if ($refer && $cfg->autoReferTicketsOnClose())
            $this->getThread()->refer($refer);

        // Log status change b4 reload — if currently has a status. (On new
        // ticket, the ticket is opened and thereafter the status is set to
        // the requested status).
        if ($hadStatus) {
            $alert = false;
            if ($comments = ThreadEntryBody::clean($comments)) {
                // Send out alerts if comments are included
                $alert = true;
                $this->logNote(__('Status Changed'), $comments, $thisstaff, $alert);
            }
        }
        // Log events via callback
        if ($ecb)
            $ecb($this);
        elseif ($hadStatus)
            // Don't log the initial status change
            $this->logEvent('edited', array('status' => $status->getId()));

        return true;
    }

    function setState($state, $alerts=false) {
        switch (strtolower($state)) {
        case 'open':
            return $this->setStatus('open');
        case 'closed':
            return $this->setStatus('closed');
        case 'answered':
            return $this->setAnsweredState(1);
        case 'unanswered':
            return $this->setAnsweredState(0);
        case 'overdue':
            return $this->markOverdue();
        case 'notdue':
            return $this->clearOverdue();
        case 'unassined':
            return $this->unassign();
        }
        // FIXME: Throw and excception and add test cases
        return false;
    }

    function setAnsweredState($isanswered) {
        $this->isanswered = $isanswered;
        return $this->save();
    }

    function reopen() {
        global $cfg;

        if (!$this->isClosed())
            return false;

        // Set status to open based on current closed status settings
        // If the closed status doesn't have configured "reopen" status then use the
        // the default ticket status.
        if (!($status=$this->getStatus()->getReopenStatus()))
            $status = $cfg->getDefaultTicketStatusId();

        return $status ? $this->setStatus($status) : false;
    }

    function onNewTicket($message, $autorespond=true, $alertstaff=true) {
        global $cfg;

        //Log stuff here...

        if (!$autorespond && !$alertstaff)
            return true; //No alerts to send.

        /* ------ SEND OUT NEW TICKET AUTORESP && ALERTS ----------*/

        if(!$cfg
            || !($dept=$this->getDept())
            || !($tpl = $dept->getTemplate())
            || !($email=$dept->getAutoRespEmail())
        ) {
            return false;  //bail out...missing stuff.
        }

        $options = array();
        if (($message instanceof ThreadEntry)
                && $message->getEmailMessageId()) {
            $options += array(
                'inreplyto'=>$message->getEmailMessageId(),
                'references'=>$message->getEmailReferences(),
                'thread'=>$message
            );
        }
        else {
            $options += array(
                'thread' => $this->getThread(),
            );
        }

        //Send auto response - if enabled.
        if ($autorespond
            && $cfg->autoRespONNewTicket()
            && $dept->autoRespONNewTicket()
            && ($msg = $tpl->getAutoRespMsgTemplate())
        ) {
            $msg = $this->replaceVars(
                $msg->asArray(),
                array('message' => $message,
                      'recipient' => $this->getOwner(),
                      'signature' => ($dept && $dept->isPublic())?$dept->getSignature():''
                )
            );
            $email->sendAutoReply($this->getOwner(), $msg['subj'], $msg['body'],
                null, $options);
        }

        // Send alert to out sleepy & idle staff.
        if ($alertstaff
            && $cfg->alertONNewTicket()
            && ($email=$dept->getAlertEmail())
            && ($msg=$tpl->getNewTicketAlertMsgTemplate())
        ) {
            $msg = $this->replaceVars($msg->asArray(), array('message' => $message));
            $recipients = $sentlist = array();
            // Exclude the auto responding email just incase it's from staff member.
            if ($message instanceof ThreadEntry && $message->isAutoReply())
                $sentlist[] = $this->getEmail();

            if ($dept->getNumMembersForAlerts()) {
                // Only alerts dept members if the ticket is NOT assigned.
                $manager = $dept->getManager();
                if ($cfg->alertDeptMembersONNewTicket() && !$this->isAssigned()
                    && ($members = $dept->getMembersForAlerts())
                ) {
                    foreach ($members as $M)
                        if ($M != $manager)
                            $recipients[] = $M;
                }

                if ($cfg->alertDeptManagerONNewTicket() && $manager) {
                    $recipients[] = $manager;
                }

                // Account manager
                if ($cfg->alertAcctManagerONNewTicket()
                    && ($org = $this->getOwner()->getOrganization())
                    && ($acct_manager = $org->getAccountManager())
                ) {
                    if ($acct_manager instanceof Team)
                        $recipients = array_merge($recipients, $acct_manager->getMembersForAlerts());
                    else
                        $recipients[] = $acct_manager;
                }

                foreach ($recipients as $k=>$staff) {
                    if (!is_object($staff)
                        || !$staff->isAvailable()
                        || in_array($staff->getEmail(), $sentlist)
                    ) {
                        continue;
                    }
                    $alert = $this->replaceVars($msg, array('recipient' => $staff));
                    $email->sendAlert($staff, $alert['subj'], $alert['body'], null, $options);
                    $sentlist[] = $staff->getEmail();
                }
            }

            // Alert admin ONLY if not already a staff??
            if ($cfg->alertAdminONNewTicket()
                    && !in_array($cfg->getAdminEmail(), $sentlist)
                    && ($dept->isGroupMembershipEnabled() != Dept::ALERTS_DISABLED)) {
                $options += array('utype'=>'A');
                $alert = $this->replaceVars($msg, array('recipient' => 'Admin'));
                $email->sendAlert($cfg->getAdminEmail(), $alert['subj'],
                        $alert['body'], null, $options);
            }

        }
        return true;
    }

    function onOpenLimit($sendNotice=true) {
        global $ost, $cfg;

        //Log the limit notice as a warning for admin.
        $msg=sprintf(_S('Maximum open tickets (%1$d) reached for %2$s'),
            $cfg->getMaxOpenTickets(), $this->getEmail());
        $ost->logWarning(sprintf(_S('Maximum Open Tickets Limit (%s)'),$this->getEmail()),
            $msg);

        if (!$sendNotice || !$cfg->sendOverLimitNotice())
            return true;

        //Send notice to user.
        if (($dept = $this->getDept())
            && ($tpl=$dept->getTemplate())
            && ($msg=$tpl->getOverlimitMsgTemplate())
            && ($email=$dept->getAutoRespEmail())
        ) {
            $msg = $this->replaceVars(
                $msg->asArray(),
                array('signature' => ($dept && $dept->isPublic())?$dept->getSignature():'')
            );

            $email->sendAutoReply($this->getOwner(), $msg['subj'], $msg['body']);
        }

        $user = $this->getOwner();

        // Alert admin...this might be spammy (no option to disable)...but it is helpful..I think.
        $alert=sprintf(__('Maximum open tickets reached for %s.'), $this->getEmail())."\n"
              .sprintf(__('Open tickets: %d'), $user->getNumOpenTickets())."\n"
              .sprintf(__('Max allowed: %d'), $cfg->getMaxOpenTickets())
              ."\n\n".__("Notice sent to the user.");

        $ost->alertAdmin(__('Overlimit Notice'), $alert);

        return true;
    }

    function onResponse($response, $options=array()) {
        $this->isanswered = 1;
        $this->save();

        $vars = array_merge($options,
            array(
                'activity' => _S('New Response'),
                'threadentry' => $response
            )
        );
        $this->onActivity($vars);
    }

    /*
     * Notify collaborators on response or new message
     *
     */
    function notifyCollaborators($entry, $vars = array()) {
        global $cfg;

        if (!$entry instanceof ThreadEntry
            || !($recipients=$this->getRecipients())
            || !($dept=$this->getDept())
            || !($tpl=$dept->getTemplate())
            || !($msg=$tpl->getActivityNoticeMsgTemplate())
            || !($email=$dept->getEmail())
        ) {
            return;
        }

        $poster = User::lookup($entry->user_id);
        $posterEmail = $poster->getEmail()->address;

        $vars = array_merge($vars, array(
            'message' => (string) $entry,
            'poster' => $poster ?: _S('A collaborator'),
            )
        );

        $msg = $this->replaceVars($msg->asArray(), $vars);

        $attachments = $cfg->emailAttachments()?$entry->getAttachments():array();
        $options = array('thread' => $entry);

        if ($vars['from_name'])
            $options += array('from_name' => $vars['from_name']);

        $skip = array();
        if ($entry instanceof MessageThreadEntry) {
          foreach ($entry->getAllEmailRecipients() as $R) {
                $skip[] = $R->mailbox.'@'.$R->host;
            }
        }

        foreach ($recipients as $key => $recipient) {
            $recipient = $recipient->getContact();

            if(get_class($recipient) == 'TicketOwner')
                $owner = $recipient;

            if ((get_class($recipient) == 'Collaborator' ? $recipient->getUserId() : $recipient->getId()) == $entry->user_id)
                unset($recipients[$key]);
         }

        if (!count($recipients))
            return true;

        //see if the ticket user is a recipient
        if ($owner->getEmail()->address != $poster->getEmail()->address && !in_array($owner->getEmail()->address, $skip))
          $owner_recip = $owner->getEmail()->address;

        //say dear collaborator if the ticket user is not a recipient
        if (!$owner_recip) {
            $nameFormats = array_keys(PersonsName::allFormats());
            $names = array();
            foreach ($nameFormats as $key => $value) {
              $names['recipient.name.' . $value] = __('Collaborator');
            }
            $names = array_merge($names, array('recipient' => $recipient));
            $cnotice = $this->replaceVars($msg, $names);
        }
        //otherwise address email to ticket user
        else
            $cnotice = $this->replaceVars($msg, array('recipient' => $owner));

        $email->send($recipients, $cnotice['subj'], $cnotice['body'], $attachments,
            $options);
    }

    function onMessage($message, $autorespond=true, $reopen=true) {
        global $cfg;

        $this->isanswered = 0;
        $this->lastupdate = SqlFunction::NOW();
        $this->save();


        // Reopen if closed AND reopenable
        // We're also checking autorespond flag because we don't want to
        // reopen closed tickets on auto-reply from end user. This is not to
        // confused with autorespond on new message setting
        if ($reopen && $this->isClosed() && $this->isReopenable())
            $this->reopen();

        if (!$autorespond)
            return;

        // Figure out the user
        if ($this->getOwnerId() == $message->getUserId())
            $user = new TicketOwner(
                    User::lookup($message->getUserId()), $this);
        else
            $user = Collaborator::lookup(array(
                    'user_id' => $message->getUserId(),
                    'thread_id' => $this->getThreadId()));

        /**********   double check auto-response  ************/
        if (!$user)
            $autorespond = false;
        elseif ((Email::getIdByEmail($user->getEmail())))
            $autorespond = false;
        elseif (($dept=$this->getDept()))
            $autorespond = $dept->autoRespONNewMessage();

        if (!$autorespond
            || !$cfg->autoRespONNewMessage()
            || !$message
        ) {
            return;  //no autoresp or alerts.
        }

        $dept = $this->getDept();
        $email = $dept->getAutoRespEmail();

        // If enabled...send confirmation to user. ( New Message AutoResponse)
        if ($email
            && ($tpl=$dept->getTemplate())
            && ($msg=$tpl->getNewMessageAutorepMsgTemplate())
        ) {
            $msg = $this->replaceVars($msg->asArray(),
                array(
                    'recipient' => $user,
                    'signature' => ($dept && $dept->isPublic())?$dept->getSignature():''
                )
            );
            $options = array('thread' => $message);
            if ($message->getEmailMessageId()) {
                $options += array(
                        'inreplyto' => $message->getEmailMessageId(),
                        'references' => $message->getEmailReferences()
                        );
            }

            $email->sendAutoReply($user, $msg['subj'], $msg['body'],
                null, $options);
        }
    }

    function onActivity($vars, $alert=true) {
        global $cfg, $thisstaff;

        //TODO: do some shit
        if (!$alert // Check if alert is enabled
            || !$cfg->alertONNewActivity()
            || !($dept=$this->getDept())
            || !$dept->getNumMembersForAlerts()
            || !($email=$cfg->getAlertEmail())
            || !($tpl = $dept->getTemplate())
            || !($msg=$tpl->getNoteAlertMsgTemplate())
        ) {
            return;
        }

        // Alert recipients
        $recipients = array();

        //Last respondent.
        if ($cfg->alertLastRespondentONNewActivity())
            $recipients[] = $this->getLastRespondent();

        // Assigned staff / team
        if ($cfg->alertAssignedONNewActivity()) {
            if (isset($vars['assignee'])
                    && $vars['assignee'] instanceof Staff)
                 $recipients[] = $vars['assignee'];
            elseif ($this->isOpen() && ($assignee = $this->getStaff()))
                $recipients[] = $assignee;

            if ($team = $this->getTeam())
                $recipients = array_merge($recipients, $team->getMembersForAlerts());
        }

        // Dept manager
        if ($cfg->alertDeptManagerONNewActivity() && $dept && $dept->getManagerId())
            $recipients[] = $dept->getManager();

        $options = array();
        $staffId = $thisstaff ? $thisstaff->getId() : 0;
        if ($vars['threadentry'] && $vars['threadentry'] instanceof ThreadEntry) {
            $options = array('thread' => $vars['threadentry']);

            // Activity details
            if (!$vars['comments'])
                $vars['comments'] = $vars['threadentry'];

            // Staff doing the activity
            $staffId = $vars['threadentry']->getStaffId() ?: $staffId;
        }

        $msg = $this->replaceVars($msg->asArray(),
                array(
                    'note' => $vars['threadentry'], // For compatibility
                    'activity' => $vars['activity'],
                    'comments' => $vars['comments']));

        $isClosed = $this->isClosed();
        $sentlist=array();
        foreach ($recipients as $k=>$staff) {
            if (!is_object($staff)
                // Don't bother vacationing staff.
                || !$staff->isAvailable()
                // No need to alert the poster!
                || $staffId == $staff->getId()
                // No duplicates.
                || isset($sentlist[$staff->getEmail()])
                // Make sure staff has access to ticket
                || ($isClosed && !$this->checkStaffPerm($staff))
            ) {
                continue;
            }
            $alert = $this->replaceVars($msg, array('recipient' => $staff));
            $email->sendAlert($staff, $alert['subj'], $alert['body'], null, $options);
            $sentlist[$staff->getEmail()] = 1;
        }
    }

    function onAssign($assignee, $comments, $alert=true) {
        global $cfg, $thisstaff;

        if ($this->isClosed())
            $this->reopen(); //Assigned tickets must be open - otherwise why assign?

        // Assignee must be an object of type Staff or Team
        if (!$assignee || !is_object($assignee))
            return false;

        $user_comments = (bool) $comments;
        $assigner = $thisstaff ?: _S('SYSTEM (Auto Assignment)');

        //Log an internal note - no alerts on the internal note.
        if ($user_comments) {
            if ($assignee instanceof Staff
                    && $thisstaff
                    // self assignment
                    && $assignee->getId() == $thisstaff->getId())
                $title = sprintf(_S('Ticket claimed by %s'),
                    $thisstaff->getName());
            else
                $title = sprintf(_S('Ticket Assigned to %s'),
                        $assignee->getName());

            $note = $this->logNote($title, $comments, $assigner, false);
        }
        $dept = $this->getDept();
        // See if we need to send alerts
        if (!$alert || !$cfg->alertONAssignment() || !$dept->getNumMembersForAlerts())
            return true; //No alerts!

        if (!$dept
            || !($tpl = $dept->getTemplate())
            || !($email = $dept->getAlertEmail())
        ) {
            return true;
        }

        // Recipients
        $recipients = array();
        if ($assignee instanceof Staff) {
            if ($cfg->alertStaffONAssignment())
                $recipients[] = $assignee;
        } elseif (($assignee instanceof Team) && $assignee->alertsEnabled()) {
            if ($cfg->alertTeamMembersONAssignment() && ($members=$assignee->getMembersForAlerts()))
                $recipients = array_merge($recipients, $members);
            elseif ($cfg->alertTeamLeadONAssignment() && ($lead=$assignee->getTeamLead()))
                $recipients[] = $lead;
        }

        // Get the message template
        if ($recipients
            && ($msg=$tpl->getAssignedAlertMsgTemplate())
        ) {
            $msg = $this->replaceVars($msg->asArray(),
                array('comments' => $comments ?: '',
                      'assignee' => $assignee,
                      'assigner' => $assigner
                )
            );
            // Send the alerts.
            $sentlist = array();
            $options = $note instanceof ThreadEntry
                ? array('thread'=>$note)
                : array();
            foreach ($recipients as $k=>$staff) {
                if (!is_object($staff)
                    || !$staff->isAvailable()
                    || in_array($staff->getEmail(), $sentlist)
                ) {
                    continue;
                }
                $alert = $this->replaceVars($msg, array('recipient' => $staff));
                $email->sendAlert($staff, $alert['subj'], $alert['body'], null, $options);
                $sentlist[] = $staff->getEmail();
            }
        }
        return true;
    }

   function onOverdue($whine=true, $comments="") {
        global $cfg;

        if ($whine && ($sla = $this->getSLA()) && !$sla->alertOnOverdue())
            $whine = false;

        // Check if we need to send alerts.
        if (!$whine
            || !$cfg->alertONOverdueTicket()
            || !($dept = $this->getDept())
            || !$dept->getNumMembersForAlerts()
        ) {
            return true;
        }
        // Get the message template
        if (($tpl = $dept->getTemplate())
            && ($msg=$tpl->getOverdueAlertMsgTemplate())
            && ($email = $dept->getAlertEmail())
        ) {
            $msg = $this->replaceVars($msg->asArray(),
                array('comments' => $comments)
            );
            // Recipients
            $recipients = array();
            // Assigned staff or team... if any
            if ($this->isAssigned() && $cfg->alertAssignedONOverdueTicket()) {
                if ($this->getStaffId()) {
                    $recipients[]=$this->getStaff();
                }
                elseif ($this->getTeamId()
                    && ($team = $this->getTeam())
                    && ($members = $team->getMembersForAlerts())
                ) {
                    $recipients=array_merge($recipients, $members);
                }
            }
            elseif ($cfg->alertDeptMembersONOverdueTicket() && !$this->isAssigned()) {
                // Only alerts dept members if the ticket is NOT assigned.
                foreach ($dept->getMembersForAlerts() as $M)
                    $recipients[] = $M;
            }
            // Always alert dept manager??
            if ($cfg->alertDeptManagerONOverdueTicket()
                && $dept && ($manager=$dept->getManager())
            ) {
                $recipients[]= $manager;
            }
            $sentlist = array();
            foreach ($recipients as $k=>$staff) {
                if (!is_object($staff)
                    || !$staff->isAvailable()
                    || in_array($staff->getEmail(), $sentlist)
                ) {
                    continue;
                }
                $alert = $this->replaceVars($msg, array('recipient' => $staff));
                $email->sendAlert($staff, $alert['subj'], $alert['body'], null);
                $sentlist[] = $staff->getEmail();
            }
        }
        return true;
    }

    // TemplateVariable interface
    function asVar() {
       return $this->getNumber();
    }

    function getVar($tag) {
        global $cfg;

        switch(mb_strtolower($tag)) {
        case 'phone':
        case 'phone_number':
            return $this->getPhoneNumber();
            break;
        case 'auth_token':
            return $this->getOldAuthToken();
            break;
        case 'client_link':
            return sprintf('%s/view.php?t=%s',
                    $cfg->getBaseUrl(), $this->getNumber());
            break;
        case 'staff_link':
            return sprintf('%s/scp/tickets.php?id=%d', $cfg->getBaseUrl(), $this->getId());
            break;
        case 'create_date':
            return new FormattedDate($this->getCreateDate());
            break;
         case 'due_date':
            if ($due = $this->getEstDueDate())
                return new FormattedDate($due);
            break;
        case 'close_date':
            if ($this->isClosed())
                return new FormattedDate($this->getCloseDate());
            break;
        case 'last_update':
            return new FormattedDate($this->lastupdate);
        case 'user':
            return $this->getOwner();
        default:
            if ($a = $this->getAnswer($tag))
                // The answer object is retrieved here which will
                // automatically invoke the toString() method when the
                // answer is coerced into text
                return $a;
        }
    }

    static function getVarScope() {
        $base = array(
            'assigned' => __('Assigned Agent / Team'),
            'close_date' => array(
                'class' => 'FormattedDate', 'desc' => __('Date Closed'),
            ),
            'create_date' => array(
                'class' => 'FormattedDate', 'desc' => __('Date Created'),
            ),
            'dept' => array(
                'class' => 'Dept', 'desc' => __('Department'),
            ),
            'due_date' => array(
                'class' => 'FormattedDate', 'desc' => __('Due Date'),
            ),
            'email' => __('Default email address of ticket owner'),
            'id' => __('Ticket ID (internal ID)'),
            'name' => array(
                'class' => 'PersonsName', 'desc' => __('Name of ticket owner'),
            ),
            'number' => __('Ticket Number'),
            'phone' => __('Phone number of ticket owner'),
            'priority' => array(
                'class' => 'Priority', 'desc' => __('Priority'),
            ),
            'recipients' => array(
                'class' => 'UserList', 'desc' => __('List of all recipient names'),
            ),
            'source' => __('Source'),
            'status' => array(
                'class' => 'TicketStatus', 'desc' => __('Status'),
            ),
            'staff' => array(
                'class' => 'Staff', 'desc' => __('Assigned/closing agent'),
            ),
            'subject' => 'Subject',
            'team' => array(
                'class' => 'Team', 'desc' => __('Assigned/closing team'),
            ),
            'thread' => array(
                'class' => 'TicketThread', 'desc' => __('Ticket Thread'),
            ),
            'topic' => array(
                'class' => 'Topic', 'desc' => __('Help Topic'),
            ),
            // XXX: Isn't lastreponse and lastmessage more useful
            'last_update' => array(
                'class' => 'FormattedDate', 'desc' => __('Time of last update'),
            ),
            'user' => array(
                'class' => 'User', 'desc' => __('Ticket Owner'),
            ),
        );

        $extra = VariableReplacer::compileFormScope(TicketForm::getInstance());
        return $base + $extra;
    }

    // Searchable interface
    static function getSearchableFields() {
        global $thisstaff;

        $base = array(
            'number' => new TextboxField(array(
                'label' => __('Ticket Number')
            )),
            'created' => new DatetimeField(array(
                'label' => __('Create Date'),
                'configuration' => array(
                    'fromdb' => true, 'time' => true,
                    'format' => 'y-MM-dd HH:mm:ss'),
            )),
            'duedate' => new DatetimeField(array(
                'label' => __('Due Date'),
                'configuration' => array(
                    'fromdb' => true, 'time' => true,
                    'format' => 'y-MM-dd HH:mm:ss'),
            )),
            'est_duedate' => new DatetimeField(array(
                'label' => __('SLA Due Date'),
                'configuration' => array(
                    'fromdb' => true, 'time' => true,
                    'format' => 'y-MM-dd HH:mm:ss'),
            )),
            'reopened' => new DatetimeField(array(
                'label' => __('Reopen Date'),
                'configuration' => array(
                    'fromdb' => true, 'time' => true,
                    'format' => 'y-MM-dd HH:mm:ss'),
            )),
            'closed' => new DatetimeField(array(
                'label' => __('Close Date'),
                'configuration' => array(
                    'fromdb' => true, 'time' => true,
                    'format' => 'y-MM-dd HH:mm:ss'),
            )),
            'lastupdate' => new DatetimeField(array(
                'label' => __('Last Update'),
                'configuration' => array(
                    'fromdb' => true, 'time' => true,
                    'format' => 'y-MM-dd HH:mm:ss'),
            )),
            'assignee' => new AssigneeChoiceField(array(
                'label' => __('Assignee'),
            )),
            'staff_id' => new AgentSelectionField(array(
                'label' => __('Assigned Staff'),
                'configuration' => array('staff' => $thisstaff),
            )),
            'team_id' => new TeamSelectionField(array(
                'label' => __('Assigned Team'),
            )),
            'dept_id' => new DepartmentChoiceField(array(
                'label' => __('Department'),
            )),
            'sla_id' => new SLAChoiceField(array(
                'label' => __('SLA Plan'),
            )),
            'topic_id' => new HelpTopicChoiceField(array(
                'label' => __('Help Topic'),
            )),
            'source' => new TicketSourceChoiceField(array(
                'label' => __('Ticket Source'),
            )),
            'isoverdue' => new BooleanField(array(
                'label' => __('Overdue'),
                'descsearchmethods' => array(
                    'set' => '%s',
                    'nset' => 'Not %s'
                    ),
            )),
            'isanswered' => new BooleanField(array(
                'label' => __('Answered'),
                'descsearchmethods' => array(
                    'set' => '%s',
                    'nset' => 'Not %s'
                    ),
            )),
            'isassigned' => new AssignedField(array(
                        'label' => __('Assigned'),
            )),
            'merged' => new MergedField(array(
                'label' => __('Merged'),
            )),
            'linked' => new LinkedField(array(
                'label' => __('Linked'),
            )),
            'thread_count' => new TicketThreadCountField(array(
                        'label' => __('Thread Count'),
            )),
            'attachment_count' => new ThreadAttachmentCountField(array(
                        'label' => __('Attachment Count'),
            )),
            'collaborator_count' => new ThreadCollaboratorCountField(array(
                        'label' => __('Collaborator Count'),
            )),
            'task_count' => new TicketTasksCountField(array(
                        'label' => __('Task Count'),
            )),
            'reopen_count' => new TicketReopenCountField(array(
                        'label' => __('Reopen Count'),
            )),
            'ip_address' => new TextboxField(array(
                'label' => __('IP Address'),
                'configuration' => array('validator' => 'ip'),
            )),
        );
        $tform = TicketForm::getInstance();
        foreach ($tform->getFields() as $F) {
            $fname = $F->get('name') ?: ('field_'.$F->get('id'));
            if (!$F->hasData() || $F->isPresentationOnly() || !$F->isEnabled())
                continue;
            if (!$F->isStorable())
                $base[$fname] = $F;
            else
                $base["cdata__{$fname}"] = $F;
        }
        return $base;
    }

    static function supportsCustomData() {
        return true;
    }

    //Replace base variables.
    function replaceVars($input, $vars = array()) {
        global $ost;

        $vars = array_merge($vars, array('ticket' => $this));
        return $ost->replaceTemplateVariables($input, $vars);
    }

    function markUnAnswered() {
        return (!$this->isAnswered() || $this->setAnsweredState(0));
    }

    function markAnswered() {
        return ($this->isAnswered() || $this->setAnsweredState(1));
    }

    function markOverdue($whine=true) {
        global $cfg;

        // Only open tickets can be marked overdue
        if (!$this->isOpen())
            return false;

        if ($this->isOverdue())
            return true;

        $this->isoverdue = 1;
        if (!$this->save())
            return false;

        $this->logEvent('overdue');
        $this->onOverdue($whine);

        return true;
    }

    function clearOverdue($save=true) {

        //NOTE: Previously logged overdue event is NOT annuled.
        if ($this->isOverdue())
            $this->isoverdue = 0;

        // clear due date if it's in the past
        if ($this->getDueDate() && Misc::db2gmtime($this->getDueDate()) <= Misc::gmtime())
            $this->duedate = null;

        // Clear SLA if est. due date is in the past
        if ($this->getSLADueDate() && Misc::db2gmtime($this->getSLADueDate()) <= Misc::gmtime())
            $this->est_duedate = null;

        return $save ? $this->save() : true;
    }

    function unlinkChild($parent) {
        $this->setPid(NULL);
        $this->setSort(1);
        $this->setFlag(Ticket::FLAG_LINKED, false);
        $this->save();
        $this->logEvent('unlinked', array('ticket' => sprintf('Ticket #%s', $parent->getNumber()), 'id' => $parent->getId()));
        $parent->logEvent('unlinked', array('ticket' => sprintf('Ticket #%s', $this->getNumber()), 'id' => $this->getId()));
    }

    function unlink() {
        $pid = $this->isChild() ? $this->getPid() : $this->getId();
        $parent = $this->isParent() ? $this : (Ticket::lookup($pid));
        $child = $this->isChild() ? $this : '';
        $children = $this->getChildren();
        $count = count($children);

        if ($children) {
            foreach ($children as $child) {
                $child = Ticket::lookup($child[0]);
                $child->unlinkChild($parent);
                $count--;
            }
        } elseif ($child)
            $child->unlinkChild($parent);

        if ($this->isParent() && $count == 0) {
            $parent->setFlag(Ticket::FLAG_LINKED, false);
            $parent->setFlag(Ticket::FLAG_PARENT, false);
            $parent->save();
        }

        return true;
    }

    static function manageMerge($tickets) {
        global $thisstaff;

        $permission = ($tickets['title'] && $tickets['title'] == 'link') ? (Ticket::PERM_LINK) : (Ticket::PERM_MERGE);
        $eventName = ($tickets['title'] && $tickets['title'] == 'link') ? 'linked' : 'merged';
        //see if any tickets should be unlinked
        if ($tickets['dtids']) {
            foreach($tickets['dtids'] as $key => $value) {
                if (is_numeric($key) && $ticket = Ticket::lookup($value))
                    $ticket->unlink();
            }
            return true;
        } elseif ($tickets['tids']) { //see if any tickets should be merged
            $ticketObjects = array();
            foreach($tickets['tids'] as $key => $value) {
                if ($ticket = Ticket::lookupByNumber($value)) {
                    $ticketObjects[] = $ticket;
                    if (!$ticket->checkStaffPerm($thisstaff, $permission) && !$ticket->getThread()->isReferred())
                       return false;

                    if ($key == 0)
                        $parent = $ticket;
                    //changing from link to merge
                    if (($ticket->isParent() || $ticket->isChild()) &&
                         $ticket->getMergeType() == 'visual' && $tickets['combine'] != 2 ||
                        ($tickets['combine'] == 2 && !$parent->isParent() && $parent->isChild())) { //changing link parent
                            $ticket->unlink();
                            $changeParent = true;
                    }

                    if ($ticket->getMergeType() == 'visual') {
                        $ticket->setSort($key);
                        $ticket->save();
                    }

                    if ($parent && $parent->getId() != $ticket->getId()) {
                        if (($changeParent) || ($parent->isParent() && $ticket->getMergeType() == 'visual' && !$ticket->isChild()) || //adding to link/merge
                           (!$parent->isParent() && !$ticket->isChild())) { //creating fresh link/merge
                               $parent->logEvent($eventName, array('ticket' => sprintf('Ticket #%s', $ticket->getNumber()),  'id' => $ticket->getId()));
                               $ticket->logEvent($eventName, array('ticket' => sprintf('Ticket #%s', $parent->getNumber()),  'id' => $parent->getId()));
                               if ($ticket->getPid() != $parent->getId())
                                   $ticket->setPid($parent->getId());
                               $parent->setMergeType($tickets['combine'], true);
                               $ticket->setMergeType($tickets['combine']);

                               //referrals for merged tickets
                               if ($parent->getDeptId() != ($ticketDeptId = $ticket->getDeptId()) && $tickets['combine'] != 2) {
                                   $refDept = $ticket->getDept();
                                   $parent->getThread()->refer($refDept);
                                   $evd = array('dept' => $ticketDeptId);
                                   $parent->logEvent('referred', $evd);
                               }
                        }
                    //switch between combine and separate
                    } elseif ($parent->isParent() && $ticket->getMergeType() != 'visual' && $parent->getId() != $ticket->getId()) {
                        $ticket->setMergeType($tickets['combine']);
                    } elseif ($parent->isParent() && $ticket->getMergeType() != 'visual' && $parent->getId() == $ticket->getId())
                        $parent->setMergeType($tickets['combine'], true);
                }
            }
        }
        return $ticketObjects;
    }

    static function merge($tickets) {
        $options = $tickets;
        if (!$tickets = self::manageMerge($tickets))
            return false;
        if (is_bool($tickets))
            return true;

        $children = array();
        foreach ($tickets as $ticket) {
            if ($ticket->isParent())
                $parent = $ticket;
            else
                $children[] = $ticket;
        }

        if ($parent && $parent->getMergeType() != 'visual') {
            $errors = array();
            foreach ($children as $child) {
                if ($options['participants'] == 'all' && $collabs = $child->getCollaborators()) {
                    foreach ($collabs as $collab) {
                        $collab = $collab->getUser();
                        if ($collab->getId() != $parent->getOwnerId())
                            $parent->addCollaborator($collab, array(), $errors);
                    }
                }
                $cUser = $child->getUser();
                if ($cUser->getId() != $parent->getOwnerId())
                    $parent->addCollaborator($cUser, array(), $errors);
                $parentThread = $parent->getThread();

                $deletedChild = Thread::objects()
                    ->filter(array('extra__contains'=>'"ticket_id":'.$child->getId()))
                    ->values_flat('id', 'extra')
                    ->first();
                if ($deletedChild) {
                    $extraThread = Thread::lookup($deletedChild[0]);
                    $extraThread->setExtra($parentThread, array('extra' => $deletedChild[1], 'threadId' => $extraThread->getId()));
                }

                if ($child->getThread())
                    $child->getThread()->setExtra($parentThread);

                $child->setMergeType($options['combine']);
                $child->setStatus(intval($options['childStatusId']), false, $errors, true, true); //force close status for children

                if ($options['parentStatusId'])
                    $parent->setStatus(intval($options['parentStatusId']));

                if ($options['delete-child'] || $options['move-tasks']) {
                    if ($tasks = Task::objects()
                        ->filter(array('object_id' => $child->getId()))
                        ->values_flat('id')) {
                        foreach ($tasks as $key => $tid) {
                            $task = Task::lookup($tid[0]);
                            $task->object_id = $parent->getId();
                            $task->save();
                        }
                    }
                }

                if ($options['delete-child'])
                     $child->delete();
            }
            return $parent;
        }
        return false;
    }

    function getRelatedTickets() {
        return sprintf('<tr>
            <td width="8px">&nbsp;</td>
            <td>
                <a class="Icon strtolower(%s) Ticket preview"
                   data-preview="#tickets/%d/preview"
                   href="tickets.php?id=%d">%s</a>
            </td>
            <td>%s</td>
            <td>%s</td>
            <td>%s</td>
            <td>%s</td>
        </tr>',
        strtolower($this->getSource()), $this->getId(), $this->getId(), $this->getNumber(), $this->getSubject(),
            $this->getDeptName(), $this->getAssignee(), Format::datetime($this->getCreateDate()));
    }

    function hasReferral($object, $type) {
        if (($referral=$this->getThread()->getReferral($object->getId(), $type)))
            return $referral;

        return false;
    }

    //Dept Transfer...with alert.. done by staff
    function transfer(TransferForm $form, &$errors, $alert=true) {
        global $thisstaff, $cfg;

        // Check if staff can do the transfer
        if (!$this->checkStaffPerm($thisstaff, Ticket::PERM_TRANSFER))
            return false;

        $cdept = $this->getDept(); // Current department
        $dept = $form->getDept(); // Target department
        if (!$dept || !($dept instanceof Dept))
            $errors['dept'] = __('Department selection required');
        elseif ($dept->getid() == $this->getDeptId())
            $errors['dept'] = sprintf(
                    __('%s already in the department'), __('Ticket'));
        else {
            $this->dept_id = $dept->getId();

            // Make sure the new department allows assignment to the
            // currently assigned agent (if any)
            if ($this->isAssigned()
                && ($staff=$this->getStaff())
                && $dept->assignMembersOnly()
                && !$dept->isMember($staff)
            ) {
                $this->staff_id = 0;
            }
        }

        if ($errors || !$this->save(true))
            return false;

        // Reopen ticket if closed
        if ($this->isClosed())
            $this->reopen();

        // Set SLA of the new department
        if (!$this->getSLAId() || $this->getSLA()->isTransient())
            if (($slaId=$this->getDept()->getSLAId()))
                $this->selectSLAId($slaId);

        // Log transfer event
        $this->logEvent('transferred', array('dept' => $dept->getName()));

        if (($referral=$this->hasReferral($dept,ObjectModel::OBJECT_TYPE_DEPT)))
            $referral->delete();

        // Post internal note if any
        $note = null;
        $comments = $form->getField('comments')->getClean();
        if ($comments) {
            $title = sprintf(__('%1$s transferred from %2$s to %3$s'),
                    __('Ticket'),
                   $cdept->getName(),
                    $dept->getName());

            $_errors = array();
            $note = $this->postNote(
                    array('note' => $comments, 'title' => $title),
                    $_errors, $thisstaff, false);
        }

        if ($form->refer() && $cdept)
            $this->getThread()->refer($cdept);

        //Send out alerts if enabled AND requested
        if (!$alert || !$cfg->alertONTransfer() || !$dept->getNumMembersForAlerts())
            return true; //no alerts!!

         if (($email = $dept->getAlertEmail())
             && ($tpl = $dept->getTemplate())
             && ($msg=$tpl->getTransferAlertMsgTemplate())
         ) {
            $msg = $this->replaceVars($msg->asArray(),
                array('comments' => $note, 'staff' => $thisstaff));
            // Recipients
            $recipients = array();
            // Assigned staff or team... if any
            if($this->isAssigned() && $cfg->alertAssignedONTransfer()) {
                if($this->getStaffId())
                    $recipients[] = $this->getStaff();
                elseif ($this->getTeamId()
                    && ($team=$this->getTeam())
                    && ($members=$team->getMembersForAlerts())
                ) {
                    $recipients = array_merge($recipients, $members);
                }
            }
            elseif ($cfg->alertDeptMembersONTransfer() && !$this->isAssigned()) {
                // Only alerts dept members if the ticket is NOT assigned.
                foreach ($dept->getMembersForAlerts() as $M)
                    $recipients[] = $M;
            }

            // Always alert dept manager??
            if ($cfg->alertDeptManagerONTransfer()
                && $dept
                && ($manager=$dept->getManager())
            ) {
                $recipients[] = $manager;
            }
            $sentlist = $options = array();
            if ($note) {
                $options += array('thread'=>$note);
            }
            foreach ($recipients as $k=>$staff) {
                if (!is_object($staff)
                    || !$staff->isAvailable()
                    || in_array($staff->getEmail(), $sentlist)
                ) {
                    continue;
                }
                $alert = $this->replaceVars($msg, array('recipient' => $staff));
                $email->sendAlert($staff, $alert['subj'], $alert['body'], null, $options);
                $sentlist[] = $staff->getEmail();
            }
         }

         return true;
    }

    function claim(ClaimForm $form, &$errors) {
        global $thisstaff;

        $dept = $this->getDept();
        $assignee = $form->getAssignee();
        if (!($assignee instanceof Staff)
                || !$thisstaff
                || $thisstaff->getId() != $assignee->getId()) {
            $errors['err'] = __('Unknown assignee');
        } elseif (!$assignee->isAvailable()) {
            $errors['err'] = __('Agent is unavailable for assignment');
        } elseif (!$dept->canAssign($assignee)) {
            $errors['err'] = __('Permission denied');
        }

        if ($errors)
            return false;

        return $this->assignToStaff($assignee, $form->getComments(), false);
    }

    function assignToStaff($staff, $note, $alert=true, $user=null) {

        if(!is_object($staff) && !($staff = Staff::lookup($staff)))
            return false;

        if (!$staff->isAvailable() || !$this->setStaffId($staff->getId()))
            return false;

        $this->onAssign($staff, $note, $alert);

        global $thisstaff;
        $data = array();
        if ($thisstaff && $staff->getId() == $thisstaff->getId())
            $data['claim'] = true;
        else
            $data['staff'] = $staff->getId();

        $this->logEvent('assigned', $data, $user);

        $key = $data['claim'] ? 'claim' : 'auto';
        $type = array('type' => 'assigned', $key => true);
        Signal::send('object.edited', $this, $type);

        if (($referral=$this->hasReferral($staff,ObjectModel::OBJECT_TYPE_STAFF)))
            $referral->delete();

        return true;
    }

    function assignToTeam($team, $note, $alert=true, $user=null) {

        if(!is_object($team) && !($team = Team::lookup($team)))
            return false;

        if (!$team->isActive() || !$this->setTeamId($team->getId()))
            return false;

        //Clear - staff if it's a closed ticket
        //  staff_id is overloaded -> assigned to & closed by.
        if ($this->isClosed())
            $this->setStaffId(0);

        $this->onAssign($team, $note, $alert);
        $this->logEvent('assigned', array('team' => $team->getId()), $user);

        if (($referral=$this->hasReferral($team,ObjectModel::OBJECT_TYPE_TEAM)))
            $referral->delete();

        return true;
    }

    function assign(AssignmentForm $form, &$errors, $alert=true) {
        global $thisstaff;

        $evd = array();
        $audit = array();
        $refer = null;
        $dept = $this->getDept();
        $assignee = $form->getAssignee();
        if ($assignee instanceof Staff) {
            if ($this->getStaffId() == $assignee->getId()) {
                $errors['assignee'] = sprintf(__('%s already assigned to %s'),
                        __('Ticket'),
                        __('the agent')
                        );
            } elseif (!$assignee->isAvailable()) {
                $errors['assignee'] = __('Agent is unavailable for assignment');
            } elseif (!$dept->canAssign($assignee)) {
                $errors['err'] = __('Permission denied');
            } else {
                $refer = $this->staff ?: null;
                $this->staff_id = $assignee->getId();
                if ($thisstaff && $thisstaff->getId() == $assignee->getId()) {
                    $alert = false;
                    $evd['claim'] = true;
                    $audit = array('staff' => $assignee->getName()->name,'claim' => true);
                } else {
                    $evd['staff'] = array($assignee->getId(), (string) $assignee->getName()->getOriginal());
                    $audit = array('staff' => $assignee->getName()->name);
                }

                if (($referral=$this->hasReferral($assignee,ObjectModel::OBJECT_TYPE_STAFF)))
                    $referral->delete();
            }
        } elseif ($assignee instanceof Team) {
            if ($this->getTeamId() == $assignee->getId()) {
                $errors['assignee'] = sprintf(__('%s already assigned to %s'),
                        __('Ticket'),
                        __('the team')
                        );
            } elseif (!$dept->canAssign($assignee)) {
                $errors['err'] = __('Permission denied');
            } else {
                $refer = $this->team ?: null;
                $this->team_id = $assignee->getId();
                $evd = array('team' => $assignee->getId());
                $audit = array('team' => $assignee->getName());
                if (($referral=$this->hasReferral($assignee,ObjectModel::OBJECT_TYPE_TEAM)))
                    $referral->delete();
            }
        } else {
            $errors['assignee'] = __('Unknown assignee');
        }

        if ($errors || !$this->save(true))
            return false;

        $this->logEvent('assigned', $evd);

        $type = array('type' => 'assigned');
        $type += $audit;
        Signal::send('object.edited', $this, $type);

        $this->onAssign($assignee, $form->getComments(), $alert);

        if ($refer && $form->refer())
            $this->getThread()->refer($refer);

        return true;
    }

    // Unassign primary assignee
    function unassign() {
        // We can't release what is not assigned buddy!
        if (!$this->isAssigned())
            return true;

        // We can only unassigned OPEN tickets.
        if ($this->isClosed())
            return false;

        // Unassign staff (if any)
        if ($this->getStaffId() && !$this->setStaffId(0))
            return false;

        // Unassign team (if any)
        if ($this->getTeamId() && !$this->setTeamId(0))
            return false;

        return true;
    }

    function release(?array $info=array(), &$errors) {
        if (isset($info['sid']) && isset($info['tid']))
            return $this->unassign();
        elseif (isset($info['sid']) && $this->setStaffId(0))
            return true;
        elseif (isset($info['tid']) && $this->setTeamId(0))
            return true;

        return false;
    }

    function refer(ReferralForm $form, &$errors, $alert=true) {
        global $thisstaff;

        $evd = array();
        $audit = array();
        $referee = $form->getReferee();
        switch (true) {
        case $referee instanceof Staff:
            $dept = $this->getDept();
            if ($this->getStaffId() == $referee->getId()) {
                $errors['agent'] = sprintf(__('%s is assigned to %s'),
                        __('Ticket'),
                        __('the agent')
                        );
            } elseif(!$referee->isAvailable()) {
                $errors['agent'] = sprintf(__('Agent is unavailable for %s'),
                        __('referral'));
            } else {
                $evd['staff'] = array($referee->getId(), (string) $referee->getName()->getOriginal());
                $audit = array('staff' => $referee->getName()->name);
            }
            break;
        case $referee instanceof Team:
            if ($this->getTeamId() == $referee->getId()) {
                $errors['team'] = sprintf(__('%s is assigned to %s'),
                        __('Ticket'),
                        __('the team')
                        );
            } else {
                //TODO::
                $evd = array('team' => $referee->getId());
                $audit = array('team' => $referee->getName());
            }
            break;
        case $referee instanceof Dept:
            if ($this->getDeptId() == $referee->getId()) {
                $errors['dept'] = sprintf(__('%s is already in %s'),
                        __('Ticket'),
                        __('the department')
                        );
            } else {
                //TODO::
                $evd = array('dept' => $referee->getId());
                $audit = array('dept' => $referee->getName());
            }
            break;
        default:
            $errors['target'] = __('Unknown referral');
        }

        if (!$errors && !$this->getThread()->refer($referee))
            $errors['err'] = __('Unable to refer ticket');

        if ($errors)
            return false;

        $this->logEvent('referred', $evd);

        $type = array('type' => 'referred');
        $type += $audit;
        Signal::send('object.edited', $this, $type);

        return true;
    }

    function systemReferral($emails) {
        global $cfg;

        if (!$thread = $this->getThread())
            return;

        $eventEmails = array();
        $events = ThreadEvent::objects()
            ->filter(array('thread_id' => $thread->getId(),
                           'event__name' => 'transferred'));
        if ($events) {
            foreach ($events as $e) {
                $emailId = Dept::getEmailIdById($e->dept_id) ?: $cfg->getDefaultEmailId();
                if (!in_array($emailId, $eventEmails))
                    $eventEmails[] = $emailId;
            }
        }

        foreach ($emails as $id) {
            $refer = $eventEmails ? !in_array($id, $eventEmails) : true;
            if ($id != $this->email_id
                    && $refer
                    && ($email=Email::lookup($id))
                    && $this->getDeptId() != $email->getDeptId()
                    && ($dept=Dept::lookup($email->getDeptId()))
                    && $this->getThread()->refer($dept)
                    )
                $this->logEvent('referred',
                            array('dept' => $dept->getId()));
        }

    }

    //Change ownership
    function changeOwner($user) {
        global $thisstaff;

        if (!$user
            || ($user->getId() == $this->getOwnerId())
            || !($this->checkStaffPerm($thisstaff,
                Ticket::PERM_EDIT))
        ) {
            return false;
        }

        $this->user_id = $user->getId();
        if (!$this->save())
            return false;

        unset($this->user);
        $this->collaborators = null;
        $this->recipients = null;

        // Remove the new owner from list of collaborators
        $c = Collaborator::lookup(array(
            'user_id' => $user->getId(),
            'thread_id' => $this->getThreadId()
        ));
        if ($c)
            $c->delete();

        $this->logEvent('edited', array('owner' => $user->getId(), 'fields' => array('Ticket Owner' => $user->getName()->name)));

        return true;
    }

    // Insert message from client
    function postMessage($vars, $origin='', $alerts=true) {
        global $cfg;

        if ($origin)
            $vars['origin'] = $origin;
        if (isset($vars['ip']))
            $vars['ip_address'] = $vars['ip'];
        elseif (!$vars['ip_address'] && $_SERVER['REMOTE_ADDR'])
            $vars['ip_address'] = $_SERVER['REMOTE_ADDR'];

        //see if message should go to a parent ticket
        if ($this->isChild() && $this->getMergeType() != 'visual')
            $parent = self::lookup($this->getPid());

        $ticket = $parent ?: $this;
        $errors = array();
        if ($vars['userId'] != $ticket->user_id) {
            if ($vars['userId']) {
                $user = User::lookup($vars['userId']);
             } elseif ($vars['header']
                    && ($hdr= Mail_Parse::splitHeaders($vars['header'], true))
                    && $hdr['From']
                    && ($addr= Mail_Parse::parseAddressList($hdr['From']))) {
                $info = array(
                        'name' => $addr[0]->personal,
                        'email' => $addr[0]->mailbox.'@'.$addr[0]->host);
                if ($user=User::fromVars($info))
                    $vars['userId'] = $user->getId();
            }

            if ($user) {
                $v = array();
                $c = $ticket->getThread()->addCollaborator($user, $v,
                        $errors);
            }
       }

      // Get active recipients of the response
      // Initial Message from Tickets created by Agent
      if ($vars['reply-to'])
          $recipients = $ticket->getRecipients($vars['reply-to'], $vars['ccs']);
      // Messages from Web Portal
      elseif (strcasecmp($origin, 'email')) {
          $recipients = $ticket->getRecipients('all');
          foreach ($recipients as $key => $recipient) {
              if (!$recipientContact = $recipient->getContact())
                  continue;

              $userId = $recipientContact->getUserId() ?: $recipientContact->getId();
              // Do not list the poster as a recipient
              if ($userId == $vars['userId'])
                unset($recipients[$key]);
          }
      }
      if ($recipients && $recipients instanceof MailingList)
          $vars['thread_entry_recipients'] = $recipients->getEmailAddresses();

        if (!($message = $ticket->getThread()->addMessage($vars, $errors)))
            return null;

        $ticket->setLastMessage($message);

        // Add email recipients as collaborators...
        if ($vars['recipients']
            && (strtolower($origin) != 'email' || ($cfg && $cfg->addCollabsViaEmail()))
            //Only add if we have a matched local address
            && $vars['to-email-id']
        ) {
            //New collaborators added by other collaborators are disable --
            // requires staff approval.
            $info = array(
                'isactive' => ($message->getUserId() == $ticket->getUserId())? 1: 0);
            $collabs = array();
            foreach ($vars['recipients'] as $recipient) {
                // Skip virtual delivered-to addresses
                if (strcasecmp($recipient['source'], 'delivered-to') === 0)
                    continue;

                if (($cuser=User::fromVars($recipient))) {
                  if (!$existing = Collaborator::getIdByUserId($cuser->getId(), $ticket->getThreadId())) {
                    $_errors = array();
                    if ($c=$ticket->addCollaborator($cuser, $info, $_errors, false)) {
                      $c->setCc($c->active);

                      // FIXME: This feels very unwise — should be a
                      // string indexed array for future
                      $collabs[$c->user_id] = array(
                          'name' => $c->getName()->getOriginal(),
                          'src' => $recipient['source'],
                      );
                    }
                  }

                }

            }
            // TODO: Can collaborators add others?
            if ($collabs) {
                $ticket->logEvent('collab', array('add' => $collabs), $message->user);
                $type = array('type' => 'collab', 'add' => $collabs);
                Signal::send('object.created', $ticket, $type);
            }
        }

        // Do not auto-respond to bounces and other auto-replies
        $autorespond = isset($vars['mailflags'])
                ? !$vars['mailflags']['bounce'] && !$vars['mailflags']['auto-reply']
                : true;
        $reopen = $autorespond; // Do not reopen bounces
        if ($autorespond && $message->isBounceOrAutoReply())
            $autorespond = $reopen= false;
        elseif ($autorespond && isset($vars['autorespond']))
            $autorespond = $vars['autorespond'];

        $ticket->onMessage($message, ($autorespond && $alerts), $reopen); //must be called b4 sending alerts to staff.

        if ($autorespond && $alerts
            && $cfg && $cfg->notifyCollabsONNewMessage()
            && strcasecmp($origin, 'email')) {
          //when user replies, this is where collabs notified
          $ticket->notifyCollaborators($message, array('signature' => ''));
        }

        if (!($alerts && $autorespond))
            return $message; //Our work is done...

        $dept = $ticket->getDept();
        $variables = array(
            'message' => $message,
            'poster' => ($vars['poster'] ? $vars['poster'] : $ticket->getName())
        );

        $options = array('thread'=>$message);
        // If enabled...send alert to staff (New Message Alert)
        if ($cfg->alertONNewMessage()
            && ($email = $dept->getAlertEmail())
            && ($tpl = $dept->getTemplate())
            && ($msg = $tpl->getNewMessageAlertMsgTemplate())
        ) {
            $msg = $ticket->replaceVars($msg->asArray(), $variables);
            // Build list of recipients and fire the alerts.
            $recipients = array();
            //Last respondent.
            if ($cfg->alertLastRespondentONNewMessage() && ($lr = $ticket->getLastRespondent()))
                $recipients[] = $lr;

            //Assigned staff if any...could be the last respondent
            if ($cfg->alertAssignedONNewMessage() && $ticket->isAssigned()) {
                if ($staff = $ticket->getStaff())
                    $recipients[] = $staff;
                elseif ($team = $ticket->getTeam())
                    $recipients = array_merge($recipients, $team->getMembersForAlerts());
            }

            // Dept manager
            if ($cfg->alertDeptManagerONNewMessage()
                && $dept
                && ($manager = $dept->getManager())
            ) {
                $recipients[]=$manager;
            }

            // Account manager
            if ($cfg->alertAcctManagerONNewMessage()
                    && ($org = $this->getOwner()->getOrganization())
                    && ($acct_manager = $org->getAccountManager())) {
                if ($acct_manager instanceof Team)
                    $recipients = array_merge($recipients, $acct_manager->getMembersForAlerts());
                else
                    $recipients[] = $acct_manager;
            }

            $sentlist = array(); //I know it sucks...but..it works.
            foreach ($recipients as $k=>$staff) {
                if (!$staff || !$staff->getEmail()
                    || !$staff->isAvailable()
                    || in_array($staff->getEmail(), $sentlist)
                ) {
                    continue;
                }
                $alert = $this->replaceVars($msg, array('recipient' => $staff));
                $email->sendAlert($staff, $alert['subj'], $alert['body'], null, $options);
                $sentlist[] = $staff->getEmail();
            }
        }
        $type = array('type' => 'message', 'uid' => $vars['userId']);
        Signal::send('object.created', $this, $type);

        return $message;
    }

    function postCannedReply($canned, $message, $alert=true) {
        global $ost, $cfg;

        if ((!is_object($canned) && !($canned=Canned::lookup($canned)))
            || !$canned->isEnabled()
        ) {
            return false;
        }
        $files = array();
        foreach ($canned->attachments->getAll() as $att) {
            $files[] = array('id' => $att->file_id, 'name' => $att->getName());
            $_SESSION[':cannedFiles'][$att->file_id] = $att->getName();
        }

        if ($cfg->isRichTextEnabled())
            $response = new HtmlThreadEntryBody(
                $this->replaceVars($canned->getHtml()));
        else
            $response = new TextThreadEntryBody(
                $this->replaceVars($canned->getPlainText()));

        $info = array('msgId' => $message instanceof ThreadEntry ? $message->getId() : 0,
                      'poster' => __('SYSTEM (Canned Reply)'),
                      'response' => $response,
                      'files' => $files
        );
        $errors = array();
        if (!($response=$this->postReply($info, $errors, false, false)))
            return null;

        $this->markUnAnswered();

        if (!$alert)
            return $response;

        $dept = $this->getDept();

        if (($email=$dept->getEmail())
            && ($tpl = $dept->getTemplate())
            && ($msg=$tpl->getAutoReplyMsgTemplate())
        ) {
            if ($dept && $dept->isPublic())
                $signature=$dept->getSignature();
            else
                $signature='';

            $msg = $this->replaceVars($msg->asArray(),
                array(
                    'response' => $response,
                    'signature' => $signature,
                    'recipient' => $this->getOwner(),
                )
            );
            $attachments = ($cfg->emailAttachments() && $files)
                ? $response->getAttachments() : array();

            $options = array('thread' => $response);
            if (($message instanceof ThreadEntry)
                    && $message->getUserId() == $this->getUserId()
                    && ($mid=$message->getEmailMessageId())) {
                $options += array(
                        'inreplyto' => $mid,
                        'references' => $message->getEmailReferences()
                        );
            }

            $email->sendAutoReply($this->getOwner(), $msg['subj'], $msg['body'], $attachments,
                $options);
        }
        return $response;
    }

    /* public */
    function postReply($vars, &$errors, $alert=true, $claim=true) {
        global $thisstaff, $cfg;

        if (!$vars['poster'] && $thisstaff)
            $vars['poster'] = $thisstaff;

        if (!$vars['staffId'] && $thisstaff)
            $vars['staffId'] = $thisstaff->getId();

        if (!$vars['ip_address'] && $_SERVER['REMOTE_ADDR'])
            $vars['ip_address'] = $_SERVER['REMOTE_ADDR'];

        // clear db cache
        $this->getThread()->_collaborators = null;

        // Get active recipients of the response
        $recipients = $this->getRecipients($vars['reply-to'], $vars['ccs']);
        if ($recipients instanceof MailingList)
            $vars['thread_entry_recipients'] = $recipients->getEmailAddresses();

        if (!($response = $this->getThread()->addResponse($vars, $errors)))
            return null;

        $dept = $this->getDept();
        $assignee = $this->getStaff();
        // Set status if new is selected
        if ($vars['reply_status_id']
                && ($status = TicketStatus::lookup($vars['reply_status_id']))
                && $status->getId() != $this->getStatusId())
            $this->setStatus($status);

        // Claim on response bypasses the department assignment restrictions
        $claim = ($claim
                && $cfg->autoClaimTickets()
                && !$dept->disableAutoClaim());
        if ($claim && $thisstaff && $this->isOpen() && !$this->getStaffId()) {
            $this->setStaffId($thisstaff->getId()); //direct assignment;
        }

        $this->onResponse($response, array('assignee' => $assignee)); //do house cleaning..

        $this->lastrespondent = $response->staff;

        $type = array('type' => 'message');
        Signal::send('object.created', $this, $type);

        /* email the user??  - if disabled - then bail out */
        if (!$alert)
            return $response;

        //allow agent to send from different dept email
        if (!$vars['from_email_id']
                ||  !($email = Email::lookup($vars['from_email_id'])))
            $email = $dept->getEmail();

        $options = array('thread'=>$response);
        $signature = $from_name = '';
        if ($thisstaff && $vars['signature']=='mine')
            $signature=$thisstaff->getSignature();
        elseif ($vars['signature']=='dept' && $dept->isPublic())
            $signature=$dept->getSignature();

        if ($thisstaff && ($type=$thisstaff->getReplyFromNameType())) {
            switch ($type) {
                case 'mine':
                    if (!$cfg->hideStaffName())
                        $from_name = (string) $thisstaff->getName();
                    break;
                case 'dept':
                    if ($dept->isPublic())
                        $from_name = $dept->getName();
                    break;
                case 'email':
                default:
                    $from_name =  $email->getName();
            }
            if ($from_name)
                $options += array('from_name' => $from_name);
        }

        $variables = array(
            'response' => $response,
            'signature' => $signature,
            'staff' => $thisstaff,
            'poster' => $thisstaff
        );

        if ($email
                && $recipients
                && ($tpl = $dept->getTemplate())
                && ($msg=$tpl->getReplyMsgTemplate())) {

            // Add ticket link (possibly with authtoken) if the ticket owner
            // is the only recipient on a ticket with collabs
            if (count($recipients) == 1
                    && $this->getNumCollaborators()
                    && ($contact = $recipients->offsetGet(0)->getContact())
                    && ($contact instanceof TicketOwner))
                $variables['recipient.ticket_link'] =
                    $contact->getTicketLink();

            $msg = $this->replaceVars($msg->asArray(),
                $variables + array('recipient' => $this->getOwner())
            );

            // Attachments
            $attachments = $cfg->emailAttachments() ?
                $response->getAttachments() : array();

            //Send email to recepients
            $email->send($recipients, $msg['subj'], $msg['body'],
                    $attachments, $options);
        }

        return $response;
    }

    //Activity log - saved as internal notes WHEN enabled!!
    function logActivity($title, $note) {
        return $this->logNote($title, $note, 'SYSTEM', false);
    }

    // History log -- used for statistics generation (pretty reports)
    function logEvent($state, $data=null, $user=null, $annul=null) {
        switch ($state) {
            case 'collab':
            case 'transferred':
                $type = $data;
                $type['type'] = $state;
                break;
            case 'edited':
                $type = array('type' => $state, 'fields' => $data['fields'] ? $data['fields'] : $data);
                break;
            case 'assigned':
            case 'referred':
                break;
            default:
                $type = array('type' => $state);
                break;
        }
        if ($type)
            Signal::send('object.created', $this, $type);
        if ($this->getThread())
            $this->getThread()->getEvents()->log($this, $state, $data, $user, $annul);
    }

    //Insert Internal Notes
    function logNote($title, $note, $poster='SYSTEM', $alert=true) {
        // Unless specified otherwise, assume HTML
        if ($note && is_string($note))
            $note = new HtmlThreadEntryBody($note);

        $errors = array();
        return $this->postNote(
            array(
                'title' => $title,
                'note' => $note,
            ),
            $errors,
            $poster,
            $alert
        );
    }

    function postNote($vars, &$errors, $poster=false, $alert=true) {
        global $cfg, $thisstaff;

        //Who is posting the note - staff or system? or user?
        if ($vars['staffId'] && !$poster)
            $poster = Staff::lookup($vars['staffId']);

        $vars['staffId'] = $vars['staffId'] ?: 0;
        if ($poster && is_object($poster) && !$vars['userId']) {
            $vars['staffId'] = $poster->getId();
            $vars['poster'] = $poster->getName();
        }
        elseif ($poster) { //string
            $vars['poster'] = $poster;
        }
        elseif (!isset($vars['poster'])) {
            $vars['poster'] = 'SYSTEM';
        }
        if (!$vars['ip_address'] && $_SERVER['REMOTE_ADDR'])
            $vars['ip_address'] = $_SERVER['REMOTE_ADDR'];

        if (!($note=$this->getThread()->addNote($vars, $errors)))
            return null;

        $alert = $alert && (
            isset($vars['mailflags'])
            // No alerts for bounce and auto-reply emails
            ? !$vars['mailflags']['bounce'] && !$vars['mailflags']['auto-reply']
            : true
        );

        // Get assigned staff just in case the ticket is closed.
        $assignee = $this->getStaff();

        if ($vars['note_status_id']
            && ($status=TicketStatus::lookup($vars['note_status_id']))
        ) {
            $this->setStatus($status);
        }

        $activity = $vars['activity'] ?: _S('New Internal Note');
        $this->onActivity(array(
            'activity' => $activity,
            'threadentry' => $note,
            'assignee' => $assignee
        ), $alert);

        $type = array('type' => 'note');
        Signal::send('object.created', $this, $type);

        return $note;
    }

    // Threadable interface
    function postThreadEntry($type, $vars, $options=array()) {
        $errors = array();
        switch ($type) {
        case 'M':
            return $this->postMessage($vars, $vars['origin']);
        case 'N':
            return $this->postNote($vars, $errors);
        case 'R':
            return $this->postReply($vars, $errors);
        }
    }

    // Print ticket... export the ticket thread as PDF.
    function pdfExport($psize='Letter', $notes=false, $events=false) {
        global $thisstaff;

        require_once(INCLUDE_DIR.'class.pdf.php');
        if (!is_string($psize)) {
            if ($_SESSION['PAPER_SIZE'])
                $psize = $_SESSION['PAPER_SIZE'];
            elseif (!$thisstaff || !($psize = $thisstaff->getDefaultPaperSize()))
                $psize = 'Letter';
        }

        $pdf = new Ticket2PDF($this, $psize, $notes, $events);
        $name = 'Ticket-'.$this->getNumber().'.pdf';
        Http::download($name, 'application/pdf', $pdf->output($name, 'S'));
        //Remember what the user selected - for autoselect on the next print.
        $_SESSION['PAPER_SIZE'] = $psize;
        exit;
    }

    function zipExport($notes=true, $tasks=false) {
        $exporter = new TicketZipExporter($this);
        $exporter->download(['notes'=>$notes, 'tasks'=>$tasks]);
        exit;
    }

    function delete($comments='') {
        global $ost, $thisstaff;

        //delete just orphaned ticket thread & associated attachments.
        // Fetch thread prior to removing ticket entry
        $t = $this->getThread();

        if (!parent::delete())
            return false;

        //deleting parent ticket
        if ($children = $this->getChildren()) {
            foreach ($children as $childId) {
                if (!($child = Ticket::lookup($childId[0])))
                    continue;

                $child->setPid(NULL);
                $child->setMergeType(3);
                $child->save();
                $childThread = $child->getThread();
                $childThread->object_type = 'T';
                $childThread->save();
            }
        }

        //deleting child ticket
        if ($this->isChild()) {
            $parent = Ticket::lookup($this->ticket_pid);
            if ($parent->isParent() && count($parent->getChildren()) == 0) {
                $parent->setMergeType(3);
                $parent->save();
            }
        } else
            $t->delete();

        $this->logEvent('deleted');

        foreach (DynamicFormEntry::forTicket($this->getId()) as $form)
            $form->delete();

        $this->deleteDrafts();

        if ($this->cdata)
            $this->cdata->delete();

        // Log delete
        $log = sprintf(__('Ticket #%1$s deleted by %2$s'),
            $this->getNumber(),
            $thisstaff ? $thisstaff->getName() : __('SYSTEM')
        );
        if ($comments)
            $log .= sprintf('<hr>%s', $comments);

        $ost->logDebug(
            sprintf( __('Ticket #%s deleted'), $this->getNumber()),
            $log
        );
        return true;
    }

    function deleteDrafts() {
        Draft::deleteForNamespace('ticket.%.' . $this->getId());
    }

    function save($refetch=false) {
        if ($this->dirty) {
            $this->updated = SqlFunction::NOW();
            if (isset($this->dirty['status_id']) && PHP_SAPI !== 'cli')
                // Refetch the queue counts
                SavedQueue::clearCounts();
        }
        return parent::save($this->dirty || $refetch);
    }

    function update($vars, &$errors) {
        global $cfg, $thisstaff;

        if (!$cfg
            || !($this->checkStaffPerm($thisstaff,
                Ticket::PERM_EDIT))
        ) {
            return false;
        }

        $fields = array();
        $fields['topicId']  = array('type'=>'int',      'required'=>1, 'error'=>__('Help topic selection is required'));
        $fields['slaId']    = array('type'=>'int',      'required'=>0, 'error'=>__('Select a valid SLA'));
        $fields['duedate']  = array('type'=>'date',     'required'=>0, 'error'=>__('Invalid date format - must be MM/DD/YY'));

        $fields['user_id']  = array('type'=>'int',      'required'=>0, 'error'=>__('Invalid user-id'));

        if (!Validator::process($fields, $vars, $errors) && !$errors['err'])
            $errors['err'] = sprintf('%s — %s',
                __('Missing or invalid data'),
                __('Correct any errors below and try again'));

        $vars['note'] = ThreadEntryBody::clean($vars['note']);

        if ($vars['duedate']) {
            if ($this->isClosed())
                $errors['duedate']=__('Due date can NOT be set on a closed ticket');
            elseif (strtotime($vars['duedate']) === false)
                $errors['duedate']=__('Invalid due date');
            elseif (Misc::user2gmtime($vars['duedate']) <= Misc::user2gmtime())
                $errors['duedate']=__('Due date must be in the future');
        }

        if (isset($vars['source']) // Check ticket source if provided
                && !array_key_exists($vars['source'], Ticket::getSources()))
            $errors['source'] = sprintf( __('Invalid source given - %s'),
                    Format::htmlchars($vars['source']));

        $topic = Topic::lookup($vars['topicId']);
        if($topic && !$topic->isActive())
          $errors['topicId']= sprintf(__('%s selected must be active'), __('Help Topic'));

        // Validate dynamic meta-data
        $forms = DynamicFormEntry::forTicket($this->getId());
        foreach ($forms as $form) {
            // Don't validate deleted forms
            if (!in_array($form->getId(), $vars['forms']))
                continue;
            $form->filterFields(function($f) { return !$f->isStorable(); });
            $form->setSource($_POST);
            if (!$form->isValid(function($f) {
                return $f->isVisibleToStaff() && $f->isEditableToStaff();
            })) {
                $errors = array_merge($errors, $form->errors());
            }
        }

        if ($errors)
            return false;

        // Decide if we need to keep the just selected SLA
        $keepSLA = ($this->getSLAId() != $vars['slaId']);

        $this->topic_id = $vars['topicId'];
        $this->sla_id = $vars['slaId'];
        $this->source = $vars['source'];
        $this->duedate = $vars['duedate']
            ? date('Y-m-d H:i:s',Misc::dbtime($vars['duedate']))
            : null;

        if ($vars['user_id'])
            $this->user_id = $vars['user_id'];
        if ($vars['duedate'])
            // We are setting new duedate...
            $this->isoverdue = 0;

        $changes = array();
        foreach ($this->dirty as $F=>$old) {
            switch ($F) {
            case 'topic_id':
            case 'user_id':
            case 'source':
            case 'duedate':
            case 'sla_id':
                $changes[$F] = array($old, $this->{$F});
            }
        }

        if (!$this->save())
            return false;

        $vars['note'] = ThreadEntryBody::clean($vars['note']);
        if ($vars['note'])
            $this->logNote(_S('Ticket Updated'), $vars['note'], $thisstaff);

        // Update dynamic meta-data
        foreach ($forms as $form) {
            if ($C = $form->getChanges())
                $changes['fields'] = ($changes['fields'] ?: array()) + $C;
            // Drop deleted forms
            $idx = array_search($form->getId(), $vars['forms']);
            if ($idx === false) {
                $form->delete();
            }
            else {
                $form->set('sort', $idx);
                $form->saveAnswers(function($f) {
                        return $f->isVisibleToStaff()
                        && $f->isEditableToStaff(); }
                        );
            }
        }

        if ($changes) {
          $this->logEvent('edited', $changes);
        }


        // Reselect SLA if transient
        if (!$keepSLA
            && (!$this->getSLA() || $this->getSLA()->isTransient())
        ) {
            $this->selectSLAId();
        }

        if (!$this->save())
            return false;

        $this->updateEstDueDate();
        Signal::send('model.updated', $this);

        return true;
   }

   function updateField($form, &$errors) {
       global $thisstaff, $cfg;

       if (!($field = $form->getField('field')))
           return null;

       $updateDuedate = false;
       if (!($changes = $field->getChanges()))
           $errors['field'] = sprintf(__('%s is already assigned this value'),
                   __($field->getLabel()));
       else {
           if ($field->answer) {
               if (!$field->isEditableToStaff())
                   $errors['field'] = sprintf(__('%s can not be edited'),
                           __($field->getLabel()));
               elseif (!$field->save(true))
                   $errors['field'] =  __('Unable to update field');

               // Strip tags from TextareaFields to ensure event data is not
               // truncated
               if ($field instanceof TextareaField)
                   foreach ($changes as $k=>$v)
                       $changes[$k] = Format::truncate(Format::striptags($v), 200);

               $changes['fields'] = array($field->getId() => $changes);
           } else {
               $val =  $field->getClean();
               $fid = $field->get('name');

               // Convert duedate to DB timezone.
               if ($fid == 'duedate') {
                   if (empty($val))
                       $val = null;
                   elseif ($dt = Format::parseDateTime($val)) {
                     // Make sure the due date is valid
                     if (Misc::user2gmtime($val) <= Misc::user2gmtime())
                         $errors['field']=__('Due date must be in the future');
                     else {
                         $dt->setTimezone(new DateTimeZone($cfg->getDbTimezone()));
                         $val = $dt->format('Y-m-d H:i:s');
                     }
                  }
               } elseif (is_object($val))
                   $val = $val->getId();

               $changes = array();
               $this->{$fid} = $val;
               foreach ($this->dirty as $F=>$old) {
                   switch ($F) {
                   case 'sla_id':
                   case 'duedate':
                        $updateDuedate = true;
                   case 'topic_id':
                   case 'user_id':
                   case 'source':
                       $changes[$F] = array($old, $this->{$F});
                   }
               }

               if (!$errors && !$this->save())
                   $errors['field'] =  __('Unable to update field');
           }
       }

       if ($errors)
           return false;

       // Record the changes
       $this->logEvent('edited', $changes);

       // Log comments (if any)
       if (($comments = $form->getField('comments')->getClean())) {
           $title = sprintf(__('%s updated'), __($field->getLabel()));
           $_errors = array();
           $this->postNote(
                   array('note' => $comments, 'title' => $title),
                   $_errors, $thisstaff, false);
       }

       $this->lastupdate = SqlFunction::NOW();

       if ($updateDuedate)
           $this->updateEstDueDate();

       $this->save();

       Signal::send('model.updated', $this);

       return true;
   }

   /*============== Static functions. Use Ticket::function(params); =============nolint*/
    static function getIdByNumber($number, $email=null, $ticket=false) {

        if (!$number)
            return 0;

        $query = static::objects()
            ->filter(array('number' => $number));

        if ($email)
            $query->filter(Q::any(array(
                'user__emails__address' => $email,
                'thread__collaborators__user__emails__address' => $email
            )));


        if (!$ticket) {
            $query = $query->values_flat('ticket_id');
            if ($row = $query->first())
                return $row[0];
        }
        else {
            return $query->first();
        }
    }

    static function lookupByNumber($number, $email=null) {
        return static::getIdByNumber($number, $email, true);
    }

    static function isTicketNumberUnique($number) {
        $num = static::objects()
            ->filter(array('number' => $number))
        ->count();

    return ($num === 0);
    }

    static function getChildTickets($pid) {
        return Ticket::objects()
                ->filter(array('ticket_pid'=>$pid))
                ->values_flat('ticket_id', 'number', 'ticket_pid', 'sort', 'thread__id', 'user_id', 'cdata__subject', 'user__name', 'flags')
                ->annotate(array('tasks' => SqlAggregate::COUNT('tasks__id', true),
                                 'collaborators' => SqlAggregate::COUNT('thread__collaborators__id'),
                                 'entries' => SqlAggregate::COUNT('thread__entries__id'),))
                ->order_by('sort');
    }

    /* Quick client's tickets stats
       @email - valid email.
     */
    function getUserStats($user) {
        if(!$user || !($user instanceof EndUser))
            return null;

        $sql='SELECT count(open.ticket_id) as open, count(closed.ticket_id) as closed '
            .' FROM '.TICKET_TABLE.' ticket '
            .' LEFT JOIN '.TICKET_TABLE.' open
                ON (open.ticket_id=ticket.ticket_id AND open.status=\'open\') '
            .' LEFT JOIN '.TICKET_TABLE.' closed
                ON (closed.ticket_id=ticket.ticket_id AND closed.status=\'closed\')'
            .' WHERE ticket.user_id = '.db_input($user->getId());

        return db_fetch_array(db_query($sql));
    }

    protected static function filterTicketData($origin, $vars, $forms, $user=false, $postCreate=false) {
        global $cfg;

        // Unset all the filter data field data in case things change
        // during recursive calls
        foreach ($vars as $k=>$v)
            if (strpos($k, 'field.') === 0)
                unset($vars[$k]);

        foreach ($forms as $F) {
            if ($F) {
                $vars += $F->getFilterData();
            }
        }

        if (!$user) {
            $interesting = array('name', 'email');
            $user_form = UserForm::getUserForm()->getForm($vars);
            // Add all the user-entered info for filtering
            foreach ($interesting as $F) {
                if ($field = $user_form->getField($F))
                    $vars[$F] = $field->toString($field->getClean());
            }
            // Attempt to lookup the user and associated data
            $user = User::lookupByEmail($vars['email']);
        }

        // Add in user and organization data for filtering
        if ($user) {
            $vars += $user->getFilterData();
            $vars['email'] = $user->getEmail();
            $vars['name'] = $user->getName()->getOriginal();
            if ($org = $user->getOrganization()) {
                $vars += $org->getFilterData();
            }
        }
        // Don't include org information based solely on email domain
        // for existing user instances
        else {
            // Unpack all known user info from the request
            foreach ($user_form->getFields() as $f) {
                $vars['field.'.$f->get('id')] = $f->toString($f->getClean());
            }
            // Add in organization data if one exists for this email domain
            list($mailbox, $domain) = explode('@', $vars['email'], 2);
            if ($org = Organization::forDomain($domain)) {
                $vars += $org->getFilterData();
            }
        }

        try {
            // Make sure the email address is not banned
            if (($filter=Banlist::isBanned($vars['email']))) {
                throw new RejectedException($filter, $vars);
            }

            // Init ticket filters...
            $ticket_filter = new TicketFilter($origin, $vars);
            $ticket_filter->apply($vars, $postCreate);

            if ($postCreate && $filterMatches = $ticket_filter->getMatchingFilterList()) {
                $username = __('Ticket Filter');
                foreach ($filterMatches as $f) {
                    $actions = $f->getActions();
                    foreach ($actions as $key => $value) {
                        $filterName = $f->getName();
                        if (!$coreClass = $value->lookupByType($value->type))
                            continue;

                        if ($description = $coreClass->getEventDescription($value, $filterName))
                            $postCreate->logEvent($description['type'], $description['desc'], $username);

                    }
                    if ($f->stopOnMatch()) break;
                }
            }
        }
        catch (FilterDataChanged $ex) {
            // Don't pass user recursively, assume the user has changed
            return self::filterTicketData($origin, $ex->getData(), $forms);
        }
        return $vars;
    }

    /*
     * The mother of all functions...You break it you fix it!
     *
     *  $autorespond and $alertstaff overrides config settings...
     */
    static function create($vars, &$errors, $origin, $autorespond=true,
            $alertstaff=true) {
        global $ost, $cfg, $thisstaff;

        // Don't enforce form validation for email
        $field_filter = function($type) use ($origin) {
            return function($f) use ($origin, $type) {
                // Ultimately, only offer validation errors for web for
                // non-internal fields. For email, no validation can be
                // performed. For other origins, validate as usual
                switch (strtolower($origin)) {
                case 'email':
                    return false;
                case 'staff':
                    // Required 'Contact Information' fields aren't required
                    // when staff open tickets
                    return $f->isVisibleToStaff();
                case 'web':
                    return $f->isVisibleToUsers();
                default:
                    return true;
                }
            };
        };

        $reject_ticket = function($message) use (&$errors) {
            global $ost;
            $errors = array(
                'errno' => 403,
                'err' => __('This help desk is for use by authorized users only'));
            $ost->logWarning(_S('Ticket denied'), $message, false);
            return 0;
        };

        Signal::send('ticket.create.before', null, $vars);

        // Create and verify the dynamic form entry for the new ticket
        $form = TicketForm::getNewInstance();
        $form->setSource($vars);

        // If submitting via email or api, ensure we have a subject and such
        if (!in_array(strtolower($origin), array('web', 'staff'))) {
            foreach ($form->getFields() as $field) {
                $fname = $field->get('name');
                if ($fname && isset($vars[$fname]) && !$field->value)
                    $field->value = $field->parse($vars[$fname]);
            }
        }

        if ($vars['uid'])
            $user = User::lookup($vars['uid']);

        $id=0;
        $fields=array();
        switch (strtolower($origin)) {
            case 'web':
                $fields['topicId']  = array('type'=>'int',  'required'=>1, 'error'=>__('Select a Help Topic'));
                break;
            case 'staff':
                $fields['deptId']   = array('type'=>'int',  'required'=>0, 'error'=>__('Department selection is required'));
                $fields['topicId']  = array('type'=>'int',  'required'=>1, 'error'=>__('Help topic selection is required'));
                $fields['duedate']  = array('type'=>'date', 'required'=>0, 'error'=>__('Invalid date format - must be MM/DD/YY'));
            case 'api':
                $fields['source']   = array('type'=>'string', 'required'=>1, 'error'=>__('Indicate ticket source'));
                break;
            case 'email':
                $fields['emailId']  = array('type'=>'int',  'required'=>1, 'error'=>__('Unknown system email'));
                break;
            default:
                # TODO: Return error message
                $errors['err']=$errors['origin'] = __('Invalid ticket origin given');
        }

        if(!Validator::process($fields, $vars, $errors) && !$errors['err'])
            $errors['err'] = sprintf('%s — %s',
                __('Missing or invalid data'),
                __('Correct any errors below and try again'));

        // Make sure the due date is valid
        if ($vars['duedate']) {
            if (strtotime($vars['duedate']) === false)
                $errors['duedate']=__('Invalid due date');
            elseif (Misc::user2gmtime($vars['duedate']) <= Misc::user2gmtime())
                $errors['duedate']=__('Due date must be in the future');
        }

        $topic_forms = array();
        if (!$errors) {

            // Handle the forms associate with the help topics. Instanciate the
            // entries, disable and track the requested disabled fields.
            if ($vars['topicId']) {
                if ($__topic=Topic::lookup($vars['topicId'])) {
                    foreach ($__topic->getForms() as $idx=>$__F) {
                        $disabled = array();
                        foreach ($__F->getFields() as $field) {
                            if (!$field->isEnabled() && $field->hasFlag(DynamicFormField::FLAG_ENABLED))
                                $disabled[] = $field->get('id');
                        }
                        // Special handling for the ticket form — disable fields
                        // requested to be disabled as per the help topic.
                        if ($__F->get('type') == 'T') {
                            foreach ($form->getFields() as $field) {
                                if (false !== array_search($field->get('id'), $disabled))
                                    $field->disable();
                            }
                            $form->sort = $idx;
                            $__F = $form;
                        }
                        else {
                            $__F = $__F->instanciate($idx);
                            $__F->setSource($vars);
                            $topic_forms[] = $__F;
                        }
                        // Track fields currently disabled
                        $__F->extra = JsonDataEncoder::encode(array(
                            'disable' => $disabled
                        ));
                    }
                }
            }

            try {
                $vars = self::filterTicketData($origin, $vars,
                    array_merge(array($form), $topic_forms), $user, false);
            }
            catch (RejectedException $ex) {
                return $reject_ticket(
                    sprintf(_S('Ticket rejected (%s) by filter "%s"'),
                    $ex->vars['email'], $ex->getRejectingFilter()->getName())
                );
            }

            //Make sure the open ticket limit hasn't been reached. (LOOP CONTROL)
            if ($cfg->getMaxOpenTickets() > 0
                    && strcasecmp($origin, 'staff')
                    && ($_user=TicketUser::lookupByEmail($vars['email']))
                    && ($openTickets=$_user->getNumOpenTickets())
                    && ($openTickets>=$cfg->getMaxOpenTickets()) ) {

                $errors = array('err' => __("You've reached the maximum open tickets allowed."));
                $ost->logWarning(sprintf(_S('Ticket denied - %s'), $vars['email']),
                        sprintf(_S('Max open tickets (%1$d) reached for %2$s'),
                            $cfg->getMaxOpenTickets(), $vars['email']),
                        false);

                return 0;
            }

            // Allow vars to be changed in ticket filter and applied to the user
            // account created or detected
            if (!$user && $vars['email'])
                $user = User::lookupByEmail($vars['email']);

            if (!$user) {
                // Reject emails if not from registered clients (if
                // configured)
                if (strcasecmp($origin, 'email') === 0
                        && !$cfg->acceptUnregisteredEmail()) {
                    list($mailbox, $domain) = explode('@', $vars['email'], 2);
                    // Users not yet created but linked to an organization
                    // are still acceptable
                    if (!Organization::forDomain($domain)) {
                        return $reject_ticket(
                            sprintf(_S('Ticket rejected (%s) (unregistered client)'),
                                $vars['email']));
                    }
                }

                $user_form = UserForm::getUserForm()->getForm($vars);
                $can_create = !$thisstaff || $thisstaff->hasPerm(User::PERM_CREATE);
                if (!$user_form->isValid($field_filter('user'))
                    || !($user=User::fromVars($user_form->getClean(), $can_create))
                ) {
                    $errors['user'] = $can_create
                        ? __('Incomplete client information')
                        : __('You do not have permission to create users.');
                }
            }
        }

        if (!$form->isValid($field_filter('ticket')))
            $errors += $form->errors();

        if ($vars['topicId']) {
            if (is_numeric($vars['topicId'])
                    && ($topic=Topic::lookup((int) $vars['topicId']))
                    && $topic->isActive()) {
                foreach ($topic_forms as $topic_form) {
                    $TF = $topic_form->getForm($vars);
                    if (!$TF->isValid($field_filter('topic')))
                        $errors = array_merge($errors, $TF->errors());
                }
            } else  {
                $vars['topicId'] = 0;
            }
        }



        // Any errors above are fatal.
        if ($errors)
            return 0;

        Signal::send('ticket.create.validated', null, $vars);

        # Some things will need to be unpacked back into the scope of this
        # function
        if (isset($vars['autorespond']))
            $autorespond = $vars['autorespond'];

        # Apply filter-specific priority
        if ($vars['priorityId'])
            $form->setAnswer('priority', null, $vars['priorityId']);

        // If the filter specifies a help topic which has a form associated,
        // and there was previously either no help topic set or the help
        // topic did not have a form, there's no need to add it now as (1)
        // validation is closed, (2) there may be a form already associated
        // and filled out from the original  help topic, and (3) staff
        // members can always add more forms now

        // OK...just do it.
        $statusId = $vars['statusId'];
        $deptId = $vars['deptId']; //pre-selected Dept if any.
        $source = ucfirst($vars['source']);

        // Apply email settings for emailed tickets. Email settings should
        // trump help topic settins if the email has an associated help
        // topic
        if ($vars['emailId'] && ($email=Email::lookup($vars['emailId']))) {
            $deptId = $deptId ?: $email->getDeptId();
            $dept = Dept::lookup($deptId);
            if ($dept && !$dept->isActive())
                $deptId = $cfg->getDefaultDeptId();
            $priority = $form->getAnswer('priority');
            if (!$priority || !$priority->getIdValue())
                $form->setAnswer('priority', null, $email->getPriorityId());
            if ($autorespond)
                $autorespond = $email->autoRespond();
            if (!isset($topic)
                    && ($T = $email->getTopic())
                    && ($T->isActive())) {
                $topic = $T;
            }
            $email = null;
            $source = 'Email';
        }

        if (!isset($topic)) {
            // This may return NULL, no big deal
            $topic = $cfg->getDefaultTopic();
        }

        // Intenal mapping magic...see if we need to override anything
        if (isset($topic)) {
            $deptId = $deptId ?: $topic->getDeptId();
            $statusId = $statusId ?: $topic->getStatusId();
            $priority = $form->getAnswer('priority');
            if (!$priority || !$priority->getIdValue())
                $form->setAnswer('priority', null, $topic->getPriorityId());
            if ($autorespond)
                $autorespond = $topic->autoRespond();

            //Auto assignment.
            if (!isset($vars['staffId']) && $topic->getStaffId())
                $vars['staffId'] = $topic->getStaffId();
            elseif (!isset($vars['teamId']) && $topic->getTeamId())
                $vars['teamId'] = $topic->getTeamId();

            // Unset slaId if 0 to use the Help Topic SLA or Default SLA
            if ($vars['slaId'] == 0)
                unset($vars['slaId']);

            //set default sla.
            if (isset($vars['slaId']))
                $vars['slaId'] = $vars['slaId'] ?: $cfg->getDefaultSLAId();
            elseif ($topic && $topic->getSLAId())
                $vars['slaId'] = $topic->getSLAId();
        }

        // Auto assignment to organization account manager
        if (($org = $user->getOrganization())
                && $org->autoAssignAccountManager()
                && ($code = $org->getAccountManagerId())) {
            if (!isset($vars['staffId']) && $code[0] == 's')
                $vars['staffId'] = substr($code, 1);
            elseif (!isset($vars['teamId']) && $code[0] == 't')
                $vars['teamId'] = substr($code, 1);
        }

        // Last minute checks
        $priority = $form->getAnswer('priority');
        if (!$priority || !$priority->getIdValue())
            $form->setAnswer('priority', null, $cfg->getDefaultPriorityId());
        $deptId = $deptId ?: $cfg->getDefaultDeptId();
        $statusId = $statusId ?: $cfg->getDefaultTicketStatusId();
        $topicId = isset($topic) ? $topic->getId() : 0;
        $ipaddress = $vars['ip'] ?: $_SERVER['REMOTE_ADDR'];
        $source = $source ?: 'Web';

        //We are ready son...hold on to the rails.
        $number = $topic ? $topic->getNewTicketNumber() : $cfg->getNewTicketNumber();
        $ticket = new static(array(
            'created' => SqlFunction::NOW(),
            'lastupdate' => SqlFunction::NOW(),
            'number' => $number,
            'user' => $user,
            'dept_id' => $deptId,
            'topic_id' => $topicId,
            'ip_address' => $ipaddress,
            'source' => $source,
        ));

        if (isset($vars['emailId']) && $vars['emailId'])
            $ticket->email_id = $vars['emailId'];

        //Make sure the origin is staff - avoid firebug hack!
        if ($vars['duedate'] && !strcasecmp($origin,'staff'))
            $ticket->duedate = date('Y-m-d G:i',
                Misc::dbtime($vars['duedate']));


        if (!$ticket->save())
            return null;
        if (!($thread = TicketThread::create($ticket->getId())))
            return null;

        /* -------------------- POST CREATE ------------------------ */

        // Save the (common) dynamic form
        // Ensure we have a subject
        $subject = $form->getAnswer('subject');
        if ($subject && !$subject->getValue() && $topic)
            $subject->setValue($topic->getFullName());

        $form->setTicketId($ticket->getId());
        $form->save();

        // Save the form data from the help-topic form, if any
        foreach ($topic_forms as $topic_form) {
            $topic_form->setTicketId($ticket->getId());
            $topic_form->save();
        }

        $ticket->loadDynamicData(true);

        $dept = $ticket->getDept();

        // Start tracking ticket lifecycle events (created should come first!)
        $ticket->logEvent('created', null, $thisstaff ?: $user);

        // Set default ticket status (if none) for Thread::getObject()
        // in addCollaborators()
        if ($ticket->getStatusId() <= 0)
            $ticket->setStatusId($cfg->getDefaultTicketStatusId());

        // Add collaborators (if any)
        if (isset($vars['ccs']) && count($vars['ccs']))
          $ticket->addCollaborators($vars['ccs'], array(), $errors);

        // Add organizational collaborators
        if ($org && $org->autoAddCollabs()) {
            $pris = $org->autoAddPrimaryContactsAsCollabs();
            $members = $org->autoAddMembersAsCollabs();
            $settings = array('isactive' => true);
            $collabs = array();
            foreach ($org->allMembers() as $u) {
                $_errors = array();
                if ($members || ($pris && $u->isPrimaryContact())) {
                    if ($c = $ticket->addCollaborator($u, $settings, $_errors)) {
                        $collabs[] = (string) $c;
                    }
                }
            }
            //TODO: Can collaborators add others?
            if ($collabs) {
                $ticket->logEvent('collab', array('org' => $org->getId()));
            }
        }

        //post the message.
        $vars['title'] = $vars['subject']; //Use the initial subject as title of the post.
        $vars['userId'] = $ticket->getUserId();
        $message = $ticket->postMessage($vars , $origin, false);

        $vars['ticket'] = $ticket;
        self::filterTicketData($origin, $vars,
            array_merge(array($form), $topic_forms), $user, $ticket);

        // If a message was posted, flag it as the orignal message. This
        // needs to be done on new ticket, so as to otherwise separate the
        // concept from the first message entry in a thread.
        if ($message instanceof ThreadEntry) {
            $message->setFlag(ThreadEntry::FLAG_ORIGINAL_MESSAGE);
            $message->save();
        }

        //check to see if ticket was created from a thread
        if ($_SESSION[':form-data']['ticketId'] || $_SESSION[':form-data']['taskId']) {
          $oldTicket = Ticket::lookup($_SESSION[':form-data']['ticketId']);
          $oldTask = Task::lookup($_SESSION[':form-data']['taskId']);

          //add internal note to new ticket.
          //New ticket should have link to old task/ticket:
          $link = sprintf('<a href="%s.php?id=%d"><b>#%s</b></a>',
              $oldTicket ? 'tickets' : 'tasks',
              $oldTicket ? $oldTicket->getId() : $oldTask->getId(),
              $oldTicket ? $oldTicket->getNumber() : $oldTask->getNumber());

          $note = array(
                  'title' => __('Ticket Created From Thread Entry'),
                  'body' => sprintf(__(
                        // %1$s is the word Ticket or Task, %2$s will be a link to it
                        'This Ticket was created from %1$s %2$s'),
                        $oldTicket ? __('Ticket') : __('Task'), $link)
                  );

          $ticket->logNote($note['title'], $note['body'], $thisstaff);

          //add internal note to referenced ticket/task
          // Old ticket/task should have link to new ticket
          $ticketLink = sprintf('<a href="tickets.php?id=%d"><b>#%s</b></a>',
              $ticket->getId(),
              $ticket->getNumber());

          $entryLink = sprintf('<a href="#entry-%d"><b>%s</b></a>',
              $_SESSION[':form-data']['eid'],
              Format::datetime($_SESSION[':form-data']['timestamp']));

          $ticketNote = array(
              'title' => __('Ticket Created From Thread Entry'),
              'body' => sprintf(__('Ticket %1$s<br/> Thread Entry: %2$s'),
                $ticketLink, $entryLink)
          );

          $taskNote = array(
              'title' => __('Ticket Created From Thread Entry'),
              'note' => sprintf(__('Ticket %1$s<br/> Thread Entry: %2$s'),
                $ticketLink, $entryLink)
          );

          if ($oldTicket)
            $oldTicket->logNote($ticketNote['title'], $ticketNote['body'], $thisstaff);
          elseif ($oldTask)
            $oldTask->postNote($taskNote, $errors, $thisstaff);
        }

        // Configure service-level-agreement for this ticket
        $ticket->selectSLAId($vars['slaId']);

        // Set status
        $status = TicketStatus::lookup($statusId);
        if (!$status || !$ticket->setStatus($status, false, $errors,
                    !strcasecmp($origin, 'staff'))) {
            // Tickets _must_ have a status. Forceably set one here
            $ticket->setStatusId($cfg->getDefaultTicketStatusId());
        }

        // Only do assignment if the ticket is in an open state
        if ($ticket->isOpen()) {
            // Assign ticket to staff or team (new ticket by staff)
            if ($vars['assignId']) {
                $asnform = $ticket->getAssignmentForm(array(
                            'assignee' => $vars['assignId'],
                            'comments' => $vars['note'])
                        );
                $e = array();
                $ticket->assign($asnform, $e);
            }
            else {
                // Auto assign staff or team - auto assignment based on filter
                // rules. Both team and staff can be assigned
                $username = __('Ticket Filter');
                if ($vars['staffId'])
                     $ticket->assignToStaff($vars['staffId'], false, true, $username);
                if ($vars['teamId'])
                    // No team alert if also assigned to an individual agent
                    $ticket->assignToTeam($vars['teamId'], false, !$vars['staffId'], $username);
            }
        }

        // Update the estimated due date in the database
        $ticket->updateEstDueDate();

        /**********   double check auto-response  ************/
        //Override auto responder if the FROM email is one of the internal emails...loop control.
        if($autorespond && (Email::getIdByEmail($ticket->getEmail())))
            $autorespond=false;

        # Messages that are clearly auto-responses from email systems should
        # not have a return 'ping' message
        if (isset($vars['mailflags']) && $vars['mailflags']['bounce'])
            $autorespond = false;
        if ($autorespond && $message instanceof ThreadEntry && $message->isAutoReply())
            $autorespond = false;

        // Post canned auto-response IF any (disables new ticket auto-response).
        if ($vars['cannedResponseId']
            && $ticket->postCannedReply($vars['cannedResponseId'], $message, $autorespond)) {
                $ticket->markUnAnswered(); //Leave the ticket as unanswred.
                $autorespond = false;
        }


        if ($vars['system_emails'])
            $ticket->systemReferral($vars['system_emails']);

        // Check department's auto response settings
        // XXX: Dept. setting doesn't affect canned responses.
        if ($autorespond && $dept && !$dept->autoRespONNewTicket())
            $autorespond=false;

        // Don't send alerts to staff when the message is a bounce
        // this is necessary to avoid possible loop (especially on new ticket)
        if ($alertstaff && $message instanceof ThreadEntry && $message->isBounce())
            $alertstaff = false;

        /***** See if we need to send some alerts ****/
        $ticket->onNewTicket($message, $autorespond, $alertstaff);

        /************ check if the user JUST reached the max. open tickets limit **********/
        if ($cfg->getMaxOpenTickets()>0
            && ($user=$ticket->getOwner())
            && ($user->getNumOpenTickets()==$cfg->getMaxOpenTickets())
        ) {
            $ticket->onOpenLimit($autorespond && strcasecmp($origin, 'staff'));
        }

        // Fire post-create signal (for extra email sending, searching)
        Signal::send('ticket.created', $ticket);

        /* Phew! ... time for tea (KETEPA) */

        return $ticket;
    }

    /* routine used by staff to open a new ticket */
    static function open($vars, &$errors) {
        global $thisstaff, $cfg;

        if (!$thisstaff)
            return false;

        if ($vars['deptId']
            && ($dept=Dept::lookup($vars['deptId']))
            && ($role = $thisstaff->getRole($dept))
            && !$role->hasPerm(Ticket::PERM_CREATE)
        ) {
            $errors['err'] = sprintf(__('You do not have permission to create a ticket in %s'), __('this department'));
            return false;
        }

        if (isset($vars['source']) // Check ticket source if provided
                && !array_key_exists($vars['source'], Ticket::getSources()))
            $errors['source'] = sprintf( __('Invalid source given - %s'),
                    Format::htmlchars($vars['source']));


        if (!$vars['uid']) {
            // Special validation required here
            if (!$vars['email'] || !Validator::is_email($vars['email']))
                $errors['email'] = __('Valid email address is required');

            if (!$vars['name'])
                $errors['name'] = __('Name is required');
        }

        // Ensure agent has rights to make assignment in the cited
        // department
        if ($vars['assignId'] && !(
            $role
            ? ($role->hasPerm(Ticket::PERM_ASSIGN) || $role->__new__)
            : $thisstaff->hasPerm(Ticket::PERM_ASSIGN, false)
        )) {
            $errors['assignId'] = __('Action Denied. You are not allowed to assign/reassign tickets.');
        }

        // TODO: Deny action based on selected department.
        $vars['response'] = ThreadEntryBody::clean($vars['response']);
        $vars['note'] = ThreadEntryBody::clean($vars['note']);
        $create_vars = $vars;
        $tform = TicketForm::objects()->one()->getForm($create_vars);
        $mfield = $tform->getField('message');
        $create_vars['message'] = $mfield->getClean();
        $create_vars['files'] = $mfield->getWidget()->getAttachments()->getFiles();

        if (!($ticket=self::create($create_vars, $errors, 'staff', false)))
            return false;

        $vars['msgId']=$ticket->getLastMsgId();

        // Effective role for the department
        $role = $ticket->getRole($thisstaff);

        $alert = strcasecmp('none', $vars['reply-to']);
        // post response - if any
        $response = null;
        if ($vars['response'] && $role->hasPerm(Ticket::PERM_REPLY)) {
            $vars['response'] = $ticket->replaceVars($vars['response']);
            // $vars['cannedatachments'] contains the attachments placed on
            // the response form.
            $response = $ticket->postReply($vars, $errors, ($alert &&
                        !$cfg->notifyONNewStaffTicket()));
        }

        // Not assigned...save optional note if any
        if (!$vars['assignId'] && $vars['note']) {
            if (!$cfg->isRichTextEnabled())
                $vars['note'] = new TextThreadEntryBody($vars['note']);
            $ticket->logNote(_S('New Ticket'), $vars['note'], $thisstaff, false);
        }

        if (!$cfg->notifyONNewStaffTicket()
            || !$alert
            || !($dept=$ticket->getDept())
        ) {
            return $ticket; //No alerts.
        }

        // Notice Recipients
        $recipients = $ticket->getRecipients($vars['reply-to']);

        // Send Notice to user --- if requested AND enabled!!
        if (($tpl=$dept->getTemplate())
            && ($msg=$tpl->getNewTicketNoticeMsgTemplate())
            && ($email=$dept->getEmail())
        ) {
           $attachments = array();
           $message = $ticket->getLastMessage();
           if ($cfg->emailAttachments()) {
               if ($message && $message->getNumAttachments()) {
                 foreach ($message->getAttachments() as $attachment)
                     $attachments[] = $attachment;
               }
               if ($response && $response->getNumAttachments()) {
                 foreach ($response->getAttachments() as $attachment)
                     $attachments[] = $attachment;
               }
           }

            if ($vars['signature']=='mine')
                $signature=$thisstaff->getSignature();
            elseif ($vars['signature']=='dept' && $dept && $dept->isPublic())
                $signature=$dept->getSignature();
            else
                $signature='';

            $msg = $ticket->replaceVars($msg->asArray(),
                array(
                    'message'   => $message ?: '',
                    'response'  => $response ?: '',
                    'signature' => $signature,
                    'recipient' => $ticket->getOwner(), //End user
                    'staff'     => $thisstaff,
                )
            );
            $message = $ticket->getLastMessage();
            $options = array(
                'thread' => $message ?: $ticket->getThread(),
            );

            //ticket created on user's behalf
            $email->send($recipients, $msg['subj'], $msg['body'], $attachments,
                $options);
        }
        return $ticket;
    }

    static function checkOverdue() {
        $overdue = static::objects()
            ->filter(array(
                'isoverdue' => 0,
                'status__state' => 'open',
                Q::any(array(
                    Q::all(array(
                        'duedate__isnull' => true,
                        'est_duedate__isnull' => false,
                        'est_duedate__lt' => SqlFunction::NOW())
                        ),
                    Q::all(array(
                        'duedate__isnull' => false,
                        'duedate__lt' => SqlFunction::NOW())
                        )
                    ))
                ))
            ->limit(100);

        foreach ($overdue as $ticket)
            $ticket->markOverdue();

    }

    static function agentActions($agent, $options=array()) {
        if (!$agent)
            return;

        require STAFFINC_DIR.'templates/tickets-actions.tmpl.php';
    }

    static function getLink($id) {
        global $thisstaff;

        switch (true) {
        case ($thisstaff instanceof Staff):
            return ROOT_PATH . sprintf('scp/tickets.php?id=%s', $id);
        }
    }

    static function getPermissions() {
        return self::$perms;
    }

    static function getSources() {
        static $translated = false;
        if (!$translated) {
            foreach (static::$sources as $k=>$v)
                static::$sources[$k] = __($v);
        }

        return static::$sources;
    }

    // TODO: Create internal Form for internal fields
    static function duedateField($name, $default='', $hint='') {
        return DateTimeField::init(array(
            'id' => $name,
            'name' => $name,
            'default' => $default ?: false,
            'label' => __('Due Date'),
            'hint' => $hint,
            'configuration' => array(
                'min' => Misc::gmtime(),
                'time' => true,
                'gmt' => false,
                'future' => true,
                )
            ));
    }

    static function registerCustomData(DynamicForm $form) {
        if (!isset(static::$meta['joins']['cdata+'.$form->id])) {
            $cdata_class = <<<EOF
class DynamicForm{$form->id} extends DynamicForm {
    static function getInstance() {
        static \$instance;
        if (!isset(\$instance))
            \$instance = static::lookup({$form->id});
        return \$instance;
    }
}
class TicketCdataForm{$form->id}
extends VerySimpleModel {
    static \$meta = array(
        'view' => true,
        'pk' => array('ticket_id'),
        'joins' => array(
            'ticket' => array(
                'constraint' => array('ticket_id' => 'Ticket.ticket_id'),
            ),
        )
    );
    static function getQuery(\$compiler) {
        return '('.DynamicForm{$form->id}::getCrossTabQuery('T', 'ticket_id').')';
    }
}
EOF;
            eval($cdata_class);
            $join = array(
                'constraint' => array('ticket_id' => 'TicketCdataForm'.$form->id.'.ticket_id'),
                'list' => true,
            );
            // This may be necessary if the model has already been inspected
            if (static::$meta instanceof ModelMeta)
                static::$meta->addJoin('cdata+'.$form->id, $join);
            else {
                static::$meta['joins']['cdata+'.$form->id] = array(
                    'constraint' => array('ticket_id' => 'TicketCdataForm'.$form->id.'.ticket_id'),
                    'list' => true,
                );
            }
        }
    }
}
RolePermission::register(/* @trans */ 'Tickets', Ticket::getPermissions(), true);

class TicketCData extends VerySimpleModel {
    static $meta = array(
        'pk' => array('ticket_id'),
        'joins' => array(
            'ticket' => array(
                'constraint' => array('ticket_id' => 'Ticket.ticket_id'),
            ),
            ':priority' => array(
                'constraint' => array('priority' => 'Priority.priority_id'),
                'null' => true,
            ),
        ),
    );
}
TicketCData::$meta['table'] = TABLE_PREFIX . 'ticket__cdata';

class.category.php:
<?php
/*********************************************************************
    class.category.php

    Backend support for article categories.

    Copyright (c)  2006-2013 osTicket
    http://www.osticket.com

    Released under the GNU General Public License WITHOUT ANY WARRANTY.
    See LICENSE.TXT for details.

    vim: expandtab sw=4 ts=4 sts=4:
**********************************************************************/
require_once INCLUDE_DIR . 'class.faq.php';

class Category extends VerySimpleModel {

    static $meta = array(
        'table' => FAQ_CATEGORY_TABLE,
        'pk' => array('category_id'),
        'ordering' => array('name'),
        'joins' => array(
            'parent' => array(
                'constraint' => array('category_pid' => 'Category.category_id'),
                'null' => true,
                ),
            'children' => array(
                'reverse' => 'Category.parent',
                ),
            'faqs' => array(
                'reverse' => 'FAQ.category'
                ),
        ),
    );

    const VISIBILITY_FEATURED = 2;
    const VISIBILITY_PUBLIC = 1;
    const VISIBILITY_PRIVATE = 0;

    var $_local;

    /* ------------------> Getter methods <--------------------- */
    function getId() { return $this->category_id; }
    function getName() { return $this->name; }
    function getFullName() {
        return self::getNameById($this->category_id) ?: $this->getLocalName();
    }
    function getNumFAQs($primary=false) {
        $count = $this->faqs->count();
        if (!$primary && $this->children)
            foreach ($this->children as $c)
                $count += $c->faqs->count();

        return $count;
    }
    function getDescription() { return $this->description; }
    function getDescriptionWithImages() {
        return Format::viewableImages($this->description);
    }
    function getNotes() { return $this->notes; }
    function getCreateDate() { return $this->created; }
    function getUpdateDate() { return $this->updated; }

    function isPublic() {
        return $this->ispublic != self::VISIBILITY_PRIVATE;
    }
    function getVisibilityDescription() {
        switch ($this->ispublic) {
        case self::VISIBILITY_PRIVATE:
            return __('Private');
        case self::VISIBILITY_PUBLIC:
            return __('Public');
        case self::VISIBILITY_FEATURED:
            return __('Featured');
        }
    }
    function getHashtable() { return $this->ht; }

    // Translation interface ----------------------------------
    function getTranslateTag($subtag) {
        return _H(sprintf('category.%s.%s', $subtag, $this->getId()));
    }
    function getLocal($subtag) {
        $tag = $this->getTranslateTag($subtag);
        $T = CustomDataTranslation::translate($tag);
        return $T != $tag ? $T : $this->ht[$subtag];
    }
    function getLocalDescriptionWithImages($lang=false) {
        return Format::viewableImages($this->_getLocal('description', $lang));
    }
    function getLocalName($lang=false) {
        return $this->_getLocal('name', $lang);
    }
    function _getLocal($what, $lang=false) {
        if (!$lang) {
            $lang = $this->getDisplayLang();
        }
        $translations = $this->getAllTranslations();
        foreach ($translations as $t) {
            if (0 === strcasecmp($lang, $t->lang)) {
                $data = $t->getComplex();
                if (isset($data[$what]))
                    return $data[$what];
            }
        }
        return $this->ht[$what];
    }
    function getAllTranslations() {
        if (!isset($this->_local)) {
            $tag = $this->getTranslateTag('c:d');
            $this->_local = CustomDataTranslation::allTranslations($tag, 'article');
        }
        return $this->_local;
    }
    function getDisplayLang() {
        if (isset($_REQUEST['kblang']))
            $lang = $_REQUEST['kblang'];
        else
            $lang = Internationalization::getCurrentLanguage();
        return $lang;
    }

    function getTopArticles() {
        return $this->faqs
            ->filter(Q::not(array('ispublished'=>0)))
            ->order_by('-ispublished')
            ->limit(5);
    }

    function getPublicSubCategories() {
        return $this->getSubCategories(array('public' => true));
    }

    function getSubCategories($criteria=array()) {

        $categories = self::objects()
            ->filter(array('category_pid' => $this->getId()));
        if (isset($criteria['public']) && $categories) {
            $categories
                ->exclude(
                    Q::any(array(
                            'ispublic'=>Category::VISIBILITY_PRIVATE,
                            'faqs__ispublished'=>FAQ::VISIBILITY_PRIVATE,
                            )))
                ->annotate(array('faq_count' => SqlAggregate::COUNT(
                                SqlCase::N()
                                ->when(array(
                                        'faqs__ispublished__gt'=> FAQ::VISIBILITY_PRIVATE), 1)
                                ->otherwise(null)
                )))
                ->filter(array('faq_count__gt'=>0));
        } else {
            $categories
                ->annotate(array(
                            'faq_count'=>SqlAggregate::COUNT('faqs')));
        }

        return $categories;
    }

    /* ------------------> Setter methods <--------------------- */
    function setName($name) { $this->name=$name; }
    function setNotes($notes) { $this->notes=$notes; }
    function setDescription($desc) { $this->description=$desc; }

    /* --------------> Database access methods <---------------- */
    function update($vars, &$errors, $validation=false) {

        // Cleanup.
        $vars['name'] = Format::striptags(trim($vars['name']));

        // Validate
        if ($vars['id'] && $this->getId() != $vars['id'])
            $errors['err'] = __('Internal error occurred');

        if (!$vars['name'])
            $errors['name'] = __('Category name is required');
        elseif (strlen($vars['name']) < 3)
            $errors['name'] = __('Name is too short. 3 chars minimum');
        elseif (($cid=self::findIdByName($vars['name'], $vars['pid'])) && $cid != $vars['id'])
            $errors['name'] = __('Category already exists');

        if (!$vars['description'])
            $errors['description'] = __('Category description is required');

        if ($errors)
            return false;

        /* validation only */
        if ($validation)
            return true;

        $this->ispublic = $vars['ispublic'];
        $this->name = $vars['name'];
        $this->category_pid = $vars['pid'] ?: 0;
        $this->description = Format::sanitize($vars['description']);
        $this->notes = Format::sanitize($vars['notes']);

        if (!$this->save())
            return false;

        if (isset($vars['trans']) && !$this->saveTranslations($vars))
            return false;

        // TODO: Move FAQs if requested.

        return true;
    }

    function delete() {
        try {
            parent::delete();
        }
        catch (OrmException $e) {
            return false;
        }
        $type = array('type' => 'deleted');
        Signal::send('object.deleted', $this, $type);

        return true;
    }

    function save($refetch=false) {
        if ($this->dirty)
            $this->updated = SqlFunction::NOW();
        return parent::save($refetch || $this->dirty);
    }

    function saveTranslations($vars) {
        global $thisstaff;

        foreach ($this->getAllTranslations() as $t) {
            $trans = @$vars['trans'][$t->lang];
            if (!$trans || !array_filter($trans))
                // Not updating translations
                continue;

            // Content is not new and shouldn't be added below
            unset($vars['trans'][$t->lang]);
            $content = array('name' => $trans['name'],
                'description' => Format::sanitize($trans['description']));

            // Don't update content which wasn't updated
            if ($content == $t->getComplex())
                continue;

            $t->text = $content;
            $t->agent_id = $thisstaff->getId();
            $t->updated = SqlFunction::NOW();
            if (!$t->save())
                return false;
        }
        // New translations (?)
        $tag = $this->getTranslateTag('c:d');
        foreach ($vars['trans'] as $lang=>$parts) {
            $content = array('name' => @$parts['name'],
                'description' => Format::sanitize(@$parts['description']));
            if (!array_filter($content))
                continue;
            $t = CustomDataTranslation::create(array(
                'type'      => 'article',
                'object_hash' => $tag,
                'lang'      => $lang,
                'text'      => $content,
                'revision'  => 1,
                'agent_id'  => $thisstaff->getId(),
                'updated'   => SqlFunction::NOW(),
            ));
            if (!$t->save())
                return false;
        }
        return true;
    }


    /* ------------------> Static methods <--------------------- */

    static function findIdByName($name, $pid=null) {
        $row = self::objects()->filter(array(
            'name'=>$name,
            'category_pid'  => $pid ?: null
        ))->values_flat('category_id')->first();

        return ($row) ? $row[0] : null;
    }

    static function findByName($name, $pid=null) {
        return self::objects()->filter(array(
            'name'=>$name,
            'category_pid'  => $pid ?: null
        ))->one();
    }

    static function getNameById($id) {
        $names = static::getCategories();
        return $names[$id] ?: '';
    }

    static function getFeatured() {
        return self::objects()->filter(array(
            'ispublic'=>self::VISIBILITY_FEATURED
        ));
    }

    static function getCategories($criteria=null, $localize=true) {
        static $categories = null;

        if (!isset($categories) || $criteria) {
            $categories = array();
            $query = self::objects();
            $query->order_by('name')
                ->values('category_id', 'category_pid', 'name', 'parent');

            foreach ($query as $row)
                $categories[$row['category_id']] = $row;

            // Resolve parent names
            $names = array();
            foreach ($categories as $id=>$info) {
                $name = $info['name'];
                $loop = array($id=>true);
                $parent = false;
                while ($info['category_pid'] && ($info = $categories[$info['category_pid']])) {
                    $name = sprintf('%s / %s', $info['name'], $name);
                    if (isset($loop[$info['category_pid']]))
                        break;
                    $loop[$info['category_pid']] = true;
                    $parent = $info;
                }
                // TODO: localize category names
                $names[$id] = $name;
            }
            asort($names);

            if ($criteria)
                return $names;

            $categories = $names;
        }

        return $categories;
    }

    static function create($vars=false) {
        $category = new static($vars);
        $category->created = SqlFunction::NOW();
        return $category;
    }
}
?>

class.canned.php:
<?php
/*********************************************************************
    class.canned.php

    Canned Responses AKA Premade replies

    Peter Rotich <peter@osticket.com>
    Copyright (c)  2006-2013 osTicket
    http://www.osticket.com

    Released under the GNU General Public License WITHOUT ANY WARRANTY.
    See LICENSE.TXT for details.

    vim: expandtab sw=4 ts=4 sts=4:
**********************************************************************/
include_once(INCLUDE_DIR.'class.file.php');

class Canned
extends VerySimpleModel {
    static $meta = array(
        'table' => CANNED_TABLE,
        'pk' => array('canned_id'),
        'joins' => array(
            'dept' => array(
                'constraint' => array('dept_id' => 'Dept.id'),
                'null' => true,
            ),
            'attachments' => array(
                'constraint' => array(
                    "'C'" => 'Attachment.type',
                    'canned_id' => 'Attachment.object_id',
                ),
                'list' => true,
                'null' => true,
                'broker' => 'GenericAttachments',
            ),
        ),
    );

    const PERM_MANAGE = 'canned.manage';

    static protected $perms = array(
            self::PERM_MANAGE => array(
                'title' =>
                /* @trans */ 'Premade',
                'desc'  =>
                /* @trans */ 'Ability to add/update/disable/delete canned responses')
    );

    static function getPermissions() {
        return self::$perms;
    }

    function getId(){
        return $this->canned_id;
    }

    function isEnabled() {
         return $this->isenabled;
    }

    function isActive(){
        return $this->isEnabled();
    }

    function getFilters() {

        if (!isset($this->_filters)) {
            $this->_filters = array();
            $cid = sprintf('"canned_id":%d', $this->getId());
            $sql='SELECT filter.id, filter.name '
                .' FROM '.FILTER_TABLE.' filter'
                .' INNER JOIN '.FILTER_ACTION_TABLE.' action'
                .'  ON (filter.id=action.filter_id)'
                .' WHERE action.type="canned"'
                ."  AND action.configuration LIKE '%$cid%'";

            if (($res=db_query($sql)) && db_num_rows($res))
                while (list($id, $name) = db_fetch_row($res))
                    $this->_filters[$id] = $name;
        }

        return $this->_filters;
    }

    function getAttachedFiles($inlines=false) {
        return AttachmentFile::objects()
            ->filter(array(
                'attachments__type'=>'C',
                'attachments__object_id'=>$this->getId(),
                'attachments__inline' => $inlines,
            ));
    }

    function getNumFilters() {
        return count($this->getFilters());
    }

    function getTitle() {
        return $this->title;
    }

    function getResponse() {
        return $this->response;
    }
    function getResponseWithImages() {
        return Format::viewableImages($this->getResponse());
    }

    function getReply() {
        return $this->getResponse();
    }

    function getHtml() {
        return $this->getFormattedResponse('html');
    }

    function getPlainText() {
        return $this->getFormattedResponse('text.plain');
    }

    function getFormattedResponse($format='text', $cb=null) {

        $resp = array();
        $html = true;
        switch($format) {
            case 'json.plain':
                $html = false;
                // fall-through
            case 'json':
                $resp['id'] = $this->getId();
                $resp['title'] = $this->getTitle();
                $resp['response'] = $this->getResponseWithImages();

                // Callback to strip or replace variables!
                if ($cb && is_callable($cb))
                    $resp = $cb($resp);

                $resp['files'] = array();
                foreach ($this->getAttachedFiles(!$html) as $file) {
                    $_SESSION[':cannedFiles'][$file->id] = $file->name;
                    $resp['files'][] = array(
                        'id' => $file->id,
                        'name' => $file->name,
                        'size' => $file->size,
                        'download_url' => $file->getDownloadUrl(),
                    );
                }
                // strip html
                if (!$html) {
                    $resp['response'] = Format::html2text($resp['response'], 90);
                }

                return Format::json_encode($resp);
                break;
            case 'html':
            case 'text.html':
                $response = $this->getResponseWithImages();
                break;
            case 'text.plain':
                $html = false;
            case 'text':
            default:
                $response = $this->getResponse();
                if (!$html)
                    $response = Format::html2text($response, 90);
                break;
        }

        // Callback to strip or replace variables!
        if ($response && $cb && is_callable($cb))
            $response = $cb($response);

        return $response;
    }

    function getNotes() {
        return $this->ht['notes'];
    }

    function getDeptId(){
        return $this->ht['dept_id'];
    }

    function getHashtable() {
        $base = $this->ht;
        unset($base['attachments']);
        return $base;
    }

    function getInfo() {
        return $this->getHashtable();
    }

    function getNumAttachments() {
        return $this->attachments->count();
    }

    function delete(){
        if ($this->getNumFilters() > 0)
            return false;

        if (!parent::delete())
            return false;

        $type = array('type' => 'deleted');
        Signal::send('object.deleted', $this, $type);

        $this->attachments->deleteAll();

        return true;
    }

    /*** Static functions ***/

    static function create($vars=false) {
        $faq = new static($vars);
        $faq->created = SqlFunction::NOW();
        return $faq;
    }

    static function getIdByTitle($title) {
        $row = static::objects()
            ->filter(array('title' => $title))
            ->values_flat('canned_id')
            ->first();

        return $row ? $row[0] : null;
    }

    static function getCannedResponses($deptId=0, $explicit=false) {
        global $thisstaff;

        $canned = static::objects()
            ->filter(array('isenabled' => true))
            ->order_by('title')
            ->values_flat('canned_id', 'title');

        if ($thisstaff) {
            $staffDepts = array();

            $staffDepts = $thisstaff->getDepts();
            $staffDepts[] = 0;

            $canned->filter(array('dept_id__in' => $staffDepts));
        }

        if ($deptId) {
            $depts = array($deptId);
            if (!$explicit)
                $depts[] = 0;
            $canned->filter(array('dept_id__in' => $depts));
        }

        $responses = array();
        foreach ($canned as $row) {
            list($id, $title) = $row;
            $responses[$id] = $title;
        }

        return $responses;
    }

    static function responsesByDeptId($deptId, $explicit=false) {
        return self::getCannedResponses($deptId, $explicit);
    }

    function save($refetch=false) {
        if ($this->dirty || $refetch)
            $this->updated = SqlFunction::NOW();
        return parent::save($refetch || $this->dirty);
    }

    function update($vars,&$errors) {
        global $cfg;

        $vars['title'] = Format::striptags(trim($vars['title']));

        $id = isset($this->canned_id) ? $this->canned_id : null;
        if ($id && $id != $vars['id'])
            $errors['err']=sprintf('%s - %s', __('Internal error occurred'), __('Please try again!'));

        if (!$vars['title'])
            $errors['title'] = __('Title required');
        elseif (strlen($vars['title']) < 3)
            $errors['title'] = __('Title is too short. 3 chars minimum');
        elseif (($cid=self::getIdByTitle($vars['title'])) && $cid!=$id)
            $errors['title'] = __('Title already exists');

        if (!$vars['response'])
            $errors['response'] = __('Response text is required');

        if ($errors)
            return false;

        $this->dept_id = $vars['dept_id'] ?: 0;
        $this->isenabled = $vars['isenabled'];
        $this->title = $vars['title'];
        $this->response = Format::sanitize($vars['response']);
        $this->notes = Format::sanitize($vars['notes']);

        $isnew = !isset($id);
        if ($this->save())
            return true;

        if ($isnew)
            $errors['err'] = sprintf(__('Unable to update %s.'), __('this canned response'));
        else
            $errors['err']=sprintf(__('Unable to create %s.'), __('this canned response'))
               .' '.__('Internal error occurred');

        return true;
    }
}
RolePermission::register( /* @trans */ 'Knowledgebase', Canned::getPermissions());

?>

class.translation.php
<?php
/*********************************************************************
    class.gettext.php

    This implements a `Translation` class that is loosely based on the PHP
    gettext pure-php module. It includes some code from the project and some
    code which is based in part at least on the PHP gettext project.

    This extension to the PHP gettext extension using a specially crafted MO
    file which is a PHP hash array. The file can be built using a utility
    method in this class.

    Jared Hancock <<PRESIDIO_ANONYMIZED_EMAIL_ADDRESS>>
    Copyright (c)  2006-2014 osTicket
    http://www.osticket.com

    PHP gettext extension is copyrighted separately:
    ---------------
    Copyright (c) 2003, 2009 Danilo Segan <<PRESIDIO_ANONYMIZED_EMAIL_ADDRESS>>.
    Copyright (c) 2005 Nico Kaiser <<PRESIDIO_ANONYMIZED_EMAIL_ADDRESS>>

    This file is part of PHP-gettext.

    PHP-gettext is free software; you can redistribute it and/or modify
    it under the terms of the GNU General Public License as published by
    the Free Software Foundation; either version 2 of the License, or
    (at your option) any later version.

    PHP-gettext is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    GNU General Public License for more details.

    You should have received a copy of the GNU General Public License
    along with PHP-gettext; if not, write to the Free Software
    Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
    ---------------

    Released under the GNU General Public License WITHOUT ANY WARRANTY.
    See LICENSE.TXT for details.

    vim: expandtab sw=4 ts=4 sts=4:
**********************************************************************/

/**
 * Provides a simple gettext replacement that works independently from
 * the system's gettext abilities.
 * It can read MO files and use them for translating strings.
 * The files are passed to gettext_reader as a Stream (see streams.php)
 *
 * This version has the ability to cache all strings and translations to
 * speed up the string lookup.
 * While the cache is enabled by default, it can be switched off with the
 * second parameter in the constructor (e.g. whenusing very large MO files
 * that you don't want to keep in memory)
 */
class gettext_reader {
  //public:
   var $error = 0; // public variable that holds error code (0 if no error)

   //private:
  var $BYTEORDER = 0;        // 0: low endian, 1: big endian
  var $STREAM = NULL;
  var $short_circuit = false;
  var $enable_cache = false;
  var $originals = NULL;      // offset of original table
  var $translations = NULL;    // offset of translation table
  var $pluralheader = NULL;    // cache header field for plural forms
  var $total = 0;          // total string count
  var $table_originals = NULL;  // table for original strings (offsets)
  var $table_translations = NULL;  // table for translated strings (offsets)
  var $cache_translations = NULL;  // original -> translation mapping


  /* Methods */


  /**
   * Reads a 32bit Integer from the Stream
   *
   * @access private
   * @return Integer from the Stream
   */
  function readint() {
      if ($this->BYTEORDER == 0) {
        // low endian
        $input=unpack('V', $this->STREAM->read(4));
        return array_shift($input);
      } else {
        // big endian
        $input=unpack('N', $this->STREAM->read(4));
        return array_shift($input);
      }
    }

  function read($bytes) {
    return $this->STREAM->read($bytes);
  }

  /**
   * Reads an array of Integers from the Stream
   *
   * @param int count How many elements should be read
   * @return Array of Integers
   */
  function readintarray($count) {
    if ($this->BYTEORDER == 0) {
        // low endian
        return unpack('V'.$count, $this->STREAM->read(4 * $count));
      } else {
        // big endian
        return unpack('N'.$count, $this->STREAM->read(4 * $count));
      }
  }

  /**
   * Constructor
   *
   * @param object Reader the StreamReader object
   * @param boolean enable_cache Enable or disable caching of strings (default on)
   */
  function __construct($Reader, $enable_cache = true) {
    // If there isn't a StreamReader, turn on short circuit mode.
    if (! $Reader || isset($Reader->error) ) {
      $this->short_circuit = true;
      return;
    }

    // Caching can be turned off
    $this->enable_cache = $enable_cache;

    $MAGIC1 = "\x95\x04\x12\xde";
    $MAGIC2 = "\xde\x12\x04\x95";

    $this->STREAM = $Reader;
    $magic = $this->read(4);
    if ($magic == $MAGIC1) {
      $this->BYTEORDER = 1;
    } elseif ($magic == $MAGIC2) {
      $this->BYTEORDER = 0;
    } else {
      $this->error = 1; // not MO file
      return false;
    }

    // FIXME: Do we care about revision? We should.
    $this->revision = $this->readint();

    $this->total = $this->readint();
    $this->originals = $this->readint();
    $this->translations = $this->readint();
  }

  /**
   * Loads the translation tables from the MO file into the cache
   * If caching is enabled, also loads all strings into a cache
   * to speed up translation lookups
   *
   * @access private
   */
  function load_tables() {
    if (is_array($this->cache_translations) &&
      is_array($this->table_originals) &&
      is_array($this->table_translations))
      return;

    /* get original and translations tables */
    if (!is_array($this->table_originals)) {
      $this->STREAM->seekto($this->originals);
      $this->table_originals = $this->readintarray($this->total * 2);
    }
    if (!is_array($this->table_translations)) {
      $this->STREAM->seekto($this->translations);
      $this->table_translations = $this->readintarray($this->total * 2);
    }

    if ($this->enable_cache) {
      $this->cache_translations = array ();
      /* read all strings in the cache */
      for ($i = 0; $i < $this->total; $i++) {
        $this->STREAM->seekto($this->table_originals[$i * 2 + 2]);
        $original = $this->STREAM->read($this->table_originals[$i * 2 + 1]);
        $this->STREAM->seekto($this->table_translations[$i * 2 + 2]);
        $translation = $this->STREAM->read($this->table_translations[$i * 2 + 1]);
        $this->cache_translations[$original] = $translation;
      }
    }
  }

  /**
   * Returns a string from the "originals" table
   *
   * @access private
   * @param int num Offset number of original string
   * @return string Requested string if found, otherwise ''
   */
  function get_original_string($num) {
    $length = $this->table_originals[$num * 2 + 1];
    $offset = $this->table_originals[$num * 2 + 2];
    if (! $length)
      return '';
    $this->STREAM->seekto($offset);
    $data = $this->STREAM->read($length);
    return (string)$data;
  }

  /**
   * Returns a string from the "translations" table
   *
   * @access private
   * @param int num Offset number of original string
   * @return string Requested string if found, otherwise ''
   */
  function get_translation_string($num) {
    $length = $this->table_translations[$num * 2 + 1];
    $offset = $this->table_translations[$num * 2 + 2];
    if (! $length)
      return '';
    $this->STREAM->seekto($offset);
    $data = $this->STREAM->read($length);
    return (string)$data;
  }

  /**
   * Binary search for string
   *
   * @access private
   * @param string string
   * @param int start (internally used in recursive function)
   * @param int end (internally used in recursive function)
   * @return int string number (offset in originals table)
   */
  function find_string($string, $start = -1, $end = -1) {
    if (($start == -1) or ($end == -1)) {
      // find_string is called with only one parameter, set start end end
      $start = 0;
      $end = $this->total;
    }
    if (abs($start - $end) <= 1) {
      // We're done, now we either found the string, or it doesn't exist
      $txt = $this->get_original_string($start);
      if ($string == $txt)
        return $start;
      else
        return -1;
    } else if ($start > $end) {
      // start > end -> turn around and start over
      return $this->find_string($string, $end, $start);
    } else {
      // Divide table in two parts
      $half = (int)(($start + $end) / 2);
      $cmp = strcmp($string, $this->get_original_string($half));
      if ($cmp == 0)
        // string is exactly in the middle => return it
        return $half;
      else if ($cmp < 0)
        // The string is in the upper half
        return $this->find_string($string, $start, $half);
      else
        // The string is in the lower half
        return $this->find_string($string, $half, $end);
    }
  }

  /**
   * Translates a string
   *
   * @access public
   * @param string string to be translated
   * @return string translated string (or original, if not found)
   */
  function translate($string) {
    if ($this->short_circuit)
      return $string;
    $this->load_tables();

    if ($this->enable_cache) {
      // Caching enabled, get translated string from cache
      if (array_key_exists($string, $this->cache_translations))
        return $this->cache_translations[$string];
      else
        return $string;
    } else {
      // Caching not enabled, try to find string
      $num = $this->find_string($string);
      if ($num == -1)
        return $string;
      else
        return $this->get_translation_string($num);
    }
  }

  /**
   * Sanitize plural form expression for use in PHP eval call.
   *
   * @access private
   * @return string sanitized plural form expression
   */
  function sanitize_plural_expression($expr) {
    // Get rid of disallowed characters.
    $expr = preg_replace('@[^a-zA-Z0-9_:;\(\)\?\|\&=!<>+*/\%-]@', '', $expr);

    // Add parenthesis for tertiary '?' operator.
    $expr .= ';';
    $res = '';
    $p = 0;
    for ($i = 0, $k = strlen($expr); $i < $k; $i++) {
      $ch = $expr[$i];
      switch ($ch) {
      case '?':
        $res .= ' ? (';
        $p++;
        break;
      case ':':
        $res .= ') : (';
        break;
      case ';':
        $res .= str_repeat( ')', $p) . ';';
        $p = 0;
        break;
      default:
        $res .= $ch;
      }
    }
    return $res;
  }

  /**
   * Parse full PO header and extract only plural forms line.
   *
   * @access private
   * @return string verbatim plural form header field
   */
  function extract_plural_forms_header_from_po_header($header) {
    $regs = array();
    if (preg_match("/(^|\n)plural-forms: ([^\n]*)\n/i", $header, $regs))
      $expr = $regs[2];
    else
      $expr = "nplurals=2; plural=n == 1 ? 0 : 1;";
    return $expr;
  }

  /**
   * Get possible plural forms from MO header
   *
   * @access private
   * @return string plural form header
   */
  function get_plural_forms() {
    // lets assume message number 0 is header
    // this is true, right?
    $this->load_tables();

    // cache header field for plural forms
    if (! is_string($this->pluralheader)) {
      if ($this->enable_cache) {
        $header = $this->cache_translations[""];
      } else {
        $header = $this->get_translation_string(0);
      }
      $expr = $this->extract_plural_forms_header_from_po_header($header);
      $this->pluralheader = $this->sanitize_plural_expression($expr);
    }
    return $this->pluralheader;
  }

  /**
   * Detects which plural form to take
   *
   * @access private
   * @param n count
   * @return int array index of the right plural form
   */
  function select_string($n) {
      // Expression reads
      // nplurals=X; plural= n != 1
      if (!isset($this->plural_expression)) {
          $matches = array();
          if (!preg_match('`nplurals\s*=\s*(\d+)\s*;\s*plural\s*=\s*(.+$)`',
                  $this->get_plural_forms(), $matches))
              return 1;

          $this->plural_expression = function ($n) use ($matches) {
              $var = str_replace('n', $n, $matches[2]);
              return eval("return $var");
          };
          $this->plural_total = (int) $matches[1];
      }
      $func = $this->plural_expression;
      $plural = $func($n);
      return ($plural > $this->plural_total)
          ? $this->plural_total - 1
          : $plural;
  }

  /**
   * Plural version of gettext
   *
   * @access public
   * @param string single
   * @param string plural
   * @param string number
   * @return translated plural form
   */
  function ngettext($single, $plural, $number) {
    if ($this->short_circuit) {
      if ($number != 1)
        return $plural;
      else
        return $single;
    }

    // find out the appropriate form
    $select = $this->select_string($number);

    // this should contains all strings separated by NULLs
    $key = $single . chr(0) . $plural;


    if ($this->enable_cache) {
      if (! array_key_exists($key, $this->cache_translations)) {
        return ($number != 1) ? $plural : $single;
      } else {
        $result = $this->cache_translations[$key];
        $list = explode(chr(0), $result);
        return $list[$select];
      }
    } else {
      $num = $this->find_string($key);
      if ($num == -1) {
        return ($number != 1) ? $plural : $single;
      } else {
        $result = $this->get_translation_string($num);
        $list = explode(chr(0), $result);
        return $list[$select];
      }
    }
  }

  function pgettext($context, $msgid) {
    $key = $context . chr(4) . $msgid;
    $ret = $this->translate($key);
    if (strpos($ret, "\004") !== FALSE) {
      return $msgid;
    } else {
      return $ret;
    }
  }

  function npgettext($context, $singular, $plural, $number) {
    $key = $context . chr(4) . $singular;
    $ret = $this->ngettext($key, $plural, $number);
    if (strpos($ret, "\004") !== FALSE) {
      return $singular;
    } else {
      return $ret;
    }

  }
}

class FileReader {
  var $_pos;
  var $_fd;
  var $_length;

  function __construct($filename) {
    if (is_resource($filename)) {
        $this->_length = strlen(stream_get_contents($filename));
        rewind($filename);
        $this->_fd = $filename;
    }
    elseif (file_exists($filename)) {

      $this->_length=filesize($filename);
      $this->_fd = fopen($filename,'rb');
      if (!$this->_fd) {
        $this->error = 3; // Cannot read file, probably permissions
        return false;
      }
    } else {
      $this->error = 2; // File doesn't exist
      return false;
    }
    $this->_pos = 0;
  }

  function read($bytes) {
    if ($bytes) {
      fseek($this->_fd, $this->_pos);

      // PHP 5.1.1 does not read more than 8192 bytes in one fread()
      // the discussions at PHP Bugs suggest it's the intended behaviour
      $data = '';
      while ($bytes > 0) {
        $chunk  = fread($this->_fd, $bytes);
        $data  .= $chunk;
        $bytes -= strlen($chunk);
      }
      $this->_pos = ftell($this->_fd);

      return $data;
    } else return '';
  }

  function seekto($pos) {
    fseek($this->_fd, $pos);
    $this->_pos = ftell($this->_fd);
    return $this->_pos;
  }

  function currentpos() {
    return $this->_pos;
  }

  function length() {
    return $this->_length;
  }

  function close() {
    fclose($this->_fd);
  }

}

/**
 * Class: Translation
 *
 * This class is strongly based on the gettext_reader class. It makes use of
 * a few simple optimizations for the context of osTicket
 *
 *    * The language packs are pre-compiled and distributed (which means
 *      they can be customized).
 *    * The MO file will always be processed by PHP code
 *    * osTicket uses utf-8 output exclusively (for web traffic anyway)
 *
 * These allow us to optimize the MO file for the osTicket project
 * specifically and make enough of an optimization to allow using a pure-PHP
 * source gettext library implementation which should be roughly the same
 * performance as the libc gettext library.
 */
class Translation extends gettext_reader implements Serializable {

    var $charset;
    var $encode;

    const META_HEADER = 0;

    function __construct($reader, $charset=false) {
        if (!$reader)
            return $this->short_circuit = true;

        // Just load the cache
        if (!is_string($reader))
            throw new RuntimeException('Programming Error: Expected filename for translation source');
        $this->STREAM = $reader;

        $this->enable_cache = true;
        $this->charset = $charset;
        $this->encode = $charset && strcasecmp($charset, 'utf-8') !== 0;
        $this->load_tables();
    }

    function load_tables() {
        if (isset($this->cache_translations))
            return;

        $this->cache_translations = (include $this->STREAM);
    }

    function translate($string) {
        if ($this->short_circuit)
            return $string;

        // Caching enabled, get translated string from cache
        if (isset($this->cache_translations[$string]))
            $string = $this->cache_translations[$string];

        if (!$this->encode)
            return $string;

        return Charset::transcode($string, 'utf-8', $this->charset);
    }

    static function buildHashFile($mofile, $outfile=false, $return=false) {
        if (!$outfile) {
            $stream = fopen('php://stdout', 'w');
        }
        elseif (is_string($outfile)) {
            $stream = fopen($outfile, 'w');
        }
        elseif (is_resource($outfile)) {
            $stream = $outfile;
        }

        if (!$stream)
            throw new InvalidArgumentException(
                'Expected a filename or valid resource');

        if (!$mofile instanceof FileReader)
            $mofile = new FileReader($mofile);

        $reader = new parent($mofile, true);

        if ($reader->short_circuit || $reader->error)
            throw new Exception('Unable to initialize MO input file');

        $reader->load_tables();

        // Get basic table
        if (!($table = $reader->cache_translations))
            throw new Exception('Unable to read translations from file');

        // Transcode the table to UTF-8
        $header = $table[""];
        $info = array();
        preg_match('/^content-type: (.*)$/im', $header, $info);
        $charset = false;
        if ($content_type = $info[1]) {
            // Find the charset property
            $settings = explode(';', $content_type);
            foreach ($settings as $v) {
                @list($prop, $value) = explode('=', trim($v), 2);
                if (strtolower($prop) == 'charset') {
                    $charset = trim($value);
                    break;
                }
            }
        }
        if ($charset && strcasecmp($charset, 'utf-8') !== 0) {
            foreach ($table as $orig=>$trans) {
                $table[Charset::utf8($orig, $charset)] =
                    Charset::utf8($trans, $charset);
                unset($table[$orig]);
            }
        }

        // Add in some meta-data
        $table[self::META_HEADER] = array(
            'Revision' => $reader->revision,      // From the MO
            'Total-Strings' => $reader->total,    // From the MO
            'Table-Size' => count($table),      // Sanity check for later
            'Build-Timestamp' => gmdate(DATE_RFC822),
            'Format-Version' => 'A',            // Support future formats
            'Encoding' => 'UTF-8',
        );

        // Serialize the PHP array and write to output
        $contents = sprintf('<?php return %s;', var_export($table, true));
        if ($return)
            return $contents;
        else
            fwrite($stream, $contents);
    }

    static function resurrect($key) {
        if (!function_exists('apcu_fetch'))
            return false;

        $success = true;
        if (($translation = apcu_fetch($key, $success)) && $success)
            return $translation;
    }
    function cache($key) {
        if (function_exists('apcu_add'))
            apcu_add($key, $this);
    }


    // Fix PHP 8.1.x Deprecation Warnings
    // Serializable interface will be removed in PHP 9.x
    function serialize() {
        return serialize($this->__serialize());
    }
    function unserialize($what) {
        $this->__unserialize(unserialize($what));
    }

    function __serialize() {
        return array($this->charset, $this->encode, $this->cache_translations);
    }
    function __unserialize($what) {
        list($this->charset, $this->encode, $this->cache_translations) = $what;
        $this->short_circuit = ! $this->enable_cache
            = 0 < $this->cache_translations ? count($this->cache_translations) : 1;
    }
}

if (!defined('LC_MESSAGES')) {
    define('LC_ALL', 0);
    define('LC_CTYPE', 1);
    define('LC_NUMERIC', 2);
    define('LC_TIME', 3);
    define('LC_COLLATE', 4);
    define('LC_MONETARY', 5);
    define('LC_MESSAGES', 6);
}

class TextDomain {
    var $l10n = array();
    var $path;
    var $codeset;
    var $domain;

    static $registry;
    static $default_domain = 'messages';
    static $current_locale = '';
    static $LC_CATEGORIES = array(
        LC_ALL => 'LC_ALL',
        LC_CTYPE => 'LC_CTYPE',
        LC_NUMERIC => 'LC_NUMERIC',
        LC_TIME => 'LC_TIME',
        LC_COLLATE => 'LC_COLLATE',
        LC_MONETARY => 'LC_MONETARY',
        LC_MESSAGES => 'LC_MESSAGES'
    );

    function __construct($domain) {
        $this->domain = $domain;
    }

    function getTranslation($category=LC_MESSAGES, $locale=false) {
        $locale = $locale ?: self::$current_locale
            ?: self::setLocale(LC_MESSAGES, 0);

        if (isset($this->l10n[$locale]))
            return $this->l10n[$locale];

        if ($locale == 'en_US') {
            $this->l10n[$locale] = new Translation(null);
        }
        else {
            // get the current locale
            $bound_path = @$this->path ?: './';
            $subpath = self::$LC_CATEGORIES[$category] .
                '/'.$this->domain.'.mo.php';

            // APC short-circuit (if supported)
            $key = sha1($locale .':lang:'. $subpath);
            if ($T = Translation::resurrect($key)) {
                return $this->l10n[$locale] = $T;
            }

            $locale_names = self::get_list_of_locales($locale);
            $input = null;
            foreach ($locale_names as $T) {
                if (substr($bound_path, 7) != 'phar://') {
                    $phar_path = 'phar://' . $bound_path . $T . ".phar/" . $subpath;
                    if (file_exists($phar_path)) {
                        $input = $phar_path;
                        break;
                    }
                }
                $full_path = $bound_path . $T . "/" . $subpath;
                if (file_exists($full_path)) {
                    $input = $full_path;
                    break;
                }
            }
            // TODO: Handle charset hint from the environment
            $this->l10n[$locale] = $T = new Translation($input);
            $T->cache($key);
        }
        return $this->l10n[$locale];
    }

    function setPath($path) {
        $this->path = $path;
    }

    static function configureForUser($user=false) {
        $lang = Internationalization::getCurrentLanguage($user);
        $info = Internationalization::getLanguageInfo($lang);
        if (!$info)
            // Not a supported language
            return;

        // Define locale for C-libraries
        // Check if putenv() is available - some hosts disable it
        if (function_exists('putenv'))
            putenv('LC_ALL=' . $info['code']);
        self::setLocale(LC_ALL, $info['code']);
    }

    static function setDefaultDomain($domain) {
        static::$default_domain = $domain;
    }

    /**
     * Returns passed in $locale, or environment variable $LANG if $locale == ''.
     */
    static function get_default_locale($locale='') {
        if ($locale == '') // emulate variable support
            return getenv('LANG');
        else
            return $locale;
    }

    static function get_list_of_locales($locale) {
        /* Figure out all possible locale names and start with the most
         * specific ones.  I.e. for sr_CS.UTF-8@latin, look through all of
         * sr_CS.UTF-8@latin, sr_CS@latin, sr@latin, sr_CS.UTF-8, sr_CS, sr.
        */
        $locale_names = $m = array();
        $lang = null;
        if ($locale) {
            if (preg_match("/^(?P<lang>[a-z]{2,3})"              // language code
                ."(?:_(?P<country>[A-Z]{2}))?"           // country code
                ."(?:\.(?P<charset>[-A-Za-z0-9_]+))?"    // charset
                ."(?:@(?P<modifier>[-A-Za-z0-9_]+))?$/",  // @ modifier
                $locale, $m)
            ) {

            if (isset($m['modifier'])) {
                // TODO: Confirm if Crowdin uses the modifer flags
                if ($m['country']) {
                    $locale_names[] = "{$m['lang']}_{$m['country']}@{$m['modifier']}";
                }
                $locale_names[] = "{$m['lang']}@{$m['modifier']}";
            }
            if ($m['country']) {
                $locale_names[] = "{$m['lang']}_{$m['country']}";
            }
            $locale_names[] = $m['lang'];
        }

        // If the locale name doesn't match POSIX style, just include it as-is.
        if (!in_array($locale, $locale_names))
            $locale_names[] = $locale;
      }
      return array_filter($locale_names);
    }

    static function setLocale($category, $locale) {
        if ($locale === 0) { // use === to differentiate between string "0"
            if (self::$current_locale != '')
                return self::$current_locale;
            else
                // obey LANG variable, maybe extend to support all of LC_* vars
                // even if we tried to read locale without setting it first
                return self::setLocale($category, self::$current_locale);
        } else {
            if (function_exists('setlocale')) {
              $ret = setlocale($category, $locale);
              if (($locale == '' and !$ret) or // failed setting it by env
                  ($locale != '' and $ret != $locale)) { // failed setting it
                // Failed setting it according to environment.
                self::$current_locale = self::get_default_locale($locale);
              } else {
                self::$current_locale = $ret;
              }
            } else {
              // No function setlocale(), emulate it all.
              self::$current_locale = self::get_default_locale($locale);
            }
            return self::$current_locale;
        }
    }

    static function lookup($domain=null) {
        if (!isset($domain))
            $domain = self::$default_domain;
        if (!isset(static::$registry[$domain])) {
            static::$registry[$domain] = new TextDomain($domain);
        }
        return static::$registry[$domain];
    }
}

require_once INCLUDE_DIR . 'class.orm.php';
class CustomDataTranslation extends VerySimpleModel {

    static $meta = array(
        'table' => TRANSLATION_TABLE,
        'pk' => array('id')
    );

    const FLAG_FUZZY        = 0x01;     // Source string has been changed
    const FLAG_UNAPPROVED   = 0x02;     // String has been reviewed by an authority
    const FLAG_CURRENT      = 0x04;     // If more than one version exist, this is current
    const FLAG_COMPLEX      = 0x08;     // Multiple strings in one translation. For instance article title and body

    var $_complex;

    static function lookup($msgid, $flags=0) {
        if (!is_string($msgid))
            return parent::lookup($msgid);

        // Hash is 16 char of md5
        $hash = substr(md5($msgid), -16);

        $criteria = array('object_hash'=>$hash);

        if ($flags)
            $criteria += array('flags__hasbit'=>$flags);

        return parent::lookup($criteria);
    }

    static function getTranslation($locale, $cache=true) {
        static $_cache = array();

        if ($cache && isset($_cache[$locale]))
            return $_cache[$locale];

        $criteria = array(
            'lang' => $locale,
            'type' => 'phrase',
        );

        $mo = array();
        foreach (static::objects()->filter($criteria) as $t) {
            $mo[$t->object_hash] = $t;
        }

        return $_cache[$locale] = $mo;
    }

    static function translate($msgid, $locale=false, $cache=true, $type='phrase') {
        global $thisstaff, $thisclient;

        // Support sending a User as the locale
        if (is_object($locale) && method_exists($locale, 'getLanguage'))
            $locale = $locale->getLanguage();
        elseif (!$locale)
            $locale = Internationalization::getCurrentLanguage();

        // Perhaps a slight optimization would be to check if the selected
        // locale is also the system primary. If so, short-circuit

        if ($locale) {
            if ($cache) {
                $mo = static::getTranslation($locale);
                if (isset($mo[$msgid]))
                    $msgid = $mo[$msgid]->text;
            }
            elseif ($p = static::lookup(array(
                    'type' => $type,
                    'lang' => $locale,
                    'object_hash' => $msgid
            ))) {
                $msgid = $p->text;
            }
        }
        return $msgid;
    }

    /**
     * Decode complex translation message. Format is given in the $text
     * parameter description. Complex data should be stored with the
     * FLAG_COMPLEX flag set, and allows for complex key:value paired data
     * to be translated. This is useful for strings which are translated
     * together, such as the title and the body of an article. Storing the
     * data in a single, complex record allows for a single database query
     * to fetch or update all data for a particular object, such as a
     * knowledgebase article. It also simplifies search indexing as only one
     * translation record could be added for all the translatable elements
     * for a single translatable object.
     *
     * Caveats:
     * ::$text will return the stored, complex text. Use ::getComplex() to
     * decode the complex storage format and retrieve the array.
     *
     * Parameters:
     * $text - (string) - encoded text with the following format
     *      version \x03 key \x03 item1 \x03 key \x03 item2 ...
     *
     * Returns:
     * (array) key:value pairs of translated content
     */
    function decodeComplex($text) {
        $blocks = explode("\x03", $text);
        $version = array_shift($blocks);

        $data = array();
        switch ($version) {
        case 'A':
            while (count($blocks) > 1) {
                $key = array_shift($blocks);
                $data[$key] = array_shift($blocks);
            }
            break;
        default:
            throw new Exception($version . ': Unknown complex format');
        }

        return $data;
    }

    /**
     * Encode complex content using the format outlined in ::decodeComplex.
     *
     * Caveats:
     * This method does not set the FLAG_COMPLEX flag for this record, which
     * should be set when storing complex data.
     */
    static function encodeComplex(array $data) {
        $encoded = 'A';
        foreach ($data as $key=>$text) {
            $encoded .= "\x03{$key}\x03{$text}";
        }
        return $encoded;
    }

    function getComplex() {
        if (!$this->flags && self::FLAG_COMPLEX)
            throw new Exception('Data consistency error. Translation is not complex');
        if (!isset($this->_complex))
            $this->_complex = $this->decodeComplex($this->text);
        return $this->_complex;
    }

    static function translateArticle($msgid, $locale=false) {
        return static::translate($msgid, $locale, false, 'article');
    }

    function save($refetch=false) {
        if (isset($this->text) && is_array($this->text)) {
            $this->text = static::encodeComplex($this->text);
            $this->flags |= self::FLAG_COMPLEX;
        }
        return parent::save($refetch);
    }

    static function create($ht=false) {
        if (!is_array($ht))
            return null;

        if (is_array($ht['text'])) {
            // The parent constructor does not honor arrays
            $ht['text'] = static::encodeComplex($ht['text']);
            $ht['flags'] = ($ht['flags'] ?: 0) | self::FLAG_COMPLEX;
        }
        return new static($ht);
    }

    static function allTranslations($msgid, $type='phrase', $lang=false) {
        $criteria = array('type' => $type);

        if (is_array($msgid))
            $criteria['object_hash__in'] = $msgid;
        else
            $criteria['object_hash'] = $msgid;

        if ($lang)
            $criteria['lang'] = $lang;

        try {
            return static::objects()->filter($criteria)->all();
        }
        catch (OrmException $e) {
            // Translation table might not exist yet — happens on the upgrader
            return array();
        }
    }
}

class CustomTextDomain {

}

// Functions for gettext library. Since the gettext extension for PHP is not
// used as a fallback, there is no detection and compat funciton
// installation for the gettext library function calls.

function _gettext($msgid) {
    return TextDomain::lookup()->getTranslation()->translate($msgid);
}
function __($msgid) {
    return _gettext($msgid);
}
function _ngettext($singular, $plural, $number) {
    return TextDomain::lookup()->getTranslation()
        ->ngettext($singular, $plural, $number);
}
function _dgettext($domain, $msgid) {
    return TextDomain::lookup($domain)->getTranslation()
        ->translate($msgid);
}
function _dngettext($domain, $singular, $plural, $number) {
    return TextDomain::lookup($domain)->getTranslation()
        ->ngettext($singular, $plural, $number);
}
function _dcgettext($domain, $msgid, $category) {
    return TextDomain::lookup($domain)->getTranslation($category)
        ->translate($msgid);
}
function _dcngettext($domain, $singular, $plural, $number, $category) {
    return TextDomain::lookup($domain)->getTranslation($category)
        ->ngettext($singular, $plural, $number);
}
function _pgettext($context, $msgid) {
    return TextDomain::lookup()->getTranslation()
        ->pgettext($context, $msgid);
}
function _dpgettext($domain, $context, $msgid) {
    return TextDomain::lookup($domain)->getTranslation()
        ->pgettext($context, $msgid);
}
function _dcpgettext($domain, $context, $msgid, $category) {
    return TextDomain::lookup($domain)->getTranslation($category)
        ->pgettext($context, $msgid);
}
function _npgettext($context, $singular, $plural, $n) {
    return TextDomain::lookup()->getTranslation()
        ->npgettext($context, $singular, $plural, $n);
}
function _dnpgettext($domain, $context, $singular, $plural, $n) {
    return TextDomain::lookup($domain)->getTranslation()
        ->npgettext($context, $singular, $plural, $n);
}
function _dcnpgettext($domain, $context, $singular, $plural, $category, $n) {
    return TextDomain::lookup($domain)->getTranslation($category)
        ->npgettext($context, $singular, $plural, $n);
}

// Custom data translations
function _H($tag) {
    return substr(md5($tag), -16);
}

interface Translatable {
    function getTranslationTag();
    function getLocalName($user=false);
}

do {
  if (PHP_SAPI != 'cli') break;
  if (empty ($_SERVER['argc']) || $_SERVER['argc'] < 2) break;
  if (empty ($_SERVER['PHP_SELF']) || FALSE === strpos ($_SERVER['PHP_SELF'], basename(__FILE__)) ) break;
  $file = $argv[1];
  Translation::buildHashFile($file);
} while (0);

class.attachment.php:
<?php
/*********************************************************************
    class.attachment.php

    Attachment Handler - mainly used for lookup...doesn't save!

    Peter Rotich <peter@osticket.com>
    Copyright (c)  2006-2013 osTicket
    http://www.osticket.com

    Released under the GNU General Public License WITHOUT ANY WARRANTY.
    See LICENSE.TXT for details.

    vim: expandtab sw=4 ts=4 sts=4:
**********************************************************************/
require_once(INCLUDE_DIR.'class.ticket.php');
require_once(INCLUDE_DIR.'class.file.php');

class Attachment extends VerySimpleModel {
    static $meta = array(
        'table' => ATTACHMENT_TABLE,
        'pk' => array('id'),
        'select_related' => array('file'),
        'joins' => array(
            'draft' => array(
                'constraint' => array(
                    'type' => "'D'",
                    'object_id' => 'Draft.id',
                ),
            ),
            'file' => array(
                'constraint' => array(
                    'file_id' => 'AttachmentFile.id',
                ),
            ),
            'thread_entry' => array(
                'constraint' => array(
                    'type' => "'H'",
                    'object_id' => 'ThreadEntry.id',
                ),
            ),
        ),
    );

    var $object;

    function getId() {
        return $this->id;
    }

    function getFileId() {
        return $this->file_id;
    }

    function getFile() {
        return $this->file;
    }

    function getFilename() {
        return $this->name ?: $this->file->name;
    }

    function getName() {
        return $this->getFilename();
    }

    function getHashtable() {
        return $this->ht;
    }

    function getInfo() {
        return $this->getHashtable();
    }

    function getObject() {

        if (!isset($this->object))
            $this->object = ObjectModel::lookup(
                    $this->ht['object_id'], $this->ht['type']);

        return $this->object;
    }

    static function lookupByFileHash($hash, $objectId=0) {
        $file = static::objects()
            ->filter(array('file__key' => $hash));

        if ($objectId)
            $file->filter(array('object_id' => $objectId));

        return $file->first();
    }

    static function lookup($var, $objectId=0) {
        return (is_string($var))
            ? static::lookupByFileHash($var, $objectId)
            : parent::lookup($var);
    }
}

class GenericAttachments
extends InstrumentedList {

    var $lang;

    function getId() { return $this->key['object_id']; }
    function getType() { return $this->key['type']; }
    function getMimeType() { return $this->getType(); }
    /**
     * Drop attachments whose file_id values are not in the included list,
     * additionally, add new files whose IDs are in the list provided.
     */
    function keepOnlyFileIds($ids, $inline=false, $lang=false) {
        if (!$ids) $ids = array();
        foreach ($this as $A) {
            if (!isset($ids[$A->file_id]) && $A->lang == $lang && $A->inline == $inline)
                // Not in the $ids list, delete
                $this->remove($A);
            unset($ids[$A->file_id]);
        }
        $attachments = array();
        // Format $new for upload() with new name
        foreach ($ids as $id=>$value) {
            if (is_array($value)) list('id' => $id, 'name' => $value) = $value;
            $attachments[] = array(
                    'id' => $id,
                    'name' => $value
                );
        }
        // Everything remaining in $attachments is truly new
        $this->upload($attachments, $inline, $lang);
    }

    function upload($files, $inline=false, $lang=false) {
        $i=array();
        if (!is_array($files))
            $files = array($files);
        foreach ($files as $file) {
            if (is_numeric($file))
                $fileId = $file;
            elseif (is_array($file) && isset($file['id']) && $file['id'])
                $fileId = $file['id'];
            elseif (isset($file['tmp_name']) && ($F = AttachmentFile::upload($file)))
                $fileId = $F->getId();
            elseif ($F = AttachmentFile::create($file))
                $fileId = $F->getId();
            else
                continue;

            $_inline = isset($file['inline']) ? $file['inline'] : $inline;

            // Check if Attachment exists
            if ($F && $this->key)
                $existing = Attachment::objects()->filter(array(
                    'file__key' => $F->key,
                    'object_id' => $this->key['object_id'],
                    'type' => $this->key['type']
                ))->first();

            $att = $this->add(isset($existing) ? $existing : new Attachment(array(
                'file_id' => $fileId,
                'inline' => $_inline ? 1 : 0,
            )));

            // Record varying file names in the attachment record
            if (is_array($file) && isset($file['name'])) {
                $filename = $file['name'];
            }
            if ($filename) {
                // This should be a noop since the ORM caches on PK
                $file = $F ?: AttachmentFile::lookup($fileId);
                // XXX: This is not Unicode safe
                if ($file && 0 !== strcasecmp($file->name, $filename))
                    $att->name = $filename;
            }
            if ($lang)
                $att->lang = $lang;

            // File may already be associated with the draft (in the
            // event it was deleted and re-added)
            $att->save();
            $i[] = $fileId;
        }
        return $i;
    }

    function save($file, $inline=true) {
        $ids = $this->upload($file, $inline);
        return $ids[0];
    }

    function getInlines($lang=false) { return $this->_getList(false, true, $lang); }
    function getSeparates($lang=false) { return $this->_getList(true, false, $lang); }
    function getAll($lang=false) { return $this->_getList(true, true, $lang); }
    function count($lang=false) { return count($this->getSeparates($lang)); }

    function _getList($separates=false, $inlines=false, $lang=false) {
        $base = $this;

        if ($separates && !$inlines)
            $base = $base->filter(array('inline' => 0));
        elseif (!$separates && $inlines)
            $base = $base->filter(array('inline' => 1));

        if ($lang)
            $base = $base->filter(array('lang' => $lang));

        return $base;
    }

    function delete($file_id) {
        return $this->objects()->filter(array('file_id'=>$file_id))->delete();
    }

    function deleteAll($inline_only=false){
        if ($inline_only)
            return $this->objects()->filter(array('inline' => 1))->delete();

        return parent::expunge();
    }

    function deleteInlines() {
        return $this->deleteAll(true);
    }

    static function forIdAndType($id, $type) {
        return new static(array(
            'Attachment',
            array('object_id' => $id, 'type' => $type)
        ));
    }
}
?>
class.forms.php:
<?php
/*********************************************************************
    class.forms.php

    osTicket forms framework

    Jared Hancock <<PRESIDIO_ANONYMIZED_EMAIL_ADDRESS>>
    Copyright (c)  2006-2013 osTicket
    http://www.osticket.com

    Released under the GNU General Public License WITHOUT ANY WARRANTY.
    See LICENSE.TXT for details.

    vim: expandtab sw=4 ts=4 sts=4:
**********************************************************************/

/**
 * Form template, used for designing the custom form and for entering custom
 * data for a ticket
 */
class Form {
    static $renderer = 'GridFluidLayout';
    static $id = 0;

    var $options = array();
    var $fields = array();
    var $title = '';
    var $notice = '';
    var $instructions = '';

    var $validators = array();

    var $_errors = null;
    var $_source = false;

    function __construct($source=null, $options=array()) {

        $this->options = $options;
        if (isset($options['title']))
            $this->title = $options['title'];
        if (isset($options['instructions']))
            $this->instructions = $options['instructions'];
        if (isset($options['notice']))
            $this->notice = $options['notice'];
        if (isset($options['id']))
            $this->id = $options['id'];

        // Use POST data if source was not specified
        $this->_source = $source ?: $_POST;
    }

    function getFormId() {
        return @$this->id ?: static::$id;
    }
    function setId($id) {
        $this->id = $id;
    }

    function setNotice($notice) {
        $this->notice = $notice;
    }

    function data($source) {
        foreach ($this->fields as $name=>$f)
            if (isset($source[$name]))
                $f->value = $source[$name];
    }

    function setFields($fields) {

        if (!is_array($fields) && !$fields instanceof Traversable)
            return;

        $this->fields = $fields;
        foreach ($fields as $k=>$f) {
            $f->setForm($this);
            if (!$f->get('name') && $k && !is_numeric($k))
                $f->set('name', $k);
        }
    }

    function getFields() {
        return $this->fields;
    }

    function getField($name) {
        $fields = $this->getFields();
        foreach($fields as $f)
            if(!strcasecmp($f->get('name'), $name))
                return $f;
        if (isset($fields[$name]))
            return $fields[$name];
    }

    function hasField($name) {
        return $this->getField($name);
    }

    function hasAnyEnabledFields() {
        return $this->hasAnyVisibleFields(false);
    }

    function hasAnyVisibleFields($user=false) {
        $visible = 0;
        $isstaff = $user instanceof Staff;
        foreach ($this->getFields() as $F) {
            if (!$user) {
                // Assume hasAnyEnabledFields
                if ($F->isEnabled())
                    $visible++;
            } elseif($isstaff) {
                if ($F->isVisibleToStaff())
                    $visible++;
            } elseif ($F->isVisibleToUsers()) {
                $visible++;
            }
        }
        return $visible > 0;
    }

    function getTitle() { return $this->title; }
    function getNotice() { return $this->notice; }
    function getInstructions() { return Format::htmldecode($this->instructions); }
    function getSource() { return $this->_source; }
    function setSource($source) { $this->_source = $source; }

    /**
     * Validate the form and indicate if there no errors.
     *
     * Parameters:
     * $filter - (callback) function to receive each field and return
     *      boolean true if the field's errors are significant
     */
    function isValid($include=false) {
        if (!isset($this->_errors)) {
            $this->_errors = array();
            $this->validate($this->getClean());
            foreach ($this->getFields() as $field)
                if ($field->errors() && (!$include || $include($field)))
                    $this->_errors[$field->get('id')] = $field->errors();
        }
        return !$this->_errors;
    }

    function validate($clean_data) {
        // Validate the whole form so that errors can be added to the
        // individual fields and collected below.
        foreach ($this->validators as $V) {
            $V($this);
        }
    }

    function getClean($validate=true) {
        if (!$this->_clean) {
            $this->_clean = array();
            foreach ($this->getFields() as $key=>$field) {
                if (!$field->hasData())
                    continue;

                // Prefer indexing by field.id if indexing numerically
                if (is_int($key) && $field->get('id'))
                    $key = $field->get('id');
                $this->_clean[$key] = $this->_clean[$field->get('name')]
                    = $field->getClean($validate);
            }
            unset($this->_clean[""]);
        }
        return $this->_clean;
    }
    /*
     * Transform form data to database ready clean data.
     *
     */
    function to_db($clean=null, $validate=true) {
        if (!$clean
                && !$this->isValid()
                && !($clean=$this->getClean($validate)))
            return false;
        $data = [];
        foreach ($clean as $name => $val) {
            if (!($f = $this->getField($name)))
                continue;
            try {
                $data[$name] = $f->to_database($val);
            } catch (FieldUnchanged $e) {
                // Unset field if it's unchanged...mainly
                // useful for Secret/PasswordField
                unset($data[$name]);
            }
        }
        return $data;
    }

    /*
     * Process the form input and return clean data.
     *
     * It's similar to to_db but forms downstream can use it to skip or add
     * extra validations

     */
    function process($validate=true) {
        return $this->to_db($validate);
    }


    function errors($formOnly=false) {
        return ($formOnly) ? $this->_errors['form'] : $this->_errors;
    }

    function addError($message, $index=false) {
        if ($index)
            $this->_errors[$index] = $message;
        else
            $this->_errors['form'][] = $message;
    }

    function addErrors($errors=[]) {
        foreach ($errors as $k => $v) {
            if (($f=$this->getField($k)))
                $f->addError($v);
            else
                $this->addError($v, $k);
        }
    }

    function addValidator($function) {
        if (!is_callable($function))
            throw new Exception('Form validator must be callable');
        $this->validators[] = $function;
    }

    function render($options=array()) {
        if (isset($options['title']))
            $this->title = $options['title'];
        if (isset($options['instructions']))
            $this->instructions = $options['instructions'];
        if (isset($options['notice']))
            $this->notice = $options['notice'];

        $form = $this;
        $template = $options['template'] ?: 'dynamic-form.tmpl.php';
        if (isset($options['staff']) && $options['staff'])
            include(STAFFINC_DIR . 'templates/' . $template);
        else
            include(CLIENTINC_DIR . 'templates/' . $template);
        echo $this->getMedia();
    }

    function getLayout($title=false, $options=array()) {
        $rc = @$options['renderer'] ?: static::$renderer;
        return new $rc($title, $options);
    }

    function asTable($title=false, $options=array()) {
        return $this->getLayout($title, $options)->asTable($this);
        // XXX: Media can't go in a table
        echo $this->getMedia();
    }

    function getMedia() {
        static $dedup = array();

        foreach ($this->getFields() as $f) {
            if (($M = $f->getMedia()) && is_array($M)) {
                foreach ($M as $type=>$files) {
                    foreach ($files as $url) {
                        $key = strtolower($type.$url);
                        if (isset($dedup[$key]))
                            continue;

                        self::emitMedia($url, $type);

                        $dedup[$key] = true;
                    }
                }
            }
        }
    }

    function emitJavascript($options=array()) {

        // Check if we need to emit javascript
        if (!($fid=$this->getFormId()))
            return;
        ?>
        <script type="text/javascript">
          $(function() {
            <?php
            //XXX: We ONLY want to watch field on this form. We'll only
            // watch form inputs if form_id is specified. Current FORM API
            // doesn't generate the entire form  (just fields)
            if ($fid) {
                ?>
                $(document).off('change.<?php echo $fid; ?>');
                $(document).on('change.<?php echo $fid; ?>',
                    'form#<?php echo $fid; ?> :input',
                    function() {
                        //Clear any current errors...
                        var errors = $('#field'+$(this).attr('id')+'_error');
                        if (errors.length)
                            errors.slideUp('fast', function (){
                                $(this).remove();
                                });
                        //TODO: Validation input inplace or via ajax call
                        // and set any new errors AND visibilty changes
                    }
                   );
            <?php
            }
            ?>
            });
        </script>
        <?php
    }

    static function emitMedia($url, $type) {
        if ($url[0] == '/')
            $url = ROOT_PATH . substr($url, 1);

        switch (strtolower($type)) {
        case 'css': ?>
        <link rel="stylesheet" type="text/css" href="<?php echo $url; ?>"/><?php
            break;
        case 'js': ?>
        <script type="text/javascript" src="<?php echo $url; ?>"></script><?php
            break;
        }
    }

    /**
     * getState
     *
     * Retrieves an array of information which can be passed to the
     * ::loadState method later to recreate the current state of the form
     * fields and values.
     */
    function getState() {
        $info = array();
        foreach ($this->getFields() as $f) {
            // Skip invisible fields
            if (!$f->isVisible())
                continue;

            // Skip fields set to default values
            $v = $f->getClean();
            $d = $f->get('default');
            if ($v == $d)
                continue;

            // Skip empty values
            if (!$v)
                continue;

            $info[$f->get('name') ?: $f->get('id')] = $f->to_database($v);
        }
        return $info;
    }

    /**
     * loadState
     *
     * Reset this form to the state previously recorded by the ::getState()
     * method
     */
    function loadState($state) {
        foreach ($this->getFields() as $f) {
            $name = $f->get('name');
            $f->reset();
            if (isset($state[$name])) {
                $f->value = $f->to_php($state[$name]);
            }
        }
    }

    /*
     * Initialize a generic static form
     */
    static function instantiate() {
        $r = new ReflectionClass(get_called_class());
        return $r->newInstanceArgs(func_get_args());
    }
}

/**
 * SimpleForm
 * Wrapper for inline/static forms.
 *
 */
class SimpleForm extends Form {
    function __construct($fields=array(), $source=null, $options=array()) {
        parent::__construct($source, $options);
        if (isset($options['type']))
            $this->type = $options['type'];
        $this->setFields($fields);
    }

    function getId() {
        return $this->getFormId();
    }
}

class CustomForm extends SimpleForm {

    function getFields() {
        global $thisstaff, $thisclient;

        $options = $this->options;
        $user = $options['user'] ?: $thisstaff ?: $thisclient;
        $isedit = ($options['mode'] == 'edit');
        $fields = array();
        foreach (parent::getFields() as $field) {
            if ($isedit && !$field->isEditable($user))
                continue;

            $fields[] = $field;
        }

        return $fields;
    }
}

abstract class AbstractForm extends Form {
    function __construct($source=null, $options=array()) {
        parent::__construct($source, $options);
        $this->setFields($this->buildFields());
    }
    /**
     * Fetch the fields defined for this form. This method is only called
     * once.
     */
    abstract function buildFields();
}

/**
 * Container class to represent the connection between the form fields and the
 * rendered state of the form.
 */
interface FormRenderer {
    // Render the form fields into a table
    function asTable($form);
    // Render the form fields into divs
    function asBlock($form);
}

abstract class FormLayout {
    static $default_cell_layout = 'Cell';

    var $title;
    var $options;

    function __construct($title=false, $options=array()) {
        $this->title = $title;
        $this->options = $options;
    }

    function getLayout($field) {
        $layout = $field->get('layout') ?: static::$default_cell_layout;
        if (is_string($layout))
            $layout = new $layout();
        return $layout;
    }
}

class GridFluidLayout
extends FormLayout
implements FormRenderer {
    function asTable($form) {
      ob_start();
?>
      <table class="<?php echo 'grid form' ?>">
          <caption><?php echo Format::htmlchars($this->title ?: $form->getTitle()); ?>
            <div><small><?php echo Format::viewableImages($form->getInstructions()); ?></small></div>
            <?php
            if ($form->getNotice())
                echo sprintf('<div><small><p id="msg_warning">%s</p></small></div>',
                        Format::htmlchars($form->getNotice()));
            ?>
        </caption>
        <tbody><tr><?php for ($i=0; $i<12; $i++) echo '<td style="width:8.3333%"/>'; ?></tr></tbody>
<?php
      $row_size = 12;
      $cols = $row = 0;

      //Layout and rendering options
      $options = $this->options;

      foreach ($form->getFields() as $f) {
          $layout = $this->getLayout($f);
          $size = $layout->getWidth() ?: 12;
          if ($offs = $layout->getOffset()) {
              $size += $offs;
          }
          if ($cols < $size || $layout->isBreakForced()) {
              if ($row) echo '</tr>';
              echo '<tr>';
              $cols = $row_size;
              $row++;
          }
          // Render the cell
          $cols -= $size;
          $attrs = array('colspan' => $size, 'rowspan' => $layout->getHeight(),
              'style' => '"'.$layout->getOption('style').'"');
          if ($offs) { ?>
              <td colspan="<?php echo $offs; ?>"></td> <?php
          }
          ?>
          <td class="cell" <?php echo Format::array_implode('=', ' ', array_filter($attrs)); ?>
              data-field-id="<?php echo $f->get('id'); ?>">
              <fieldset class="field <?php if (!$f->isVisible()) echo 'hidden'; ?>"
                id="field<?php echo $f->getWidget()->id; ?>"
                data-field-id="<?php echo $f->get('id'); ?>">
<?php         $label = $f->get('label'); ?>
              <label class="<?php if ($f->isRequired()) echo 'required'; ?>"
                  for="<?php echo $f->getWidget()->id; ?>">
                  <?php echo $label ? (Format::htmlchars($label).':') : '&nbsp;'; ?>
                <?php if ($f->isRequired()) { ?>
                <span class="error">*</span>
              </label>
<?php         }
              if ($f->get('hint')) { ?>
                  <div class="field-hint-text">
                      <?php echo Format::htmlchars($f->get('hint')); ?>
                  </div>
<?php         }
              $f->render($options);
              if ($f->errors())
                  foreach ($f->errors() as $e)
                      echo sprintf('<div class="error">%s</div>', Format::htmlchars($e));
?>
              </fieldset>
          </td>
      <?php
      }
      if ($row)
        echo  '</tr>';

      echo '</tbody></table>';

      return ob_get_clean();
    }

    function asBlock($form) {}
}

/**
 * Basic container for field and form layouts. By default every cell takes
 * a whole output row and does not imply any sort of width.
 */
class Cell {
    function isBreakForced()  { return true; }
    function getWidth()       { return false; }
    function getHeight()      { return 1; }
    function getOffset()      { return 0; }
    function getOption($prop) { return false; }
}

/**
 * Fluid grid layout, meaning each cell renders to the right of the previous
 * cell (for left-to-right layouts). A width in columns can be specified for
 * each cell along with an offset from the previous cell. A height of columns
 * along with an optional break is supported.
 */
class GridFluidCell
extends Cell {
    var $span;
    var $options;

    function __construct($span, $options=array()) {
        $this->span = $span;
        $this->options = $options + array(
            'rows' => 1,        # rowspan
            'offset' => 0,      # skip some columns
            'break' => false,   # start on a new row
        );
    }

    function isBreakForced()  { return $this->options['break']; }
    function getWidth()       { return $this->span; }
    function getHeight()      { return $this->options['rows']; }
    function getOffset()      { return $this->options['offset']; }
    function getOption($prop) { return $this->options[$prop]; }
}

require_once(INCLUDE_DIR . "class.json.php");

class FormField {
    static $widget = false;

    var $ht = array(
        'label' => false,
        'required' => false,
        'default' => false,
        'configuration' => array(),
    );

    var $_form;
    var $_cform;
    var $_clean;
    var $_errors = array();
    var $_widget;
    var $answer;
    var $parent;
    var $presentation_only = false;

    static $types = array(
        /* @trans */ 'Basic Fields' => array(
            'text'  => array(   /* @trans */ 'Short Answer', 'TextboxField'),
            'memo' => array(    /* @trans */ 'Long Answer', 'TextareaField'),
            'thread' => array(  /* @trans */ 'Thread Entry', 'ThreadEntryField', false),
            'datetime' => array(/* @trans */ 'Date and Time', 'DatetimeField'),
            'timezone' => array(/* @trans */ 'Timezone', 'TimezoneField'),
            'phone' => array(   /* @trans */ 'Phone Number', 'PhoneField'),
            'bool' => array(    /* @trans */ 'Checkbox', 'BooleanField'),
            'choices' => array( /* @trans */ 'Choices', 'ChoiceField'),
            'files' => array(   /* @trans */ 'File Upload', 'FileUploadField'),
            'break' => array(   /* @trans */ 'Section Break', 'SectionBreakField'),
            'info' => array(    /* @trans */ 'Information', 'FreeTextField'),
        ),
    );
    static $more_types = array();
    static $uid = null;

    static function _uid() {
        return ++self::$uid;
    }

    function __construct($options=array()) {
        $this->ht = array_merge($this->ht, $options);
        if (!isset($this->ht['id']))
            $this->ht['id'] = self::_uid();
    }

    function __clone() {
        $this->_widget = null;
        $this->ht['id'] = self::_uid();
    }

    static function addFieldTypes($group, $callable) {
        static::$more_types[$group][] = $callable;
    }

    static function allTypes() {
        if (static::$more_types) {
            foreach (static::$more_types as $group => $entries)
                foreach ($entries as $c)
                    static::$types[$group] = array_merge(
                            static::$types[$group] ?? array(), call_user_func($c));

            static::$more_types = array();
        }
        return static::$types;
    }

    static function getFieldType($type) {
        foreach (static::allTypes() as $group=>$types)
            if (isset($types[$type]))
                return $types[$type];
    }

    function get($what, $default=null) {
        return array_key_exists($what, $this->ht)
            ? $this->ht[$what]
            : $default;
    }
    function set($field, $value) {
        $this->ht[$field] = $value;
    }

    function getId() {
        return $this->ht['id'];
    }

    /**
     * getClean
     *
     * Validates and cleans inputs from POST request. This is performed on a
     * field instance, after a DynamicFormSet / DynamicFormSection is
     * submitted via POST, in order to kick off parsing and validation of
     * user-entered data.
     */
    function getClean($validate=true) {
        if (!isset($this->_clean)) {
            $this->_clean = (isset($this->value))
                // XXX: The widget value may be parsed already if this is
                //      linked to dynamic data via ::getAnswer()
                ? $this->value : $this->parse($this->getWidget()->value);

            if ($vs = $this->get('cleaners')) {
                if (is_array($vs)) {
                    foreach ($vs as $cleaner)
                        if (is_callable($cleaner))
                            $this->_clean = call_user_func_array(
                                    $cleaner, array($this, $this->_clean));
                }
                elseif (is_callable($vs))
                    $this->_clean = call_user_func_array(
                            $vs, array($this, $this->_clean));
            }

            if (!isset($this->_clean) && ($d = $this->get('default')))
                $this->_clean = $d;

            if ($this->isVisible() && $validate)
                $this->validateEntry($this->_clean);
        }
        return $this->_clean;
    }

    function reset() {
        $this->value = $this->_clean = $this->_widget = null;
    }

    function getValue() {
        return $this->getWidget()->getValue();
    }

    function errors() {
        return $this->_errors;
    }

    function resetErrors() {
        $this->_errors = [];
        return !($this->_errors);
    }

    function addError($message, $index=false) {
        if ($index)
            $this->_errors[$index] = $message;
        else
            $this->_errors[] = $message;

        // Update parent form errors for the field
        if ($this->_form)
            $this->_form->addError($this->errors(), $this->get('id'));
    }

    function isValidEntry() {
        $this->validateEntry();
        return count($this->_errors) == 0;
    }

    /**
     * validateEntry
     *
     * Validates user entry on an instance of the field on a dynamic form.
     * This is called when an instance of this field (like a TextboxField)
     * receives data from the user and that value should be validated.
     *
     * Parameters:
     * $value - (string) input from the user
     */
    function validateEntry($value) {
        if (!$value && count($this->_errors))
            return;

        # Validates a user-input into an instance of this field on a dynamic
        # form
        if ($this->get('required') && !$value && $this->hasData())
            $this->_errors[] = $this->getLocal('label')
                ? sprintf(__('%s is a required field'), $this->getLocal('label'))
                : __('This is a required field');

        # Perform declared validators for the field
        if ($vs = $this->get('validators')) {
            if (is_array($vs)) {
                foreach ($vs as $validator)
                    if (is_callable($validator))
                        $validator($this, $value);
            }
            elseif (is_callable($vs))
                $vs($this, $value);
        }
    }

    /**
     * isVisible
     *
     * If this field has visibility configuration, then it will parse the
     * constraints with the visibility configuration to determine if the
     * field is visible and should be considered for validation
     */
    function isVisible() {
        if ($this->get('visibility') instanceof VisibilityConstraint) {
            return $this->get('visibility')->isVisible($this);
        }
        return true;
    }

    /**
     * Check if the user has edit rights
     *
     */

    function isEditable($user=null) {

        // Internal editable flag used by internal forms e.g internal lists
        if (!$user && isset($this->ht['editable']))
            return $this->ht['editable'];

        if ($user instanceof Staff)
            $flag = DynamicFormField::FLAG_AGENT_EDIT;
        else
            $flag = DynamicFormField::FLAG_CLIENT_EDIT;

        return (($this->get('flags') & $flag) != 0);
    }


    /**
     * isStorable
     *
     * Indicate if this field data is storable locally (default).Some field's data
     * might beed to be stored elsewhere for optimization reasons at the
     * application level.
     *
     */

    function isStorable() {
        return (($this->get('flags') & DynamicFormField::FLAG_EXT_STORED) == 0);
    }

    function isRequired() {
        return $this->get('required');
    }

    /**
     * parse
     *
     * Used to transform user-submitted data to a PHP value. This value is
     * not yet considered valid. The ::validateEntry() method will be called
     * on the value to determine if the entry is valid. Therefore, if the
     * data is clearly invalid, return something like NULL that can easily
     * be deemed invalid in ::validateEntry(), however, can still produce a
     * useful error message indicating what is wrong with the input.
     */
    function parse($value) {
        return is_string($value) ? trim($value) : $value;
    }

    /**
     * to_php
     *
     * Transforms the data from the value stored in the database to a PHP
     * value. The ::to_database() method is used to produce the database
     * valse, so this method is the compliment to ::to_database().
     *
     * Parameters:
     * $value - (string or null) database representation of the field's
     *      content
     */
    function to_php($value) {
        return $value;
    }

    /**
     * to_config
     *
     * Transform the data from the value to config form (as determined by
     * field). to_php is used for each field returned from
     * ::getConfigurationOptions(), and when the whole configuration is
     * built, to_config() is called and receives the config array. The array
     * should be returned, perhaps with modifications, and will be JSON
     * encoded and stashed in the database.
     */
    function to_config($value) {
        return $value;
    }

    /**
     * to_database
     *
     * Determines the value to be stored in the database. The database
     * backend for all fields is a text field, so this method should return
     * a text value or NULL to represent the value of the field. The
     * ::to_php() method will convert this value back to PHP.
     *
     * Paremeters:
     * $value - PHP value of the field's content
     */
    function to_database($value) {
        return $value;
    }

    /**
     * toString
     *
     * Converts the PHP value created in ::parse() or ::to_php() to a
     * pretty-printed value to show to the user. This is especially useful
     * for something like dates which are stored considerably different in
     * the database from their respective human-friendly versions.
     * Furthermore, this method allows for internationalization and
     * localization.
     *
     * Parametes:
     * $value - PHP value of the field's content
     */
    function toString($value) {
        return (string) $value;
    }

    function __toString() {
        return $this->toString($this->value);
    }

    /**
     * When data for this field is deleted permanently from some storage
     * backend (like a database), other associated data may need to be
     * cleaned as well. This hook allows fields to participate when the data
     * for a field is cleaned up.
     */
    function db_cleanup($field=false) {
    }

    /**
     * Returns an HTML friendly value for the data in the field.
     */
    function display($value) {
        return Format::htmlchars($this->toString($value ?: $this->value));
    }

    /**
     * Returns a value suitable for exporting to a foreign system. Mostly
     * useful for things like dates and phone numbers which should be
     * formatted using a standard when exported
     */
    function export($value) {
        return $this->toString($value);
    }

    /**
     * Fetch a value suitable for embedding the value of this field in an
     * email template. Reference implementation uses ::to_php();
     */
    function asVar($value, $id=false) {
        return $this->to_php($value, $id);
    }

    /**
     * Fetch the var type used with the email templating system's typeahead
     * feature. This helps with variable expansion if supported by this
     * field's ::asVar() method. This method should return a valid classname
     * which implements the `TemplateVariable` interface.
     */
    function asVarType() {
        return false;
    }

    /**
     * Describe the difference between the to two values. Note that the
     * values should be passed through ::parse() or to_php() before
     * utilizing this method.
     */
    function whatChanged($before, $after) {
        if ($before)
            $desc = __('changed from <strong>%1$s</strong> to <strong>%2$s</strong>');
        else
            $desc = __('set to <strong>%2$s</strong>');
        return sprintf($desc, $this->display($before), $this->display($after));
    }

    /**
     * Convert the field data to something matchable by filtering. The
     * primary use of this is for ticket filtering.
     */
    function getFilterData() {
        return $this->toString($this->getClean());
    }

    /**
     * Fetches a value that represents this content in a consistent,
     * searchable format. This is used by the search engine system and
     * backend.
     */
    function searchable($value) {
        return Format::searchable($this->toString($value));
    }

    function getKeys($value) {
        return $this->to_database($value);
    }

    /**
     * Fetches a list of options for searching. The values returned from
     * this method are passed to the widget's `::render()` method so that
     * the widget can be affected by this setting. For instance, date fields
     * might have a 'between' search option which should trigger rendering
     * of two date widgets for search results.
     */
    function getSearchMethods() {
        return array(
            'set' =>        __('has a value'),
            'nset' =>       __('does not have a value'),
            'equal' =>      __('is'),
            'nequal' =>     __('is not'),
            'contains' =>   __('contains'),
            'match' =>      __('matches'),
        );
    }

    function getSearchMethodWidgets() {
        return array(
            'set' => null,
            'nset' => null,
            'equal' => array('TextboxField', array('configuration' => array('size' => 40))),
            'nequal' => array('TextboxField', array('configuration' => array('size' => 40))),
            'contains' => array('TextboxField', array('configuration' => array('size' => 40))),
            'match' => array('TextboxField', array(
                'placeholder' => __('Valid regular expression'),
                'configuration' => array('size'=>30),
                'validators' => function($self, $v) {
                    if (false === @preg_match($v, ' ')
                        && false === @preg_match("/$v/", ' '))
                        $self->addError(__('Cannot compile this regular expression'));
                })),
        );
    }

    /**
     * This is used by the searching system to build a query for the search
     * engine. The function should return a criteria listing to match
     * content saved by the field by the `::to_database()` function.
     */
    function getSearchQ($method, $value, $name=false) {
        $criteria = array();
        $Q = new Q();
        $name = $name ?: $this->get('name');
        switch ($method) {
            case 'nset':
                $Q->negate();
            case 'set':
                $criteria[$name . '__isnull'] = false;
                break;

            case 'nequal':
                $Q->negate();
            case 'equal':
                $criteria[$name] = $value;
                break;

            case 'contains':
                $criteria[$name . '__contains'] = $value;
                break;

            case 'match':
                $criteria[$name . '__regex'] = $value;
                break;
        }
        return $Q->add($criteria);
    }

    function getSearchWidget($method) {
        $methods = $this->getSearchMethodWidgets();
        $info = $methods[$method];
        if (is_array($info)) {
            $class = $info[0];
            return new $class($info[1]);
        }
        return $info;
    }

    function describeSearchMethod($method) {
        switch ($method) {
        case 'set':
            return __('%s has a value');
        case 'nset':
            return __('%s does not have a value');
        case 'equal':
            return __('%s is %s' /* describes an equality */);
        case 'nequal':
            return __('%s is not %s' /* describes an inequality */);
        case 'contains':
            return __('%s contains "%s"');
        case 'match':
            return __('%s matches pattern %s');
        case 'includes':
            return __('%s in (%s)');
        case '!includes':
            return __('%s not in (%s)');
        }
    }

    function describeSearch($method, $value, $name=false) {
        $name = $name ?: $this->get('name');
        $desc = $this->describeSearchMethod($method);
        switch ($method) {
            case 'set':
            case 'nset':
                return sprintf($desc, $name);
            default:
                 return sprintf($desc, $name, $this->toString($value));
        }
    }

    function addToQuery($query, $name=false) {
        return $query->values($name ?: $this->get('name'));
    }

    /**
     * Similary to to_php() and parse(), except a row from a queryset is
     * passed. The value returned should be what would be retured from
     * parse() or to_php()
     */
    function from_query($row, $name=false) {
        return $row[$name ?: $this->get('name')];
    }

    /**
     * If the field can be used in a quick filter. To be used, it should
     * also implement getQuickFilterChoices() which should return a list of
     * choices to appear in a quick filter drop-down
     */
    function supportsQuickFilter() {
        return false;
    }

    /**
     * Fetch a keyed array of quick filter choices. The keys should be
     * passed later to ::applyQuickFilter() to apply the quick filter to a
     * query. The values should be localized titles for the choices.
     */
    function getQuickFilterChoices() {
        return array();
    }

    /**
     * Apply a quick filter selection of this field to the query. The
     * modified query should be returned. Optionally, the orm path / field
     * name can be passed.
     */
    function applyQuickFilter($query, $choice, $name=false) {
        return $query;
    }

    function getLabel() { return $this->get('label'); }

    function getSortKeys($path) {
        return array($path);
    }

    function getOrmPath($name=false, $query=null) {
        return CustomQueue::getOrmPath($name ?:$this->get('name'), $query);
    }

    function applyOrderBy($query, $reverse=false, $name=false) {
        $col = sprintf('%s%s',
                $reverse ? '-' : '',
                $this->getOrmPath($name, $query));
        return $query->order_by($col);
    }

    /**
     * getImpl
     *
     * Magic method that will return an implementation instance of this
     * field based on the simple text value of the 'type' value of this
     * field instance. The list of registered fields is determined by the
     * global get_dynamic_field_types() function. The data from this model
     * will be used to initialize the returned instance.
     *
     * For instance, if the value of this field is 'text', a TextField
     * instance will be returned.
     */
    function getImpl($parent=null) {
        // Allow registration with ::addFieldTypes and delayed calling
        $type = static::getFieldType($this->get('type'));
        $clazz = $type[1];
        $inst = new $clazz($this->ht);
        $inst->parent = $parent;
        $inst->setForm($this->_form);
        return $inst;
    }

    function __call($what, $args) {
        // XXX: Throw exception if $this->parent is not set
        if (!$this->parent)
            throw new Exception(sprintf(__('%s: Call to undefined function'),
                $what));
        // BEWARE: DynamicFormField has a __call() which will create a new
        //      FormField instance and invoke __call() on it or bounce
        //      immediately back
        return call_user_func_array(
            array($this->parent, $what), $args);
    }

    function getAnswer() { return $this->answer; }
    function setAnswer($ans) { $this->answer = $ans; }

    function setValue($value) {
        $this->reset();
        $this->getWidget()->value = $value;
    }

    /**
     * Fetch a pseudo-random id for this form field. It is used when
     * rendering the widget in the @name attribute emitted in the resulting
     * HTML. The form element is based on the form id, field id and name,
     * and the current user's session id. Therefore, the same form fields
     * will yield differing names for different users. This is used to ward
     * off bot attacks as it makes it very difficult to predict and
     * correlate the form names to the data they represent.
     */
    function getFormName() {
        $default = $this->get('name') ?: $this->get('id');
        if ($this->_form && is_numeric($fid = $this->_form->getFormId()))
            return substr(md5(
                session_id() . "-form-field-id-$fid-$default-" . SECRET_SALT), -14);
        elseif (is_numeric($this->get('id')))
            return substr(md5(
                session_id() . '-field-id-'.$this->get('id') . '-' . SECRET_SALT), -16);

        return $default;
    }

    function getFormNames() {

        // All possible names - this is important for inline data injection
        $names = array_filter([
                'hash' => $this->getFormName(),
                'name' => $this->get('name'),
                'id' => $this->get('id')]);

        // Force pseudo-random name for Dynamicforms on POST (Web Tickets)
        if (0 && $_POST
                && !defined('APICALL')
                && isset($this->ht['form'])
                && ($this->ht['form'] instanceof DynamicForm))
            return [$names['hash']];

        return $names;
    }

    function setForm($form) {
        $this->_form = $form;
    }
    function getForm() {
        return $this->_form;
    }
    /**
     * Returns the data source for this field. If created from a form, the
     * data source from the form is returned. Otherwise, if the request is a
     * POST, then _POST is returned.
     */
    function getSource() {
        if ($this->_form)
            return $this->_form->getSource();
        elseif ($_SERVER['REQUEST_METHOD'] == 'POST')
            return $_POST;
        else
            return array();
    }

    function render($options=array()) {
        $rv = $this->getWidget()->render($options);
        if ($v = $this->get('visibility')) {
            $v->emitJavascript($this);
        }
        return $rv;
    }

    function renderExtras($options=array()) {
        return;
    }

    function getMedia() {
        $widget = $this->getWidget();
        return $widget::$media;
    }

    function getConfigurationOptions() {
        return array();
    }

    /**
     * getConfiguration
     *
     * Loads configuration information from database into hashtable format.
     * Also, the defaults from ::getConfigurationOptions() are integrated
     * into the database-backed options, so that if options have not yet
     * been set or a new option has been added and not saved for this field,
     * the default value will be reflected in the returned configuration.
     */
    function getConfiguration() {
        if (!isset($this->_config)) {
            $this->_config = $this->get('configuration');
            if (is_string($this->_config))
                $this->_config = JsonDataParser::parse($this->_config);
            elseif (!$this->_config)
                $this->_config = array();
            foreach ($this->getConfigurationOptions() as $name=>$field)
                if (!isset($this->_config[$name]))
                    $this->_config[$name] = $field->get('default');
        }
        return $this->_config;
    }

    /**
     * If the [Config] button should be shown to allow for the configuration
     * of this field
     */
    function isConfigurable() {
        return true;
    }

    /**
     * Field type is changeable in the admin interface
     */
    function isChangeable() {
        return true;
    }

    /**
     * Field does not contain data that should be saved to the database. Ie.
     * non data fields like section headers
     */
    function hasData() {
        return true;
    }

    /**
     * Returns true if the field/widget should be rendered as an entire
     * block in the target form.
     */
    function isBlockLevel() {
        return false;
    }

    /**
     * Fields should not be saved with the dynamic data. It is assumed that
     * some static processing will store the data elsewhere.
     */
    function isPresentationOnly() {
        return $this->presentation_only;
    }

    /**
     * Indicates if the field places data in the `value_id` column. This
     * is currently used by the materialized view system
     */
    function hasIdValue() {
        return false;
    }

    /**
     * Indicates if the field has subfields accessible via getSubFields()
     * method. Useful for filter integration. Should connect with
     * getFilterData()
     */
    function hasSubFields() {
        return false;
    }
    function getSubFields() {
        return null;
    }

    function getConfigurationForm($source=null) {
        if (!$this->_cform) {
            $type = static::getFieldType($this->get('type'));
            $clazz = $type[1];
            $T = new $clazz($this->ht);
            $config = $this->getConfiguration();
            $this->_cform = new SimpleForm($T->getConfigurationOptions(), $source);
            if (!$source) {
                foreach ($this->_cform->getFields() as $name=>$f) {
                    if ($config && isset($config[$name]))
                        $f->value = $config[$name];
                    elseif ($f->get('default'))
                        $f->value = $f->get('default');
                }
            }
        }
        return $this->_cform;
    }

    function configure($prop, $value) {
        $this->getConfiguration();
        $this->_config[$prop] = $value;
    }

    function getWidget($widgetClass=false) {
        if (!static::$widget)
            throw new Exception(__('Widget not defined for this field'));
        if (!isset($this->_widget)) {
            $wc = $widgetClass ?: $this->get('widget') ?: static::$widget;
            $this->_widget = new $wc($this);
            $this->_widget->parseValue();
        }
        return $this->_widget;
    }

    function getSelectName() {
        $name = $this->get('name') ?: 'field_'.$this->get('id');
        if ($this->hasIdValue())
            $name .= '_id';

        return $name;
    }

    function getTranslateTag($subtag) {
        return _H(sprintf('field.%s.%s%s', $subtag, $this->get('id'),
            $this->get('form_id') ? '' : '*internal*'));
    }

    function getLocal($subtag, $default=false) {
        $tag = $this->getTranslateTag($subtag);
        $T = CustomDataTranslation::translate($tag);
        return $T != $tag ? $T : ($default ?: $this->get($subtag));
    }

    function getEditForm($source=null) {
        $fields = array(
                'field' => $this,
                'comments' => new TextareaField(array(
                        'id' => 2,
                        'label'=> '',
                        'required' => false,
                        'default' => '',
                        'configuration' => array(
                            'html' => true,
                            'size' => 'small',
                            'placeholder' => __('Optional reason for the update'),
                            )
                        ))
                );

        return new SimpleForm($fields, $source);
    }

    function getChanges() {
        $new = $this->getValue();
        $old = $this->answer ? $this->answer->getValue() : $this->get('default');
        return ($old != $new) ? array($this->to_database($old), $this->to_database($new)) : false;
    }


    function save() {

        if (!($changes=$this->getChanges()))
            return true;

        if (!($a = $this->answer))
            return false;

        $val = $changes[1];
        if (is_array($val)) {
            $a->set('value', $val[0]);
            $a->set('value_id', $val[1]);
        } else {
            $a->set('value', $val);
        }

        if (!$a->save(true))
            return false;

        $this->_clean = $this->_widget = null;
        return $this->parent->save();
    }


    static function init($config) {
        return new Static($config);
    }
}

class TextboxField extends FormField {
    static $widget = 'TextboxWidget';

    function getConfigurationOptions() {
        return array(
            'size'  =>  new TextboxField(array(
                'id'=>1, 'label'=>__('Size'), 'required'=>false, 'default'=>16,
                    'validator' => 'number')),
            'length' => new TextboxField(array(
                'id'=>2, 'label'=>__('Max Length'), 'required'=>false, 'default'=>30,
                    'validator' => 'number')),
            'validator' => new ChoiceField(array(
                'id'=>3, 'label'=>__('Validator'), 'required'=>false, 'default'=>'',
                'choices' => array('phone'=>__('Phone Number'),'email'=>__('Email Address'),
                    'ip'=>__('IP Address'), 'number'=>__('Number'),
                    'regex'=>__('Custom (Regular Expression)'), ''=>__('None')))),
            'regex' => new TextboxField(array(
                'id'=>6, 'label'=>__('Regular Expression'), 'required'=>true,
                'configuration'=>array('size'=>40, 'length'=>100),
                'visibility' => new VisibilityConstraint(
                    new Q(array('validator__eq'=>'regex')),
                    VisibilityConstraint::HIDDEN
                ),
                'cleaners' => function ($self, $value) {
                    $wrapped = "/".$value."/iu";
                    if (false === @preg_match($value, ' ')
                            && false !== @preg_match($wrapped, ' ')) {
                        $value = $wrapped;
                    }
                    if ($value == '//iu')
                        return '';

                    return $value;
                },
                'validators' => function($self, $v) {
                    if (false === @preg_match($v, ' '))
                        $self->addError(__('Cannot compile this regular expression'));
                })),
            'validator-error' => new TextboxField(array(
                'id'=>4, 'label'=>__('Validation Error'), 'default'=>'',
                'configuration'=>array('size'=>40, 'length'=>60,
                    'translatable'=>$this->getTranslateTag('validator-error')
                ),
                'hint'=>__('Message shown to user if the input does not match the validator'))),
            'placeholder' => new TextboxField(array(
                'id'=>5, 'label'=>__('Placeholder'), 'required'=>false, 'default'=>'',
                'hint'=>__('Text shown in before any input from the user'),
                'configuration'=>array('size'=>40, 'length'=>40,
                    'translatable'=>$this->getTranslateTag('placeholder')
                ),
            )),
        );
    }

    function validateEntry($value) {
        //check to see if value is the string '0'
        $value = ($value === '0') ? '&#48' : Format::htmlchars($this->toString($value ?: $this->value));
        parent::validateEntry($value);
        $config = $this->getConfiguration();
        $validators = array(
            '' => '',
            'noop' => array(
                function($a, &$b) { return true; }
            ),
            'formula' => array(array('Validator', 'is_formula'),
                __('Content cannot start with the following characters: = - + @')),
            'email' =>  array(array('Validator', 'is_valid_email'),
                __('Enter a valid email address')),
            'phone' =>  array(array('Validator', 'is_phone'),
                __('Enter a valid phone number')),
            'ip' =>     array(array('Validator', 'is_ip'),
                __('Enter a valid IP address')),
            'number' => array(array('Validator', 'is_numeric'),
                __('Enter a number')),
            'password' => array(array('Validator', 'check_passwd'),
                __('Invalid Password')),
            'regex' => array(
                function($v) use ($config) {
                    $regex = $config['regex'];
                    return @preg_match($regex, $v);
                }, $config['validator-error'] ?? __('Value does not match required pattern')
            ),
        );
        // Support configuration forms, as well as GUI-based form fields
        $valid = $this->get('validator');
        if (!$valid) {
            $valid = $config['validator'];
        }
        if (!$value || !isset($validators[$valid]))
            return;
        // If no validators are set and not an instanceof AdvancedSearchForm
        // force formula validation
        if (!$valid && !($this->getForm() instanceof AdvancedSearchForm))
            $valid = 'formula';
        $func = $validators[$valid];
        $error = $err = null;
        // If validator is number and the value is &#48 set to 0 (int) for is_numeric
        if ($valid == 'number' && $value == '&#48')
            $value = 0;
        if ($config['validator-error'])
            $error = $this->getLocal('validator-error', $config['validator-error']);
        if (is_array($func) && is_callable($func[0]))
            if (!call_user_func_array($func[0], array($value, &$err)))
                $this->_errors[] =  $error ?: $err ?: $func[1];
    }

    function parse($value) {
        return Format::strip_emoticons(Format::striptags($value));
    }

    function display($value) {
        return ($value === '0') ? '&#48;' : Format::htmlchars($this->toString($value ?: $this->value), true);
    }
}

class PasswordField extends TextboxField {
    static $widget = 'PasswordWidget';

    function __construct($options=array()) {
        parent::__construct($options);
        if (!isset($options['validator']))
            $this->set('validator', 'password');
    }

    protected function getMasterKey() {
        return SECRET_SALT;
    }

    protected function getSubKey() {
        $config = $this->getConfiguration();
        return $config['key'] ?: 'pwfield';
    }

    function parse($value) {
        // Don't trim the value
        return $value;
    }

    function to_database($value) {
        // If not set in UI, don't save the empty value
        if (!$value)
            throw new FieldUnchanged();
        return Crypto::encrypt($value, $this->getMasterKey(),
                $this->getSubKey());
    }

    function to_php($value) {
        return Crypto::decrypt($value, $this->getMasterKey(),
                $this->getSubKey());
    }
}

class TextareaField extends FormField {
    static $widget = 'TextareaWidget';

    function getConfigurationOptions() {
        return array(
            'cols'  =>  new TextboxField(array(
                'id'=>1, 'label'=>__('Width').' '.__('(chars)'), 'required'=>true, 'default'=>40)),
            'rows'  =>  new TextboxField(array(
                'id'=>2, 'label'=>__('Height').' '.__('(rows)'), 'required'=>false, 'default'=>4)),
            'length' => new TextboxField(array(
                'id'=>3, 'label'=>__('Max Length'), 'required'=>false, 'default'=>0)),
            'html' => new BooleanField(array(
                'id'=>4, 'label'=>__('HTML'), 'required'=>false, 'default'=>true,
                'configuration'=>array('desc'=>__('Allow HTML input in this box')))),
            'placeholder' => new TextboxField(array(
                'id'=>5, 'label'=>__('Placeholder'), 'required'=>false, 'default'=>'',
                'hint'=>__('Text shown in before any input from the user'),
                'configuration'=>array('size'=>40, 'length'=>40,
                    'translatable'=>$this->getTranslateTag('placeholder')),
            )),
        );
    }

    function validateEntry($value) {
        parent::validateEntry($value);
        if (!$value)
            return;
        $config = $this->getConfiguration();
        $validators = array(
            '' =>       array(array('Validator', 'is_formula'),
                __('Content cannot start with the following characters: = - + @')),
            'choices' => array(
                function($val) {
                    $val = str_replace('"', '', JsonDataEncoder::encode($val));
                    $regex = "/^(?! )[A-z0-9 _-]+:{1}[^\n]+$/";
                    foreach (explode('\r\n', $val) as $v) {
                        if (!preg_match($regex, $v))
                            return false;
                    }
                    return true;
                }, __('Each choice requires a key and has to be on a new line. (eg. key:value)')
            ),
        );
        // Support configuration forms, as well as GUI-based form fields
        if (!($valid = $this->get('validator')) && isset($config['validator']))
            $valid = $config['validator'];

        if (!isset($validators[$valid]))
            return;

        $func = $validators[$valid];
        $error = $func[1];
        if ($config['validator-error'])
            $error = $this->getLocal('validator-error', $config['validator-error']);
        if (is_array($func) && is_callable($func[0]))
            if (!call_user_func($func[0], $value))
                $this->_errors[] = $error;
    }

    function display($value) {
        $config = $this->getConfiguration();
        if ($config['html'])
            return Format::safe_html($value);
        else
            return nl2br(Format::htmlchars($value, true));
    }

    function searchable($value) {
        $body = new HtmlThreadEntryBody($value);
        return $body->getSearchable();
    }

    function export($value) {
        return (!$value) ? $value : Format::html2text($value);
    }

    function parse($value) {
        $config = $this->getConfiguration();
        if ($config['html'])
            return Format::sanitize($value);
        else
            return $value;
    }

}

class PhoneField extends FormField {
    static $widget = 'PhoneNumberWidget';

    function getConfigurationOptions() {
        return array(
            'ext' => new BooleanField(array(
                'label'=>__('Extension'), 'default'=>true,
                'configuration'=>array(
                    'desc'=>__('Add a separate field for the extension'),
                ),
            )),
            'digits' => new TextboxField(array(
                'label'=>__('Minimum length'), 'default'=>7,
                'hint'=>__('Fewest digits allowed in a valid phone number'),
                'configuration'=>array('validator'=>'number', 'size'=>5),
            )),
            'format' => new ChoiceField(array(
                'label'=>__('Display format'), 'default'=>'us',
                'choices'=>array(''=>'-- '.__('Unformatted').' --',
                    'us'=>__('United States')),
            )),
        );
    }

    function validateEntry($value) {
        parent::validateEntry($value);
        $config = $this->getConfiguration();
        # Run validator against $this->value for email type
        list($phone, $ext) = explode("X", $value, 2);
        if ($phone && (
                !is_numeric($phone) ||
                strlen($phone) < $config['digits']))
            $this->_errors[] = __("Enter a valid phone number");
        if ($ext && $config['ext']) {
            if (!is_numeric($ext))
                $this->_errors[] = __("Enter a valid phone extension");
            elseif (!$phone)
                $this->_errors[] = __("Enter a phone number for the extension");
        }
    }

    function parse($value) {
        // NOTE: Value may have a legitimate 'X' to separate the number and
        // extension parts. Don't remove the 'X'
        $val = preg_replace('/[^\dX]/', '', $value);
        // Pass completely-incorrect string for validation error
        return $val ?: $value;
    }

    function toString($value) {
        $config = $this->getConfiguration();
        list($phone, $ext) = explode("X", $value, 2);
        switch ($config['format']) {
        case 'us':
            $phone = Format::phone($phone);
            break;
        }
        if ($ext)
            $phone.=" x$ext";
        return $phone;
    }
}

class BooleanField extends FormField {
    static $widget = 'CheckboxWidget';

    function getConfigurationOptions() {
        return array(
            'desc' => new TextareaField(array(
                'id'=>1, 'label'=>__('Description'), 'required'=>false, 'default'=>'',
                'hint'=>__('Text shown inline with the widget'),
                'configuration'=>array('rows'=>2)))
        );
    }

    function to_database($value) {
        return ($value) ? '1' : '0';
    }

    function parse($value) {
        return $this->to_php($value);
    }
    function to_php($value) {
        return $value ? true : false;
    }

    function toString($value) {
        return ($value) ? __('Yes') : __('No');
    }

    function getClean($validate=true) {
        if (!isset($this->_clean)) {
            $this->_clean = (isset($this->value))
                ? $this->value : $this->getValue();

            if ($this->isVisible() && $validate)
                $this->validateEntry($this->_clean);
        }
        return $this->_clean;
    }

    function getSearchMethods() {
        return array(
            'set' =>        __('checked'),
            'nset' =>    __('unchecked'),
        );
    }

    function describeSearchMethod($method) {

        $methods = $this->get('descsearchmethods');
        if (isset($methods[$method]))
            return $methods[$method];

        return parent::describeSearchMethod($method);
    }

    function getSearchMethodWidgets() {
        return array(
            'set' => null,
            'nset' => null,
        );
    }

    function getSearchQ($method, $value, $name=false) {
        $name = $name ?: $this->get('name');
        switch ($method) {
        case 'set':
            return new Q(array($name => '1'));
        case 'nset':
            return new Q(array($name => '0'));
        default:
            return parent::getSearchQ($method, $value, $name);
        }
    }

    function supportsQuickFilter() {
        return true;
    }

    function getQuickFilterChoices() {
        return array(
            true => __('Checked'),
            false => __('Not Checked'),
        );
    }

    function applyQuickFilter($query, $qf_value, $name=false) {
        return $query->filter(array(
            $name ?: $this->get('name') => (int) $qf_value,
        ));
    }
}

class ChoiceField extends FormField {
    static $widget = 'ChoicesWidget';
    var $_choices;

    function getConfigurationOptions() {
        return array(
            'choices'  =>  new TextareaField(array(
                'id'=>1, 'label'=>__('Choices'), 'required'=>false, 'default'=>'',
                'hint'=>__('List choices, one per line. To protect against spelling changes, specify key:value names to preserve entries if the list item names change.</br><b>Note:</b> If you have more than two choices, use a List instead.'),
                'validator'=>'choices',
                'configuration'=>array(
                    'html' => false,
                    'disabled' => false,
                    )
            )),
            'default' => new TextboxField(array(
                'id'=>3, 'label'=>__('Default'), 'required'=>false, 'default'=>'',
                'hint'=>__('(Enter a key). Value selected from the list initially'),
                'configuration'=>array('size'=>20, 'length'=>40),
            )),
            'prompt' => new TextboxField(array(
                'id'=>2, 'label'=>__('Prompt'), 'required'=>false, 'default'=>'',
                'hint'=>__('Leading text shown before a value is selected'),
                'configuration'=>array('size'=>40, 'length'=>40,
                    'translatable'=>$this->getTranslateTag('prompt'),
                ),
            )),
            'multiselect' => new BooleanField(array(
                'id'=>1, 'label'=>'Multiselect', 'required'=>false, 'default'=>false,
                'configuration'=>array(
                    'desc'=>'Allow multiple selections')
            )),
        );
    }

    function parse($value) {
        return $this->to_php($value ?: null);
    }

    function to_database($value) {
        if (!is_array($value)) {
            $choices = $this->getChoices();
            if (isset($choices[$value]))
                $value = array($value => $choices[$value]);
        }
        if (is_array($value))
            $value = JsonDataEncoder::encode($value);

        return $value;
    }

    function to_php($value) {
        if (is_string($value))
            $value = JsonDataParser::parse($value) ?: $value;

        // CDATA table may be built with comma-separated key,value,key,value
        if (is_string($value) && strpos($value, ',')) {
            $values = array();
            $choices = $this->getChoices();
            $vals = array_map('trim', explode(',', $value));
            foreach ($vals as $V) {
                if (isset($choices[$V]))
                    $values[$V] = $choices[$V];
            }
            if (array_filter($values))
                $value = $values;
            elseif($vals)
                list($value) = $vals;

        }
        $config = $this->getConfiguration();
        if (!$config['multiselect'] && is_array($value) && count($value) < 2) {
            reset($value);
            $value = key($value);
        }
        return $value;
    }

    function toString($value) {
        if (!is_array($value))
            $value = $this->getChoice($value);
        if (is_array($value))
            return implode(', ', $value);
        return (string) $value;
    }

    function getKeys($value) {
        if (!is_array($value))
            $value = $this->getChoice($value);
        if (is_array($value))
            return implode(', ', array_keys($value));
        return (string) $value;
    }

    function asVar($value, $id=false) {
        $value = $this->to_php($value);
        return $this->toString($this->getChoice($value));
    }

    function getChanges() {
        $new = $this->to_database($this->getValue());
        $old = $this->to_database($this->answer ? $this->answer->getValue()
                : $this->get('default'));
        // Compare old and new
        return ($old == $new)
            ? false
            : array($old, $new);
    }

    function whatChanged($before, $after) {
        $B = (array) $before;
        $A = (array) $after;
        $added = array_diff($A, $B);
        $deleted = array_diff($B, $A);
        $added = array_map(array($this, 'display'), $added);
        $deleted = array_map(array($this, 'display'), $deleted);
        $added = array_filter($added);
        $deleted = array_filter($deleted);

        if ($added && $deleted) {
            $desc = sprintf(
                __('added <strong>%1$s</strong> and removed <strong>%2$s</strong>'),
                implode(', ', $added), implode(', ', $deleted));
        }
        elseif ($added) {
            $desc = sprintf(
                __('added <strong>%1$s</strong>'),
                implode(', ', $added));
        }
        elseif ($deleted) {
            $desc = sprintf(
                __('removed <strong>%1$s</strong>'),
                implode(', ', $deleted));
        }
        else {
            $desc = sprintf(
                __('changed from <strong>%1$s</strong> to <strong>%2$s</strong>'),
                $this->display($before), $this->display($after));
        }
        return $desc;
    }

    /*
     Return criteria to which the choice should be filtered by
     */
    function getCriteria() {
        $config = $this->getConfiguration();
        $criteria = array();
        if (isset($config['criteria']))
            $criteria = $config['criteria'];

        return $criteria;
    }

    function getChoice($value) {

        $choices = $this->getChoices();
        $selection = array();
        if ($value && is_array($value)) {
            $selection = $value;
        } elseif (isset($choices[$value]))
            $selection[$value] = $choices[$value];
        elseif (($v=$this->get('default')) && isset($choices[$v]))
            $selection[$v] = $choices[$v];

        return $selection;
    }

    function getChoices($verbose=false, $options=array()) {
        if ($this->_choices === null || $verbose) {
            // Allow choices to be set in this->ht (for configurationOptions)
            $this->_choices = $this->get('choices');
            if (!$this->_choices) {
                $this->_choices = array();
                $config = $this->getConfiguration();
                $choices = explode("\n", $config['choices']);
                foreach ($choices as $choice) {
                    // Allow choices to be key: value
                    list($key, $val) = explode(':', $choice, 2);
                    if ($val == null)
                        $val = $key;
                    $this->_choices[trim($key)] = trim($val);
                }
                // Add old selections if nolonger available
                // This is necessary so choices made previously can be
                // retained
                $values = ($a=$this->getAnswer()) ? $a->getValue() : array();
                if ($values && is_array($values)) {
                    foreach ($values as $k => $v) {
                        if (!isset($this->_choices[$k])) {
                            if ($verbose) $v .= ' (retired)';
                            $this->_choices[$k] = $v;
                        }
                    }
                }
            }
        }
        return $this->_choices;
    }

    function lookupChoice($value) {
        return null;
    }

    function getSearchMethods() {
        return array(
            'set' =>        __('has a value'),
            'nset' =>     __('does not have a value'),
            'includes' =>   __('includes'),
            '!includes' =>  __('does not include'),
        );
    }

    function getSearchMethodWidgets() {
        return array(
            'set' => null,
            'nset' => null,
            'includes' => array('ChoiceField', array(
                'choices' => $this->getChoices(),
                'configuration' => array('multiselect' => true),
            )),
            '!includes' => array('ChoiceField', array(
                'choices' => $this->getChoices(),
                'configuration' => array('multiselect' => true),
            )),
        );
    }

    function getSearchQ($method, $value, $name=false) {
        $name = $name ?: $this->get('name');
        $val = $value;
        if ($value && is_array($value))
            $val = '"?(?<![0-9])'.implode('("|,|$)|"?(?<![0-9])', array_keys($value)).'("|,|$)';
        switch ($method) {
        case '!includes':
            return Q::not(array("{$name}__regex" => $val));
        case 'includes':
            return new Q(array("{$name}__regex" => $val));
        default:
            return parent::getSearchQ($method, $value, $name);
        }
    }

    function describeSearchMethod($method) {
        switch ($method) {
        case 'includes':
            return __('%s includes %s' /* includes -> if a list includes a selection */);
        case '!includes':
            return __('%s does not include %s' /* includes -> if a list includes a selection */);
        default:
            return parent::describeSearchMethod($method);
        }
    }

    function supportsQuickFilter() {
        return true;
    }

    function getQuickFilterChoices() {
        return $this->getChoices();
    }

    function applyQuickFilter($query, $qf_value, $name=false) {
        global $thisstaff;

        $field = new AssigneeChoiceField();
        //special assignment quick filters
        switch (true) {
            case ($qf_value == 'assigned'):
            case ($qf_value == '!assigned'):
                $result = $field->getSearchQ($qf_value, $qf_value);
                return $query->filter($result);
            case (strpos($qf_value, 's') !== false):
            case (strpos($qf_value, 't') !== false):
            case ($qf_value == 'M'):
            case ($qf_value == 'T'):
                $value = array($qf_value => $qf_value);
                $result = $field->getSearchQ('includes', $value);
                return $query->filter($result);
                break;
        }

        return $query->filter(array(
            $name ?: $this->get('name') => $qf_value,
        ));
    }
}

class NumericField extends FormField {

    function getSearchMethods() {
        return array(
            'equal' =>   __('Equal'),
            'greater' =>  __('Greater Than'),
            'less' =>  __('Less Than'),
        );
    }

    function getSearchMethodWidgets() {
        return array(
            'equal' => array('TextboxField', array(
                    'configuration' => array(
                        'validator' => 'number',
                        'size' => 6
                        ),
            )),
            'greater' => array('TextboxField', array(
                    'configuration' => array(
                        'validator' => 'number',
                        'size' => 6
                        ),
            )),
            'less' => array('TextboxField', array(
                    'configuration' => array(
                        'validator' => 'number',
                        'size' => 6
                        ),
            )),
        );
    }

    function getSearchQ($method, $value, $name=false) {
        switch ($method) {
        case 'equal':
            return new Q(array(
                "{$name}__exact" => intval($value)
            ));
        break;
        case 'greater':
            return Q::any(array(
                "{$name}__gt" => intval($value)
            ));
        break;
        case 'less':
            return Q::any(array(
                "{$name}__lt" => intval($value)
            ));
        break;
        default:
            return parent::getSearchQ($method, $value, $name);
        }
    }
}

class DatetimeField extends FormField {
    static $widget = 'DatetimePickerWidget';

    var $min = null;
    var $max = null;

    static function intervals($count=2, $i='') {
        $intervals = array(
            'i' => _N('minute', 'minutes', $count),
            'h' => _N('hour', 'hours', $count),
            'd' => _N('day','days', $count),
            'w' => _N('week', 'weeks', $count),
            'm' => _N('month', 'months', $count),
        );
        return $i ? $intervals[$i] : $intervals;
    }

    static function periods($period='') {
        $periods = array(
                'td' => __('Today'),
                'yd' => __('Yesterday'),
                'tw' => __('This Week'),
                'tm' => __('This Month'),
                'tq' => __('This Quarter'),
                'ty' => __('This Year'),
                'lw' => __('Last Week'),
                'lm' => __('Last Month'),
                'lq' => __('Last Quarter'),
                'ly' => __('Last Year'),
        );
        return $period ? $periods[$period] : $periods;
    }

    // Get php DatateTime object of the field  - null if value is empty
    function getDateTime($value=null) {
        return Format::parseDateTime($value ?: $this->value);
    }

    // Get effective timezone for the field
    function getTimeZone() {
        global $cfg;

        $config = $this->getConfiguration();
        $timezone = new DateTimeZone($config['timezone'] ?:
                $cfg->getTimezone());

        return $timezone;
    }

    function getMinDateTime() {

        if (!isset($this->min)) {
            $config = $this->getConfiguration();
            $this->min = $config['min']
                ? Format::parseDateTime($config['min']) : false;
        }

        return $this->min;
    }

    function getMaxDateTime() {

        if (!isset($this->max)) {
            $config = $this->getConfiguration();
            $this->max = $config['max']
                ? Format::parseDateTime($config['max']) : false;
        }

        return $this->max;
    }

    static function getPastPresentLabels() {
      return array(__('Create Date'), __('Reopen Date'),
                    __('Close Date'), __('Last Update'));
    }

    function to_database($value) {
        // Store time in format given by Date Picker (DateTime::W3C)
        return $value;
    }

    function to_php($value) {

        if (!is_numeric($value) && strtotime($value) <= 0)
            return 0;

        return $value;
    }

    function display($value) {
        global $cfg;

        if (!$value || !($datetime = Format::parseDateTime($value)))
            return '';

        $config = $this->getConfiguration();
        $format = $config['format'] ?: false;
        if ($config['gmt'])
            return $this->format((int) $datetime->format('U'), $format);

        // Force timezone if field has one.
        if ($config['timezone']) {
            $timezone = new DateTimezone($config['timezone']);
            $datetime->setTimezone($timezone);
        }

        $value = $this->format($datetime->format('U'),
                $datetime->getTimezone()->getName(),
                $format);
        // No need to show timezone
        if (!$config['time'] || $format)
            return $value;

        // Display is NOT timezone aware show entry's timezone.
        return sprintf('%s (%s)',
                $value, $datetime->format('T'));
    }

    function from_query($row, $name=false) {
        $value = parent::from_query($row, $name);
        $timestamp = is_int($value) ? $value : (int) strtotime($value);
        return ($timestamp > 0) ? $timestamp : '';
    }

    function format($timestamp, $timezone=false, $format=false) {

        if (!$timestamp || $timestamp <= 0)
            return '';

        $config = $this->getConfiguration();
        if ($config['time'])
            $formatted = Format::datetime($timestamp, false, $format,  $timezone);
        else
            $formatted = Format::date($timestamp, false, $format, $timezone);

        return $formatted;
    }

    function toString($value) {
        if (is_array($value))
            return '';

        $timestamp = is_int($value) ? $value : (int) strtotime($value);
        if ($timestamp <= 0)
            return '';

        return $this->format($timestamp);
    }

    function asVar($value, $id=false) {
        global $cfg;

        if (!$value)
            return null;

        $datetime = $this->getDateTime($value);
        $config = $this->getConfiguration();
        if (!$config['gmt'] || !$config['time'])
            $timezone  = $datetime->getTimezone()->getName();
        else
            $timezone  = false;

        return  new FormattedDate($value, array(
                    'timezone'  =>  $timezone,
                    'format'    =>  $config['time'] ? 'long' : 'short'
                    )
                );
    }

    function asVarType() {
        return 'FormattedDate';
    }

    function getConfigurationOptions() {
        return array(
            'time' => new BooleanField(array(
                'id'=>1, 'label'=>__('Time'), 'required'=>false, 'default'=>false,
                'configuration'=>array(
                    'desc'=>__('Show time selection with date picker')))),
            'timezone' => new TimezoneField(array(
                'id'=>2, 'label'=>__('Timezone'), 'required'=>false,
                'hint'=>__('Timezone of the date time selection'),
                'configuration' => array('autodetect'=>false,
                    'prompt' => __("User's timezone")),
               'visibility' => new VisibilityConstraint(
                    new Q(array('time__eq'=> true)),
                    VisibilityConstraint::HIDDEN
                ),
                )),
            'gmt' => new BooleanField(array(
                'id'=>3, 'label'=>__('Timezone Aware'), 'required'=>false,
                'configuration'=>array(
                    'desc'=>__("Show date/time relative to user's timezone")))),
            'min' => new DatetimeField(array(
                'id'=>4, 'label'=>__('Earliest'), 'required'=>false,
                'hint'=>__('Earliest date selectable'))),
            'max' => new DatetimeField(array(
                'id'=>5, 'label'=>__('Latest'), 'required'=>false,
                'default'=>null, 'hint'=>__('Latest date selectable'))),
            'future' => new BooleanField(array(
                'id'=>6, 'label'=>__('Allow Future Dates'), 'required'=>false,
                'default'=>true, 'configuration'=>array(
                    'desc'=>__('Allow entries into the future' /* Used in the date field */)),
            )),
        );
    }

    function validateEntry($value) {
        global $cfg;

        $config = $this->getConfiguration();
        parent::validateEntry($value);
        if (!$value || !($datetime = Format::parseDateTime($value)))
            return;

        // Get configured min/max (if any)
        $min = $this->getMinDateTime();
        $max = $this->getMaxDateTime();

        if (!$datetime) {
            $this->_errors[] = __('Enter a valid date');
        } elseif ($min and $datetime < $min) {
            $this->_errors[] = sprintf('%s (%s)',
                    __('Selected date is earlier than permitted'),
                     Format::date($min->getTimestamp(), false, false,
                         $min->getTimezone()->getName() ?: 'UTC')
                     );
        } elseif ($max and $datetime > $max) {
            $this->_errors[] = sprintf('%s (%s)',
                    __('Selected date is later than permitted'),
                    Format::date($max->getTimestamp(), false, false,
                        $max->getTimezone()->getName() ?: 'UTC')
                    );
        }
    }

    // SearchableField interface ------------------------------
    function getSearchMethods() {
        return array(
            'set' =>        __('has a value'),
            'nset' =>       __('does not have a value'),
            'equal' =>      __('on'),
            'nequal' =>     __('not on'),
            'before' =>     __('before'),
            'after' =>      __('after'),
            'between' =>    __('between'),
            'period' =>     __('period'),
            'ndaysago' =>   __('in the last n days'),
            'ndays' =>      __('in the next n days'),
            'future' =>     __('in the future'),
            'past' =>       __('in the past'),
            'distfut' =>    __('more than n days from now'),
            'distpast' =>   __('more than n days ago'),
        );
    }

    function getSearchMethodWidgets() {
        $config_notime = $config = $this->getConfiguration();
        $config_notime['time'] = false;
        $nday_form = function($x=5) {
            return array(
                'until' => new TextboxField(array(
                    'configuration' => array('validator'=>'number', 'size'=>4))
                ),
                'int' => new ChoiceField(array(
                    'default' => 'd',
                    'choices' => self::intervals($x),
                )),
            );
        };
        return array(
            'set' => null,
            'nset' => null,
            'past' => null,
            'future' => null,
            'equal' => array('DatetimeField', array(
                'configuration' => $config_notime,
            )),
            'nequal' => array('DatetimeField', array(
                'configuration' => $config_notime,
            )),
            'before' => array('DatetimeField', array(
                'configuration' => $config,
            )),
            'after' => array('DatetimeField', array(
                'configuration' => $config,
            )),
            'between' => array('InlineformField', array(
                'form' => array(
                    'left' => new DatetimeField($config + array('required' => true)),
                    'text' => new FreeTextField(array(
                        'configuration' => array('content' => __('and')))
                    ),
                    'right' => new DatetimeField($config + array('required' => true)),
                ),
                'configuration' => array(
                    'error' => '',
                ),
            )),
            'period' => array('ChoiceField', array(
                'choices' => self::periods(),
            )),
            'ndaysago' => array('InlineformField', array('form'=>$nday_form())),
            'ndays' => array('InlineformField', array('form'=>$nday_form())),
            'distfut' => array('InlineformField', array('form'=>$nday_form())),
            'distpast' => array('InlineformField', array('form'=>$nday_form())),
        );
    }

    function getSearchQ($method, $value, $name=false) {
        global $cfg;

        static $intervals = array(
            'm' => 'MONTH',
            'w' => 'WEEK',
            'd' => 'DAY',
            'h' => 'HOUR',
            'i' => 'MINUTE',
        );
        $name = $name ?: $this->get('name');
        $now = SqlFunction::NOW();
        $config = $this->getConfiguration();
       if (is_int($value))
          $value = DateTime::createFromFormat('U', !$config['gmt'] ? Misc::gmtime($value) : $value) ?: $value;
       elseif (is_string($value) && strlen($value) > 2)
           $value = Format::parseDateTime($value) ?: $value;

        switch ($method) {
        case 'equal':
            $l = clone $value;
            $r = $value->add(new DateInterval('P1D'));
            return new Q(array(
                "{$name}__gte" => $l,
                "{$name}__lt" => $r
            ));
        case 'nequal':
            $l = clone $value;
            $r = $value->add(new DateInterval('P1D'));
            return Q::any(array(
                "{$name}__lt" => $l,
                "{$name}__gte" => $r,
            ));
        case 'future':
            $value = $now;
        case 'after':
            return new Q(array("{$name}__gte" => $value));
        case 'past':
            $value = $now;
        case 'before':
            return new Q(array("{$name}__lt" => $value));
        case 'between':
            $left = Format::parseDateTime($value['left']);
            $right = Format::parseDateTime($value['right']);
            if (!$left || !$right)
                return null;

            // TODO: allow time selection for between
            $left = $left->setTime(00, 00, 00);
            $right = $right->setTime(23, 59, 59);
            // Convert time to db timezone
            $dbtz = new DateTimeZone($cfg->getDbTimezone());
            $left->setTimezone($dbtz);
            $right->setTimezone($dbtz);
            return new Q(array(
                "{$name}__gte" =>  $left->format('Y-m-d H:i:s'),
                "{$name}__lte" =>  $right->format('Y-m-d H:i:s'),
            ));
        case 'ndaysago':
            $int = $intervals[$value['int'] ?: 'd'] ?: 'DAY';
            $interval = new SqlInterval($int, $value['until']);
            return new Q(array(
                "{$name}__range" => array($now->minus($interval), $now),
            ));
        case 'ndays':
            $int = $intervals[$value['int'] ?: 'd'] ?: 'DAY';
            $interval = new SqlInterval($int, $value['until']);
            return new Q(array(
                "{$name}__range" => array($now, $now->plus($interval)),
            ));
        // Distant past and future ranges
        case 'distpast':
            $int = $intervals[$value['int'] ?: 'd'] ?: 'DAY';
            $interval = new SqlInterval($int, $value['until']);
            return new Q(array(
                "{$name}__lte" => $now->minus($interval),
            ));
        case 'distfut':
            $int = $intervals[$value['int'] ?: 'd'] ?: 'DAY';
            $interval = new SqlInterval($int, $value['until']);
            return new Q(array(
                "{$name}__gte" => $now->plus($interval),
            ));
        case 'period':
            // User's effective timezone
            $tz = new DateTimeZone($cfg->getTimezone());
            // Get the period range boundaries in user's tz
            $period = Misc::date_range($value, Misc::gmtime('now'), $tz);
            // Convert boundaries to db time
            $dbtz = new DateTimeZone($cfg->getDbTimezone());
            $start = $period->start->setTimezone($dbtz);
            $end = $period->end->setTimezone($dbtz);
            // Set the range
            return new Q(array(
                "{$name}__range" => array(
                    $start->format('Y-m-d H:i:s'),
                    $end->format('Y-m-d H:i:s')
                    )
                ));
            break;
        default:
            return parent::getSearchQ($method, $value, $name);
        }
    }

    function describeSearchMethod($method) {
        switch ($method) {
        case 'before':
            return __('%1$s before %2$s' /* occurs before a date and time */);
        case 'after':
            return __('%1$s after %2$s' /* occurs after a date and time */);
        case 'ndays':
            return __('%1$s in the next %2$s' /* occurs within a window (like 3 days) */);
        case 'ndaysago':
            return __('%1$s in the last %2$s' /* occurs within a recent window (like 3 days) */);
        case 'distfut':
            return __('%1$s after %2$s from now' /* occurs after a window (like 3 days) */);
        case 'distpast':
            return __('%1$s before %2$s ago' /* occurs previous to a window (like 3 days) */);
        case 'between':
            return __('%1$s between %2$s and %3$s');
        case 'future':
            return __('%1$s is in the future');
        case 'past':
            return __('%1$s is in the past');
        case 'period':
            return __('%1$s is %2$s');
        default:
            return parent::describeSearchMethod($method);
        }
    }

    function describeSearch($method, $value, $name=false) {

        $name = $name ?: $this->get('name');
        $desc = $this->describeSearchMethod($method);
        switch ($method) {
            case 'between':
                return sprintf($desc, $name,
                        $this->toString($value['left']),
                        $this->toString($value['right']));
            case 'ndays':
            case 'ndaysago':
            case 'distfut':
            case 'distpast':
                $interval = sprintf('%s %s', $value['until'],
                        self::intervals($value['until'], $value['int']));
                return sprintf($desc, $name, $interval);
                break;
            case 'future':
            case 'past':
                return sprintf($desc, $name);
            case 'before':
            case 'after':
                return sprintf($desc, $name, $this->toString($value));
            case 'period':
                return sprintf($desc, $name, self::periods($value) ?: $value);
            default:
                return parent::describeSearch($method, $value, $name);
        }
    }

    function supportsQuickFilter() {
        return true;
    }

    function getQuickFilterChoices() {
        return array(
            'h' => __('Today'),
            'm' => __('Tomorrow'),
            'g' => __('Yesterday'),
            'l7' => __('Last 7 days'),
            'l30' => __('Last 30 days'),
            'n7' => __('Next 7 days'),
            'n30' => __('Next 30 days'),
            /* Ugh. These boundaries are so difficult in SQL
            'w' =>  __('This Week'),
            'm' =>  __('This Month'),
            'lw' => __('Last Week'),
            'lm' => __('Last Month'),
            'nw' => __('Next Week'),
            'nm' => __('Next Month'),
            */
        );
    }

    function applyQuickFilter($query, $qf_value, $name=false) {
        $name = $name ?: $this->get('name');
        $now = SqlFunction::NOW();
        $midnight = Misc::dbtime(time() - (time() % 86400));
        switch ($qf_value) {
        case 'l7':
            return $query->filter([
                "{$name}__range" => array($now->minus(SqlInterval::DAY(7)), $now),
            ]);
        case 'l30':
            return $query->filter([
                "{$name}__range" => array($now->minus(SqlInterval::DAY(30)), $now),
            ]);
        case 'n7':
            return $query->filter([
                "{$name}__range" => array($now, $now->plus(SqlInterval::DAY(7))),
            ]);
        case 'n30':
            return $query->filter([
                "{$name}__range" => array($now, $now->plus(SqlInterval::DAY(30))),
            ]);
        case 'g':
            $midnight -= 86400;
             // Fall through to the today case
        case 'm':
            if ($qf_value === 'm') $midnight += 86400;
             // Fall through to the today case
        case 'h':
            $midnight = DateTime::createFromFormat('U', $midnight);
            return $query->filter([
                "{$name}__range" => array($midnight,
                    SqlExpression::plus($midnight, SqlInterval::DAY(1))),
            ]);
        }
    }
}


/**
 * TimeField for time selection
 *
 */
class TimeField extends FormField {
    static $widget = 'TimePickerWidget';

    var $start = null;
    var $end = null;

    function getTimeZone() {
        global $cfg;
        $config = $this->getConfiguration();
        return new DateTimeZone($config['timezone'] ?: $cfg->getTimezone());
    }
}

/**
 * This is kind-of a special field that doesn't have any data. It's used as
 * a field to provide a horizontal section break in the display of a form
 */
class SectionBreakField extends FormField {
    static $widget = 'SectionBreakWidget';

    function hasData() {
        return false;
    }

    function isBlockLevel() {
        return true;
    }

    function isEditableToStaff() {
        return $this->isVisibleToStaff();
    }

    function isEditableToUsers() {
        return $this->isVisibleToUsers();
    }
}

class ThreadEntryField extends FormField {
    static $widget = 'ThreadEntryWidget';

    function isChangeable() {
        return false;
    }
    function isBlockLevel() {
        return true;
    }
    function isPresentationOnly() {
        return true;
    }
    function getMedia() {
        $config = $this->getConfiguration();
        $media = parent::getMedia() ?: array();
        if ($config['attachments'])
            $media = array_merge_recursive($media, FileUploadWidget::$media);
        return $media;
    }

    function getConfiguration() {
        global $cfg;
        $config = parent::getConfiguration();
        $config['html'] = (bool) ($cfg && $cfg->isRichTextEnabled());
        return $config;
    }

    function getConfigurationOptions() {
        global $cfg;

        $attachments = new FileUploadField();
        $fileupload_config = $attachments->getConfigurationOptions();
        if ($cfg->getAllowedFileTypes())
            $fileupload_config['extensions']->set('default', $cfg->getAllowedFileTypes());

        foreach ($fileupload_config as $C) {
            $C->set('visibility', new VisibilityConstraint(new Q(array(
                'attachments__eq'=>true,
            )), VisibilityConstraint::HIDDEN));
        }
        return array(
            'attachments' => new BooleanField(array(
                'label'=>__('Enable Attachments'),
                'default'=>$cfg->allowAttachments(),
                'configuration'=>array(
                    'desc'=>__('Enables attachments, regardless of channel'),
                ),
                'validators' => function($self, $value) {
                    if (!ini_get('file_uploads'))
                        $self->addError(__('The "file_uploads" directive is disabled in php.ini'));
                }
            )),
        )
        + $fileupload_config;
    }

    function isAttachmentsEnabled() {
        $config = $this->getConfiguration();
        return $config['attachments'];
    }

    function getWidget($widgetClass=false) {
        if ($hint = $this->getLocal('hint'))
            $this->set('placeholder', Format::striptags($hint));
        $this->set('hint', null);
        $widget = parent::getWidget($widgetClass);
        return $widget;
    }
}

class TopicField extends ChoiceField {
    var $topics;
    var $_choices;

    function getTopics() {
        global $thisstaff;

        if (!isset($this->topics))
            $this->topics = $thisstaff->getTopicNames();

        return $this->topics;
    }

    function getTopic($id) {
        if ($this->getTopics() &&
                isset($this->topics[$id]))
            return Topic::lookup($id);
    }

    function getWidget($widgetClass=false) {
        $default = $this->get('default');
        $widget = parent::getWidget($widgetClass);
        if ($widget->value instanceof Topic)
            $widget->value = $widget->value->getId();
        elseif (!isset($widget->value) && $default)
            $widget->value = $default;
        return $widget;
    }

    function hasIdValue() {
        return true;
    }

    function getChoices($verbose=false, $options=array()) {
        if (!isset($this->_choices)) {
            $this->_choices = $this->getTopics();
        }

        return $this->_choices;
    }

    function parse($id) {
        return $this->to_php(null, $id);
    }

    function to_php($value, $id=false) {
        if ($value instanceof Topic)
            return $value;
        if (is_array($id)) {
            reset($id);
            $id = key($id);
        }
        elseif (is_array($value))
            list($value, $id) = $value;
        elseif ($id === false)
            $id = $value;

        return $this->getTopic($id);
    }

    function to_database($topic) {
        return ($topic instanceof Topic)
            ? array($topic->getName(), $topic->getId())
            : $topic;
    }

    function display($topic, &$styles=null) {
        if (!$topic instanceof Topic)
            return parent::display($topic);

        return Format::htmlchars($topic->getName());
    }

    function toString($value) {
        if (!($value instanceof Topic) && is_numeric($value))
            $value = $this->getTopic($value);

        return ($value instanceof Topic) ? $value->getName() : $value;
    }

    function whatChanged($before, $after) {
        return parent::whatChanged($before, $after);
    }

    function searchable($value) {
        return null;
    }

    function getKeys($value) {
        return ($value instanceof Topic) ? array($value->getId()) : null;
    }

    function asVar($value, $id=false) {
        return $this->to_php($value, $id);
    }

    function getConfiguration() {
        global $cfg;

        $config = parent::getConfiguration();
        if (!isset($config['default']))
            $config['default'] = $cfg->getDefaultTopicId();
        return $config;
    }
}

class SLAField extends ChoiceField {
    var $slas;
    var $_choices;

    function getSLAs() {
        if (!isset($this->slas))
            $this->slas = SLA::objects();

        return $this->slas;
    }

    function getSLA($id) {
        if ($this->getSLAs() &&
                ($s=$this->slas->findFirst(array('id' => $id))))
            return $s;

        return SLA::lookup($id);
    }

    function getWidget($widgetClass=false) {
        $default = $this->get('default');
        $widget = parent::getWidget($widgetClass);
        if ($widget->value instanceof SLA)
            $widget->value = $widget->value->getId();
        elseif (!isset($widget->value) && $default)
            $widget->value = $default;
        return $widget;
    }

    function hasIdValue() {
        return true;
    }

    function getChoices($verbose=false, $options=array()) {
        if (!isset($this->_choices)) {
            $choices = array();
            foreach ($this->getSLAs() as $s)
                $choices[$s->getId()] = $s->getName();
            $this->_choices = $choices;
        }

        return $this->_choices;
    }

    function parse($id) {
        return $this->to_php(null, $id);
    }

    function to_php($value, $id=false) {
        if ($value instanceof SLA)
            return $value;
        if (is_array($id)) {
            reset($id);
            $id = key($id);
        }
        elseif (is_array($value))
            list($value, $id) = $value;
        elseif ($id === false)
            $id = $value;

        return $this->getSLA($id);
    }

    function to_database($sla) {
        return ($sla instanceof SLA)
            ? array($sla->getName(), $sla->getId())
            : $sla;
    }

    function display($sla, &$styles=null) {
        if (!$sla instanceof SLA)
            return parent::display($sla);

        return Format::htmlchars($sla->getName());
    }

    function toString($value) {
        if (!($value instanceof SLA) && is_numeric($value))
            $value = $this->getSLA($value);
        return ($value instanceof SLA) ? $value->getName() : $value;
    }

    function whatChanged($before, $after) {
        return parent::whatChanged($before, $after);
    }

    function searchable($value) {
        return null;
    }

    function getKeys($value) {
        return ($value instanceof SLA) ? array($value->getId()) : null;
    }

    function asVar($value, $id=false) {
        return $this->to_php($value, $id);
    }

    function getConfiguration() {
        global $cfg;

        $config = parent::getConfiguration();
        if (!isset($config['default']))
            $config['default'] = $cfg->getDefaultSLAId();
        return $config;
    }
}

class PriorityField extends ChoiceField {

    var $priorities;
    var $_choices;

    function getPriorities() {
        if (!isset($this->priorities))
            $this->priorities = Priority::objects();

        return $this->priorities;
    }

    function getPriority($id) {

        if ($this->getPriorities() &&
                ($p=$this->priorities->findFirst(array('priority_id' =>
                                                       $id))))
            return $p;

        return Priority::lookup($id);
    }

    function getWidget($widgetClass=false) {
        $widget = parent::getWidget($widgetClass);
        if ($widget->value instanceof Priority)
            $widget->value = $widget->value->getId();
        return $widget;
    }

    function hasIdValue() {
        return true;
    }

    function getChoices($verbose=false, $options=array()) {
        if (!isset($this->_choices)) {
            $choices = array();
            foreach ($this->getPriorities() as $p)
                $choices[$p->getId()] = $p->getDesc();
            $this->_choices = $choices;
        }

        return $this->_choices;
    }

    function parse($id) {
        return $this->to_php(null, $id);
    }

    function to_php($value, $id=false) {
        if ($value instanceof Priority)
            return $value;

        if (is_array($id)) {
            reset($id);
            $id = key($id);
        } elseif (is_array($value)) {
            list($value, $id) = $value;
        } elseif ($id === false && is_numeric($value))
            $id = $value;

        if (is_numeric($id))
            return $this->getPriority($id);

        return $value;
    }

    function to_database($value) {
        if ($value instanceof Priority)
            return array($value->getDesc(), $value->getId());

        if (is_array($value))
            return array(current($value), key($value));

        return $value;
    }

    function display($prio, &$styles=null) {
        if (!$prio instanceof Priority)
            return parent::display($prio);
        if (is_array($styles))
            $styles += array(
                'background-color' => $prio->getColor()
            );
        return Format::htmlchars($prio->getDesc());
    }

    function toString($value) {
        return ($value instanceof Priority) ? $value->getDesc() : $value;
    }

    function whatChanged($before, $after) {
        return parent::whatChanged($before, $after);
    }

    function searchable($value) {
        // Priority isn't searchable this way
        return null;
    }

    function getKeys($value) {
        return ($value instanceof Priority) ? array($value->getId()) : null;
    }

    function asVar($value, $id=false) {
        return $this->to_php($value, $id);
    }

    function getConfigurationOptions() {
        $choices = $this->getChoices();
        return array(
            'prompt' => new TextboxField(array(
                'id'=>2, 'label'=>__('Prompt'), 'required'=>false, 'default'=>'',
                'hint'=>__('Leading text shown before a value is selected'),
                'configuration'=>array('size'=>40, 'length'=>40),
            )),
            'default' => new ChoiceField(array(
                'id'=>3, 'label'=>__('Default'), 'required'=>false, 'default'=>'',
                'choices' => $choices,
                'hint'=>__('Default selection for this field'),
                'configuration'=>array('size'=>20, 'length'=>40),
            )),
        );
    }

    function getConfiguration() {
        global $cfg;

        $config = parent::getConfiguration();
        if (!isset($config['default']))
            $config['default'] = $cfg->getDefaultPriorityId();
        return $config;
    }

    function applyOrderBy($query, $reverse=false, $name=false) {
        if ($query->model == 'Ticket' && $name == 'cdata__priority') {
            // Order by the priority urgency field
            $col = 'cdata__:priority__priority_urgency';
            $reverse = !$reverse;
        }
        else {
            $col = $name ?: CustomQueue::getOrmPath($this->get('name'), $query);
        }
        if ($reverse)
            $col = "-$col";
        return $query->order_by($col);
    }
}
FormField::addFieldTypes(/*@trans*/ 'Dynamic Fields', function() {
    return array(
        'priority' => array(__('Priority Level'), 'PriorityField'),
    );
});


class TimezoneField extends ChoiceField {
    static $widget = 'TimezoneWidget';

    function hasIdValue() {
        return false;
    }

    function getChoices($verbose=false, $options=array()) {
        global $cfg;

        $choices = array();
        foreach (DateTimeZone::listIdentifiers() as $zone)
            $choices[$zone] =  str_replace('/',' / ',$zone);

        return $choices;
    }

    function whatChanged($before, $after) {
        return parent::whatChanged($before, $after);
    }

    function searchable($value) {
        return null;
    }

    function getConfigurationOptions() {
        return array(
            'autodetect' => new BooleanField(array(
                'id'=>1, 'label'=>__('Auto Detect'), 'required'=>false, 'default'=>true,
                'configuration'=>array(
                    'desc'=>__('Add Auto Detect Button'))
            )),
            'prompt' => new TextboxField(array(
                'id'=>2, 'label'=>__('Prompt'), 'required'=>false, 'default'=>'',
                'hint'=>__('Leading text shown before a value is selected'),
                'configuration'=>array('size'=>40, 'length'=>40),
            )),
        );
    }
}


class DepartmentField extends ChoiceField {
    function getWidget($widgetClass=false) {
        $widget = $this->_widget ?: parent::getWidget($widgetClass);
        if (is_object($widget->value))
            $widget->value = $widget->value->getId();
        return $widget;
    }

    function hasIdValue() {
        return true;
    }

    function getChoices($verbose=false, $options=array()) {
        global $cfg, $thisstaff;

        $config = $this->getConfiguration();
        $staff = $config['staff'] ?: $thisstaff;
        $selected = $this->getWidget();
        if($selected && $selected->value) {
          if(is_array($selected->value)) {
            foreach ($selected->value as $k => $v) {
              $current_id = $k;
              $current_name = $v;
            }
          }
          else {
            $current_id = $selected->value;
            $current_name = Dept::getNameById($current_id);
          }
        }

        $choices = array();

        //get all depts unfiltered
        $depts = $config['hideDisabled'] ? Dept::getDepartments(array('activeonly' => true)) :
            Dept::getDepartments(null, true, Dept::DISPLAY_DISABLED);

        //get staff depts based on permissions
        if ($staff) {
            $active = $staff->getDepartmentNames(true);

            if ($staff->hasPerm(Dept::PERM_DEPT))
                return $depts;
        }
        //filter custom department fields when there is no staff
        else {
            $userDepts = Dept::getDepartments(array('publiconly' => true, 'activeonly' => true));

            return $userDepts;
        }

         //add selected dept to list
         if($current_id)
            $active[$current_id] = $current_name;
         else
            return $active;

         foreach ($depts as $id => $name) {
            $choices[$id] = $name;
            if(!array_key_exists($id, $active) && $current_id)
                unset($choices[$id]);
         }

        return $choices;
    }

    function display($dept, &$styles=null) {
        if (!is_numeric($dept) && is_string($dept))
            return Format::htmlchars($dept);
        elseif ($dept instanceof Dept)
            return Format::htmlchars($dept->getName());

        return parent::display($dept);
    }

    function parse($id) {
        return $this->to_php(null, $id);
    }

    function getValue() {
         if (($value = parent::getValue()) && ($id=$this->getClean()))
            return $value[$id];
     }

    function to_php($value, $id=false) {
        if ($id) {
            if (is_array($id)) {
                reset($id);
                $id = key($id);
            }
            return $id;
        } else {
            return $value;
        }
    }

    function to_database($dept) {
        if ($dept instanceof Dept)
            return array($dept->getName(), $dept->getId());

        if (!is_array($dept)) {
            $choices = $this->getChoices();
            if (in_array($dept, $choices)) {
                $deptId = array_search($dept, $choices);
                $dept = array($dept, $deptId);
            }
         }

        return $dept ?: array();
    }

    function toString($value) {
        if (!is_array($value))
            $value = $this->getChoice($value);
        if (is_array($value))
            return implode(', ', $value);
        return (string) $value;
    }

    function getChoice($value) {
        $choices = $this->getChoices();
        $selection = array();
        if ($value && is_array($value)) {
            $selection = $value;
        } elseif (isset($choices[$value])) {
            $selection[] = $choices[$value];
        }

        return $selection;
    }

    function searchable($value) {
        return null;
    }

    function getConfigurationOptions() {
        return array(
            'prompt' => new TextboxField(array(
                'id'=>2, 'label'=>__('Prompt'), 'required'=>false, 'default'=>'',
                'hint'=>__('Leading text shown before a value is selected'),
                'configuration'=>array('size'=>40, 'length'=>40),
            )),
        );
    }
}
FormField::addFieldTypes(/*@trans*/ 'Dynamic Fields', function() {
    return array(
        'department' => array(__('Department'), 'DepartmentField'),
    );
});

class AssigneeField extends ChoiceField {
    var $_choices = null;
    var $_criteria = null;

    function getWidget($widgetClass=false) {
        $widget = parent::getWidget($widgetClass);
        $value = $widget->value;
        if (is_object($value)) {
            $id = $value->getId();
            if ($value instanceof Staff)
                $widget->value = 's'.$id;
            elseif ($value instanceof Team)
                $widget->value = 't'.$id;
        }
        return $widget;
    }

    function getCriteria() {
        if (!isset($this->_criteria)) {
            $this->_criteria = array('available' => true, 'namesOnly' => true);
            if (($c=parent::getCriteria()))
                $this->_criteria = array_merge($this->_criteria, $c);
        }

        return $this->_criteria;
    }

    function hasIdValue() {
        return true;
    }

    function setChoices($choices) {
        $this->_choices = $choices;
    }

    function display($value) {
        if ($this->getAnswer() && is_string($this->getAnswer()->value)) {
            $v = JsonDataParser::parse($this->getAnswer()->value);
            if (is_array($v))
                $value = $v[key($v)];
        }
        return $value;
    }

    function getAssignees($options=array()) {
        global $thisstaff;

        $config = $this->getConfiguration();
        $criteria = $this->getCriteria();
        $dept = $config['dept'] ?: null;
        $staff = $config['staff'] ?: $thisstaff;
        $assignees = array();
        switch (strtolower($config['target'])) {
        case 'agents':
            if ($dept)
                foreach ($dept->getAssignees(array('staff' => $staff)) as $a)
                    $assignees['s'.$a->getId()] = $a;
            else
                foreach (Staff::getStaffMembers(array('staff' => $staff)) as $id => $name)
                    $assignees['s'.$id] = $name;
            break;
        case 'teams':
            foreach (Team::getActiveTeams() ?: array() as $id => $name)
                $assignees['t'.$id] = $name;
            break;
        default:
            // both agents and teams
            $assignees = array(
                    __('Agents') => new ArrayObject(),
                    __('Teams') => new ArrayObject());
            $A = current($assignees);
            $criteria = $this->getCriteria();
            $agents = array();
            if ($dept)
                foreach ($dept->getAssignees(array('staff' => $staff)) as $a)
                    $A['s'.$a->getId()] = $a;
            else
                foreach (Staff::getStaffMembers(array('staff' => $staff)) as $a => $name)
                    $A['s'.$a] = $name;

            next($assignees);
            $T = current($assignees);
            if (($teams = Team::getActiveTeams()))
                foreach ($teams as $id => $name)
                    $T['t'.$id] = $name;
            break;
        }

        return $assignees;
    }

    function getChoices($verbose=false, $options=array()) {
        if (!isset($this->_choices))
            $this->_choices = $this->getAssignees($options);

        return $this->_choices;
    }

    function getChoice($value) {
        $choices = $this->getChoices();
        $selection = array();
        if ($value && is_object($value)) {
            $keys = null;
            if ($value instanceof Staff)
                $keys = array('Agents', 's'.$value->getId());
            elseif ($value instanceof Team)
                $keys = array('Teams', 't'.$value->getId());
            if ($keys && isset($choices[$keys[0]]))
                $selection = $choices[$keys[0]][$keys[1]];

            if (!empty($selection))
                return $selection;
        }

        return parent::getChoice($value);
    }

    function getQuickFilterChoices() {
        $choices = $this->getChoices();
        $names = array();
        foreach ($choices as $value) {
            foreach ($value as $key => $value)
                $names[$key] = is_object($value) ? $value->name : $value;
        }

        return $names;
    }

    function getValue() {
        if (($value = parent::getValue()) && ($id=$this->getClean())) {
            $name = (is_object($value[key($value)]) && get_class($value[key($value)]) == 'AgentsName') ?
                $value[key($value)]->name : $value[key($value)];
            $key = (($value[key($value)] instanceof AgentsName) ? 's' : 't').substr(key($value), 1);
            return array(array($key => $name), substr(key($value), 1));
        } else
            return null;
    }

    function parse($id) {
        return $this->to_php(null, $id);
    }

    function to_php($value, $id=false) {
        if (is_string($value))
            $value = JsonDataParser::parse($value) ?: $value;

        if (is_string($value) && strpos($value, ',')) {
            $values = array();
            list($key, $V) = array_map('trim', explode(',', $value));

            $values[$key] = $V;
            $value = $values;
        }

        $type = '';
        if (is_array($id)) {
            reset($id);
            $id = key($id);
            $type = $id[0];
            $id = substr($id, 1);
        }
        if (is_array($value)) {
            $type = key($value)[0];
            if (!$id)
                $id = substr(key($value), 1);
        }

        if (!$type && is_numeric($value))
            return Staff::lookup($value);

        switch ($type) {
        case 's':
            return Staff::lookup($id);
        case 't':
            return Team::lookup($id);
        case 'd':
            return Dept::lookup($id);
        default:
            return $id;
        }
    }

    function to_database($value) {
        if (is_object($value)) {
            $id = $value->getId();
            if ($value instanceof Staff) {
                $key = 's'.$id;
                $name = $value->getName()->name;
            } elseif ($value instanceof Team) {
                $key = 't'.$id;
                $name = $value->getName();
            }

            return JsonDataEncoder::encode(array($key => $name));
        }
        if (is_array($value)) {
            return JsonDataEncoder::encode($value[0]);
        }
        return $value;
    }

    function toString($value) {
        return (string) $value;
    }

    function searchable($value) {
        return null;
    }

    function getKeys($value) {
        $value = $this->to_database($value);
        if (is_array($value))
            return $value[0];

        return (string) $value;
    }

    function asVar($value, $id=false) {
        $v = $this->to_php($value, $id);
        return $v ? $v->getName() : null;
    }

    function getChanges() {
        $new = $this->to_database($this->getValue());
        $old = $this->to_database($this->answer ? $this->answer->getValue()
                : $this->get('default'));
        // Compare old and new
        return ($old == $new)
            ? false
            : array($old, $new);
    }

    function whatChanged($before, $after) {
        if ($before)
            $before = array($before->getName());
        if ($after)
            $after = array($after->getName());

        return parent::whatChanged($before, $after);
    }

    function getConfigurationOptions() {
        return array(
            'prompt' => new TextboxField(array(
                'id'=>2, 'label'=>__('Prompt'), 'required'=>false, 'default'=>'',
                'hint'=>__('Leading text shown before a value is selected'),
                'configuration'=>array('size'=>40, 'length'=>40),
            )),
        );
    }
}
FormField::addFieldTypes(/*@trans*/ 'Dynamic Fields', function() {
    return array(
        'assignee' => array(__('Assignee'), 'AssigneeField'),
    );
});


class TicketStateField extends ChoiceField {

    static $_states = array(
            'open' => array(
                'name' => /* @trans, @context "ticket state name" */ 'Open',
                'verb' => /* @trans, @context "ticket state action" */ 'Open'
                ),
            'closed' => array(
                'name' => /* @trans, @context "ticket state name" */ 'Closed',
                'verb' => /* @trans, @context "ticket state action" */ 'Close'
                )
            );
    // Private states
    static $_privatestates = array(
            'archived' => array(
                'name' => /* @trans, @context "ticket state name" */ 'Archived',
                'verb' => /* @trans, @context "ticket state action" */ 'Archive'
                ),
            'deleted'  => array(
                'name' => /* @trans, @context "ticket state name" */ 'Deleted',
                'verb' => /* @trans, @context "ticket state action" */ 'Delete'
                )
            );

    function hasIdValue() {
        return true;
    }

    function isChangeable() {
        return false;
    }

    function getChoices($verbose=false, $options=array()) {
        static $_choices;

        $states = static::$_states;
        if ($this->options['private_too'])
            $states += static::$_privatestates;

        if (!isset($_choices)) {
            // Translate and cache the choices
            foreach ($states as $k => $v)
                $_choices[$k] =  _P('ticket state name', $v['name']);

            $this->ht['default'] =  '';
        }

        return $_choices;
    }

    function getChoice($state) {

        if ($state && is_array($state))
            $state = key($state);

        if (isset(static::$_states[$state]))
            return _P('ticket state name', static::$_states[$state]['name']);

        if (isset(static::$_privatestates[$state]))
            return _P('ticket state name', static::$_privatestates[$state]['name']);

        return $state;
    }

    function getConfigurationOptions() {
        return array(
            'prompt' => new TextboxField(array(
                'id'=>2, 'label'=> __('Prompt'), 'required'=>false, 'default'=>'',
                'hint'=> __('Leading text shown before a value is selected'),
                'configuration'=>array('size'=>40, 'length'=>40),
            )),
        );
    }

    static function getVerb($state) {

        if (isset(static::$_states[$state]))
            return _P('ticket state action', static::$_states[$state]['verb']);

        if (isset(static::$_privatestates[$state]))
            return _P('ticket state action', static::$_privatestates[$state]['verb']);
    }
}
FormField::addFieldTypes('Dynamic Fields', function() {
    return array(
        'state' => array('Ticket State', 'TicketStateField', false),
    );
});

class TicketFlagField extends ChoiceField {

    // Supported flags (TODO: move to configurable custom list)
    static $_flags = array(
            'onhold' => array(
                'flag' => 1,
                'name' => 'Onhold',
                'states' => array('open'),
                ),
            'overdue' => array(
                'flag' => 2,
                'name' => 'Overdue',
                'states' => array('open'),
                ),
            'answered' => array(
                'flag' => 4,
                'name' => 'Answered',
                'states' => array('open'),
                )
            );

    var $_choices;

    function hasIdValue() {
        return true;
    }

    function isChangeable() {
        return true;
    }

    function getChoices($verbose=false, $options=array()) {
        $this->ht['default'] =  '';

        if (!$this->_choices) {
            foreach (static::$_flags as $k => $v)
                $this->_choices[$k] = $v['name'];
        }

        return $this->_choices;
    }

    function getConfigurationOptions() {
        return array(
            'prompt' => new TextboxField(array(
                'id'=>2, 'label'=>'Prompt', 'required'=>false, 'default'=>'',
                'hint'=>'Leading text shown before a value is selected',
                'configuration'=>array('size'=>40, 'length'=>40),
            )),
        );
    }
}

FormField::addFieldTypes('Dynamic Fields', function() {
    return array(
        'flags' => array('Ticket Flags', 'TicketFlagField', false),
    );
});

class FileUploadField extends FormField {
    static $widget = 'FileUploadWidget';

    protected $attachments;
    protected $files;

    static function getFileTypes() {
        static $filetypes;

        if (!isset($filetypes)) {
            if (function_exists('apcu_fetch')) {
                $key = md5(SECRET_SALT . GIT_VERSION . 'filetypes');
                $filetypes = apcu_fetch($key);
            }
            if (!$filetypes)
                $filetypes = YamlDataParser::load(INCLUDE_DIR . '/config/filetype.yaml');
            if ($key)
                apcu_store($key, $filetypes, 7200);
        }
        return $filetypes;
    }

    function getConfigurationOptions() {
        // Compute size selections
        $sizes = array('262144' => '— '.__('Small').' —');
        $next = 512 << 10;
        $max = strtoupper(ini_get('upload_max_filesize'));
        $limit = (int) $max;
        if (!$limit) $limit = 2 << 20; # 2M default value
        elseif (strpos($max, 'K')) $limit <<= 10;
        elseif (strpos($max, 'M')) $limit <<= 20;
        elseif (strpos($max, 'G')) $limit <<= 30;
        while ($next <= $limit) {
            // Select the closest, larger value (in case the
            // current value is between two)
            $sizes[$next] = Format::file_size($next);
            $next *= 2;
        }
        // Add extra option if top-limit in php.ini doesn't fall
        // at a power of two
        if ($next < $limit * 2)
            $sizes[$limit] = Format::file_size($limit);

        $types = array();
        foreach (self::getFileTypes() as $type=>$info) {
            $types[$type] = $info['description'];
        }

        global $cfg;
        return array(
            'size' => new ChoiceField(array(
                'label'=>__('Maximum File Size'),
                'hint'=>__('Choose maximum size of a single file uploaded to this field'),
                'default'=>$cfg->getMaxFileSize(),
                'choices'=>$sizes
            )),
            'mimetypes' => new ChoiceField(array(
                'label'=>__('Restrict by File Type'),
                'hint'=>__('Optionally, choose acceptable file types.'),
                'required'=>false,
                'choices'=>$types,
                'configuration'=>array('multiselect'=>true,'prompt'=>__('No restrictions'))
            )),
            'extensions' => new TextareaField(array(
                'label'=>__('Additional File Type Filters'),
                'hint'=>__('Optionally, enter comma-separated list of additional file types, by extension. (e.g .doc, .pdf).'),
                'configuration'=>array('html'=>false, 'rows'=>2),
            )),
            'strictmimecheck' => new BooleanField([
                'id' => 4, 'label'=>__('Strict Mime Type Check'), 'required' => false, 'default' => false,
                'hint' => 'File Mime Type associations is OS dependent',
                'configuration' => ['desc' => __('Enable strict Mime Type check')]]),
            'max' => new TextboxField(array(
                'label'=>__('Maximum Files'),
                'hint'=>__('Users cannot upload more than this many files.'),
                'default'=>false,
                'required'=>false,
                'validator'=>'number',
                'configuration'=>array('size'=>8, 'length'=>4, 'placeholder'=>__('No limit')),
            ))
        );
    }

    /**
     * Called from the ajax handler for async uploads via web clients.
     */
    function ajaxUpload($bypass=false) {
        $config = $this->getConfiguration();

        $files = AttachmentFile::format($_FILES['upload'],
            // For numeric fields assume configuration exists
            !is_numeric($this->get('id')));
        if (count($files) != 1)
            Http::response(400, 'Send one file at a time');
        $file = array_shift($files);
        $file['name'] = urldecode($file['name']);
        $config = $this->getConfiguration();

        if (!self::isValidFile($file, $config['strictmimecheck']))
            Http::response(413, 'Invalid File');

        if (!$bypass && !$this->isValidFileType($file['name'], $file['type']))
            Http::response(415, 'File type is not allowed');

        $config = $this->getConfiguration();
        if (!$bypass && $file['size'] > $config['size'])
            Http::response(413, 'File is too large');

        if (!($F = AttachmentFile::upload($file)))
            Http::response(500, 'Unable to store file: '. $file['error']);

        $id = $F->getId();

        // This file is allowed for attachment in this session
        $_SESSION[':uploadedFiles'][$id] = $F->getName();

        return $id;
    }

    /**
     * Called from FileUploadWidget::getValue() when manual upload is used
     * for browsers which do not support the HTML5 way of uploading async.
     */
    function uploadFile($file) {
        if (!$this->isValidFileType($file['name'], $file['type']))
            throw new FileUploadError(__('File type is not allowed'));

        $config = $this->getConfiguration();

        if (!self::isValidFile($file, $config['strictmimecheck']))
             throw new FileUploadError(__('Invalid File'));

        if ($file['size'] > $config['size'])
            throw new FileUploadError(__('File size is too large'));

        return AttachmentFile::upload($file);
    }

    /**
     * Called from API and email routines and such to handle attachments
     * sent other than via web upload
     */
    function uploadAttachment(&$file) {
        if (!$this->isValidFileType($file['name'], $file['type']))
            throw new FileUploadError(__('File type is not allowed'));

        if (!isset($file['data']) && isset($file['data_cbk'])
                && is_callable($file['data_cbk']))
            $file['data'] = $file['data_cbk']();

        if (!isset($file['size']) && isset($file['data'])) {
            // bootstrap.php include a compat version of mb_strlen
            if (extension_loaded('mbstring'))
                $file['size'] = mb_strlen($file['data'], '8bit');
            else
                $file['size'] = strlen($file['data']);
        }

        $config = $this->getConfiguration();
        if ($file['size'] > $config['size'])
            throw new FileUploadError(__('File size is too large'));

        if (!$F = AttachmentFile::create($file))
            throw new FileUploadError(__('Unable to save file'));

        return $F;
    }

    /**
     * Strict mode can be enabled in Admin Panel > Settings > Tickets
     *
     * PS: Please note that the a mismatch can happen if the mime types
     * database is not up to date or a little different compared to what the
     * browser reports.
     **/
    static function isValidFile($file, $strict = false) {
        // Strict mime check
        if ($strict
            && !empty($file['type'])
            && FileObject::mimecmp($file['tmp_name'], $file['type']))
            return false;

        // Check invalid image hacks
        if ($file['tmp_name']
                && stripos($file['type'], 'image/') === 0
                && !exif_imagetype($file['tmp_name']))
            return false;

        return true;
    }

    function isValidFileType($name, $type=false) {
        $config = $this->getConfiguration();

        // Check MIME type - file ext. shouldn't be solely trusted.
        if ($type && $config['__mimetypes']
                && in_array($type, $config['__mimetypes'], true))
            return true;

        // Return true if all file types are allowed (.*)
        if (!$config['__extensions'] || in_array('.*', $config['__extensions']))
            return true;

        $allowed = $config['__extensions'];
        $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));

        return ($ext && is_array($allowed) && in_array(".$ext", $allowed));
    }

    function getAttachments() {
        if (!isset($this->attachments) && ($a = $this->getAnswer())
            && ($e = $a->getEntry()) && ($e->get('id'))
        ) {
            $this->attachments = GenericAttachments::forIdAndType(
                // Combine the field and entry ids to make the key
                sprintf('%u', abs(crc32('E'.$this->get('id').$e->get('id')))),
                'E');
        }
        return $this->attachments ?: array();
    }

    function setAttachments(GenericAttachments $att) {
        $this->attachments = $att;
    }

    function getFiles() {
        if (!isset($this->files)) {
            $files = array();
            foreach ($this->getAttachments() as $a) {
                if ($a && ($f=$a->getFile()))
                    $files[] = $f;
            }

            foreach ($this->getClean(false) ?: array() as $key => $value)
                $files[] = array('id' => $key, 'name' => $value);

            $this->files = $files;
        }
        return $this->files;
    }

    function getConfiguration() {
        global $cfg;

        $config = parent::getConfiguration();
        // If no size present default to system setting
        $config['size'] ??= $cfg->getMaxFileSize();
        $_types = self::getFileTypes();
        $mimetypes = array();
        $extensions = array();
        if (isset($config['mimetypes']) && is_array($config['mimetypes'])) {
            foreach ($config['mimetypes'] as $type=>$desc) {
                foreach ($_types[$type]['types'] as $mime=>$exts) {
                    $mimetypes[$mime] = true;
                    if (is_array($exts))
                        foreach ($exts as $ext)
                            $extensions['.'.$ext] = true;
                }
            }
        }
        if (strpos($config['extensions'], '.*') !== false)
            $config['extensions'] = '';

        if (is_string($config['extensions'])) {
            foreach (preg_split('/\s+/', str_replace(',',' ', $config['extensions'])) as $ext) {
                if (!$ext) {
                    continue;
                }
                elseif (strpos($ext, '/')) {
                    $mimetypes[$ext] = true;
                }
                else {
                    if ($ext[0] != '.')
                        $ext = '.' . $ext;

                    // Ensure that the extension is lower-cased for comparison latr
                    $ext = strtolower($ext);

                    // Add this to the MIME types list so it can be exported to
                    // the @accept attribute
                    if (!isset($extensions[$ext]))
                        $mimetypes[$ext] = true;

                    $extensions[$ext] = true;
                }
            }
            $config['__extensions'] = array_keys($extensions);
        }
        elseif (is_array($config['extensions'])) {
            $config['__extensions'] = $config['extensions'];
        }

        // 'mimetypes' is the array represented from the user interface,
        // '__mimetypes' is a complete list of supported MIME types.
        $config['__mimetypes'] = array_keys($mimetypes);
        return $config;
    }

    // When the field is saved to database, encode the ID listing as a json
    // array. Then, inspect the difference between the files actually
    // attached to this field
    function to_database($value) {
        $this->getAttachments();
        if (isset($this->attachments) && $this->attachments) {
            $this->attachments->keepOnlyFileIds($value);
        }
        return JsonDataEncoder::encode($value) ?? NULL;
    }

    function parse($value) {
        return $value;
    }

    function to_php($value) {
        return is_array($value) ? $value : JsonDataParser::decode($value);
    }

    function display($value) {
        $links = array();
        foreach ($this->getAttachments() as $a) {
            $links[] = sprintf('<a class="no-pjax" href="%s"><i class="icon-paperclip icon-flip-horizontal"></i> %s</a>',
                Format::htmlchars($a->file->getDownloadUrl()),
                Format::htmlchars($a->getFilename()));
        }
        return implode('<br/>', $links);
    }

    function toString($value) {
        $files = array();
        foreach ($this->getFiles() as $f) {
            $files[] = $f->name;
        }
        return implode(', ', $files);
    }

    function db_cleanup($field=false) {
        if ($this->getAttachments()) {
            $this->attachments->deleteAll();
        }
    }

    function asVar($value, $id=false) {
        if (($attachments = $this->getAttachments()))
            $attachments = $attachments->all();

        return new FileFieldAttachments($attachments ?: array());
    }
    function asVarType() {
        return 'FileFieldAttachments';
    }

    function whatChanged($before, $after) {
        $B = (array) $before;
        $A = (array) $after;
        $added = array_diff($A, $B);
        $deleted = array_diff($B, $A);
        $added = Format::htmlchars(array_values($added));
        $deleted = Format::htmlchars(array_values($deleted));

        if ($added && $deleted) {
            $desc = sprintf(
                __('added <strong>%1$s</strong> and removed <strong>%2$s</strong>'),
                implode(', ', $added), implode(', ', $deleted));
        }
        elseif ($added) {
            $desc = sprintf(
                __('added <strong>%1$s</strong>'),
                implode(', ', $added));
        }
        elseif ($deleted) {
            $desc = sprintf(
                __('removed <strong>%1$s</strong>'),
                implode(', ', $deleted));
        }
        else {
            $desc = sprintf(
                __('changed from <strong>%1$s</strong> to <strong>%2$s</strong>'),
                $this->display($before), $this->display($after));
        }
        return $desc;
    }
}

class FileFieldAttachments {
    var $attachments;

    function __construct($attachments) {
        $this->attachments = $attachments;
    }

    function __toString() {
        $files = array();
        foreach ($this->getAttachments() as $a) {
            $files[] = $a->getFilename();
        }
        return implode(', ', $files);
    }

    function getAttachments() {
        return $this->attachments ?: array();
    }

    function getVar($tag) {
        switch ($tag) {
        case 'names':
            return $this->__toString();
        case 'files':
            throw new OOBContent(OOBContent::FILES, $this->getAttachments());
        }
    }

    static function getVarScope() {
        return array(
            'names' => __('List of file names'),
            'files' => __('Attached files'),
        );
    }
}

class ColorChoiceField extends FormField {
    static $widget = 'ColorPickerWidget';
}

class InlineFormData extends ArrayObject {
    var $_form;

    function __construct($form, array $data=array()) {
        parent::__construct($data);
        $this->_form = $form;
    }

    function getVar($tag) {
        foreach ($this->_form->getFields() as $f) {
            if ($f->get('name') == $tag)
                return $this[$f->get('id')];
        }
    }
}


class InlineFormField extends FormField {
    static $widget = 'InlineFormWidget';

    var $_iform = null;

    function validateEntry($value) {
        if (!$this->getInlineForm()->isValid()) {
            $config = $this->getConfiguration();
            $this->_errors[] = isset($config['error'])
                ? $config['error'] : __('Correct any errors below and try again.');
        }
    }

    function parse($value) {
        // The InlineFieldWidget returns an array of cleaned data
        return $value;
    }

    function to_database($value) {
        return JsonDataEncoder::encode($value);
    }

    function to_php($value) {
        $data = JsonDataParser::decode($value);
        // The InlineFormData helps with the variable replacer API
        return new InlineFormData($this->getInlineForm(), $data);
    }

    function display($data) {
        $form = $this->getInlineForm();
        ob_start(); ?>
        <div><?php
        foreach ($form->getFields() as $field) { ?>
            <span style="display:inline-block;padding:0 5px;vertical-align:top">
                <strong><?php echo Format::htmlchars($field->get('label')); ?></strong>
                <div><?php
                    $value = $data[$field->get('id')];
                    echo $field->display($value); ?></div>
            </span><?php
        } ?>
        </div><?php
        return ob_get_clean();
    }

    function getInlineForm($data=false) {
        $form = $this->get('form');
        if (is_array($form)) {
            $form = new SimpleForm($form, $data ?: $this->value ?: $this->getSource());
            // Ensure unique, but predictable form and field IDs
            $form->setId(sprintf('%u', crc32($this->get('name')) >> 1));
        }
        return $form;
    }
}

class InlineDynamicFormField extends FormField {
    function getInlineForm($data=false) {
        if (!isset($this->_iform) || $data) {
            $config = $this->getConfiguration();
            $this->_iform = DynamicForm::lookup($config['form']);
            if ($data)
                $this->_iform = $this->_iform->getForm($data);
        }
        return $this->_iform;
    }

    function getConfigurationOptions() {
        $forms = DynamicForm::objects()->filter(array('type'=>'G'))
            ->values_flat('id', 'title');
        $choices = array();
        foreach ($forms as $row) {
            list($id, $title) = $row;
            $choices[$id] = $title;
        }
        return array(
            'form' => new ChoiceField(array(
                'id'=>2, 'label'=>'Inline Form', 'required'=>true,
                'default'=>'', 'choices'=>$choices
            )),
        );
    }
}

class InlineFormWidget extends Widget {
    function render($mode=false) {
        $form = $this->field->getInlineForm();
        if (!$form)
            return;
        // Handle first-step edits -- load data from $this->value
        if ($form instanceof DynamicForm && !$form->getSource())
            $form = $form->getForm($this->value);
        $inc = ($mode == 'client') ? CLIENTINC_DIR : STAFFINC_DIR;
        include $inc . 'templates/inline-form.tmpl.php';
    }

    function getValue() {
        $data = $this->field->getSource();
        if (!$data)
            return null;
        $form = $this->field->getInlineForm($data);
        if (!$form)
            return null;
        return $form->getClean();
    }
}

class Widget {
    static $media = null;

    function __construct($field) {
        $this->field = $field;
        $this->name = $field->getFormName();
        $this->id = '_' . $this->name;
    }

    function parseValue() {
        $this->value = $this->getValue();
        if (!isset($this->value) && is_object($this->field->getAnswer()))
            $this->value = $this->field->getAnswer()->getValue();
        if (!isset($this->value) && isset($this->field->value))
            $this->value = $this->field->value;
    }

    function getValue() {
        $data = $this->field->getSource();
        foreach ($this->field->getFormNames() as $name)
            if (isset($data[$name]))
                return $data[$name];

        return null;
    }

    /**
     * getJsValueGetter
     *
     * Used with the dependent fields feature, this function should return a
     * single javascript expression which can be used in a larger expression
     * (<> == true, where <> is the result of this function). The %s token
     * will be replaced with a jQuery variable representing this widget.
     */
    function getJsValueGetter($id='%s') {
        return sprintf('%s.val()', $id);
    }

    /**
     * getJsComparator
     *
     * Used with the dependent fields to get comparison expression
     *
     */
    function getJsComparator($value, $id) {

        if (strpos($value, '|') !== false)
            return sprintf('$.inArray(%s, %s) !== -1',
                   $this->getJsValueGetter($id),
                   JsonDataEncoder::encode(explode('|', $value)));

        return sprintf('%s == %s',
                $this->getJsValueGetter($id),
                JsonDataEncoder::encode($value));
    }
}

class TextboxWidget extends Widget {
    static $input_type = 'text';

    function render($options=array(), $extraConfig=false) {
        $config = $this->field->getConfiguration();
        if (is_array($extraConfig)) {
            foreach ($extraConfig as $k=>$v)
                if (!isset($config[$k]) || !$config[$k])
                    $config[$k] = $v;
        }

        // Input attributes
        $attrs = array();
        foreach ($config as $k => $v) {
            switch ($k) {
                case 'autocomplete':
                    if (is_numeric($v))
                        $v = $v ? 'on' : 'off';
                    $attrs[$k] = '"'.$v.'"';
                    break;
                case 'disabled';
                    $attrs[$k] = '"disabled"';
                    break;
                case 'translatable':
                    if ($v)
                        $attrs['data-translate-tag'] =  '"'.$v.'"';
                    break;
                case 'length':
                    $k = 'maxlength';
                case 'size':
                case 'maxlength':
                    if ($v && is_numeric($v))
                        $attrs[$k] = '"'.$v.'"';
                    break;
                case 'class':
                case 'classes':
                    $attrs['class'] = '"'.$v.'"';
                    break;
                case 'inputmode':
                case 'pattern':
                    $attrs[$k] = '"'.$v.'"';
                    break;
            }
        }
        // autofocus
        $autofocus = '';
        if (isset($config['autofocus']))
            $autofocus = 'autofocus';
        // placeholder
        $attrs['placeholder'] = sprintf('"%s"',
                Format::htmlchars($this->field->getLocal('placeholder',
                    $config['placeholder'])));
        $type = static::$input_type;
        $types = array(
            'email' => 'email',
            'phone' => 'tel',
        );
        if ($type == 'text' && isset($types[$config['validator']]))
            $type = $types[$config['validator']];
        ?>
        <input type="<?php echo $type; ?>"
            id="<?php echo $this->id; ?>"
            <?php echo $autofocus .' '.Format::array_implode('=', ' ',
                    array_filter($attrs)); ?>
            name="<?php echo $this->name; ?>"
            value="<?php echo Format::htmlchars($this->value, true); ?>"/>
        <?php
    }
}


class TextboxSelectionWidget extends TextboxWidget {
    //TODO: Support multi-input e.g comma separated inputs
    function render($options=array(), $extraConfig=array()) {

        if ($this->value && is_array($this->value))
            $this->value = current($this->value);

        parent::render($options);
    }

    function getValue() {

        $value = parent::getValue();
        if ($value && ($item=$this->field->lookupChoice((string) $value)))
            $value = $item;

        return $value;
    }
}

class PasswordWidget extends TextboxWidget {
    static $input_type = 'password';

    function render($mode=false, $extra=false) {
        $extra = array();
        if (isset($this->field->value)) {
            $extra['placeholder'] = str_repeat('•',
                    strlen($this->field->value));
        }
        return parent::render($mode, $extra);
    }

    function parseValue() {
        parent::parseValue();
        // Show empty box unless failed POST
        if ($_SERVER['REQUEST_METHOD'] != 'POST'
                || !$this->field->getForm()->isValid())
            $this->value = '';
    }
}

class TextareaWidget extends Widget {
    function render($options=array()) {
        $config = $this->field->getConfiguration();
        // process textarea attributes
        $attrs = array();
        foreach ($config as $k => $v) {
            switch ($k) {
                case 'rows':
                case 'cols':
                case 'length':
                case 'maxlength':
                    if ($v && is_numeric($v))
                        $attrs[$k] = '"'.$v.'"';;
                    break;
                case 'context':
                    $attrs['data-root-context'] =  '"'.$v.'"';
                    break;
                case 'class':
                    // This might conflict with html attr below
                    $attrs[$k] = '"'.$v.'"';
                    break;
                case 'html':
                    if ($v) {
                        $class = array('richtext', 'no-bar');
                        $class[] = @$config['size'] ?: 'small';
                        $attrs['class'] =  '"'.implode(' ', $class).'"';
                        $this->value = Format::viewableImages($this->value);
                    }
                    break;
            }
        }
        // placeholder
        $attrs['placeholder'] = sprintf('"%s"',
                Format::htmlchars($this->field->getLocal('placeholder',
                $config['placeholder'])));
        ?>
        <span style="display:inline-block;width:100%">
        <textarea <?php echo Format::array_implode('=', ' ',
                array_filter($attrs)); ?>
            id="<?php echo $this->id; ?>"
            name="<?php echo $this->name; ?>"><?php
                echo Format::htmlchars($this->value, ($config['html']));
            ?></textarea>
        </span>
        <?php
    }

    function parseValue() {
        parent::parseValue();
        if (isset($this->value)) {
            $value = $this->value;
            $config = $this->field->getConfiguration();
            // Trim empty spaces based on text input type.
            // Preserve original input if not empty.
            if ($config['html'])
                $this->value = trim($value, " <>br/\t\n\r") ? $value : '';
            else
                $this->value = trim($value) ? $value : '';
        }
    }

}

class PhoneNumberWidget extends Widget {
    function render($options=array()) {
        $config = $this->field->getConfiguration();
        list($phone, $ext) = explode("X", $this->value);
        ?>
        <input id="<?php echo $this->id; ?>" type="tel" name="<?php echo $this->name; ?>" value="<?php
        echo Format::htmlchars($phone); ?>"/><?php
        // Allow display of extension field even if disabled if the phone
        // number being edited has an extension
        if ($ext || $config['ext']) { ?> <?php echo __('Ext'); ?>:
            <input type="text" name="<?php
            echo $this->name; ?>-ext" value="<?php echo Format::htmlchars($ext);
                ?>" size="5"/>
        <?php }
    }

    function getValue() {
        $data = $this->field->getSource();
        $base = parent::getValue();
        if ($base === null)
            return $base;
        $ext = $data["{$this->name}-ext"];
        // NOTE: 'X' is significant. Don't change it
        if ($ext) $ext = 'X'.$ext;
        return $base . $ext;
    }
}

class ChoicesWidget extends Widget {
    function render($options=array()) {
        $mode = null;
        if (isset($options['mode']))
            $mode = $options['mode'];
        elseif (isset($this->field->options['render_mode']))
            $mode = $this->field->options['render_mode'];

        if ($mode == 'view') {
            $val = (string) $this->field;
            echo sprintf('<span id="field_%s" %s >%s</span>', $this->id,
                    $val ? '': 'class="faded"',
                    $val ?: __('None'));
            return;
        }

        $config = $this->field->getConfiguration();
        if ($mode == 'search') {
            $config['multiselect'] = true;
        }

        // Determine the value for the default (the one listed if nothing is
        // selected)
        $choices = $this->field->getChoices(true, $options);
        $prompt = ($config['prompt'])
            ? $this->field->getLocal('prompt', $config['prompt'])
            : __('Select'
            /* Used as a default prompt for a custom drop-down list */);

        $have_def = false;
        // We don't consider the 'default' when rendering in 'search' mode
        if (!strcasecmp($mode, 'search')) {
            $def_val = $prompt;
        } else {
            $showdefault = true;
            if ($mode != 'create')
                 $showdefault = false;
            $def_key = $this->field->get('default');
            if (!$def_key && isset($config['default']))
                $def_key = $config['default'];
            if (is_array($def_key))
                $def_key = key($def_key);
            $have_def = isset($choices[$def_key]);
            $def_val = ($have_def && !$showdefault) ? $choices[$def_key] : $prompt;
        }

        $values = $this->value;
        if (!is_array($values) && isset($values)) {
            $values = array($values => $this->field->getChoice($values));
        }

        if (!is_array($values))
            $values = $have_def ? array($def_key => $choices[$def_key]) : array();

        if (isset($config['classes']))
            $classes = 'class="'.$config['classes'].'"';
        ?>
        <select name="<?php echo $this->name; ?>[]"
            <?php echo implode(' ', array_filter(array($classes))); ?>
            id="<?php echo $this->id; ?>"
            <?php if (isset($config['data']))
              foreach ($config['data'] as $D=>$V)
                echo ' data-'.$D.'="'.Format::htmlchars($V).'"';
            ?>
            data-placeholder="<?php echo Format::htmlchars($prompt); ?>"
            <?php if ($config['disabled'])
                echo ' disabled="disabled"'; ?>
            <?php if ($config['multiselect'])
                echo ' multiple="multiple"'; ?>>
            <?php if ($showdefault || (!$have_def && !$config['multiselect'])) { ?>
            <option value="<?php echo $showdefault ? '' : $def_key; ?>">&mdash; <?php
                echo $def_val; ?> &mdash;</option>
<?php
        }
        $this->emitChoices($choices, $values, $have_def, $def_key); ?>
        </select>
        <?php
        if ($config['multiselect']) {
         ?>
        <script type="text/javascript">
        $(function() {
            $("#<?php echo $this->id; ?>")
            .select2({'minimumResultsForSearch':10, 'width': '350px'});
        });
        </script>
       <?php
        }
    }

    function emitChoices($choices, $values=array(), $have_def=false, $def_key=null) {
        reset($choices);
        if (is_array(current($choices)) || current($choices) instanceof Traversable)
            return $this->emitComplexChoices($choices, $values, $have_def, $def_key);

        foreach ($choices as $key => $name) {
            if (!$have_def && $key === $def_key)
                continue; ?>
            <option value="<?php echo $key; ?>" <?php
                if (isset($values[$key])) echo 'selected="selected"';
            ?>><?php echo Format::htmlchars($name); ?></option>
        <?php
        }
    }

    function emitComplexChoices($choices, $values=array(), $have_def=false, $def_key=null) {
        foreach ($choices as $label => $group) {
            if (!count($group)) continue;
            ?>
            <optgroup label="<?php echo $label; ?>"><?php
            foreach ($group as $key => $name) {
                if (!$have_def && $key == $def_key)
                    continue; ?>
            <option value="<?php echo $key; ?>" <?php
                if (isset($values[$key])) echo 'selected="selected"';
            ?>><?php echo Format::htmlchars($name); ?></option>
<?php       } ?>
            </optgroup><?php
        }
    }

    function getValue() {

        if (!($value = parent::getValue()))
            return null;

        if ($value && !is_array($value))
            $value = array($value);

        // Assume multiselect
        $values = array();
        $choices = $this->field->getChoices();

        if ($choices && is_array($value)) {
            // Complex choices
            if (is_array(current($choices))
                    || current($choices) instanceof Traversable) {
                foreach ($choices as $label => $group) {
                     foreach ($group as $k => $v)
                        if (in_array($k, $value))
                            $values[$k] = $v;
                }
            } else {
                foreach($value as $k => $v) {
                    if (isset($choices[$v]))
                        $values[$v] = $choices[$v];
                    elseif (($i=$this->field->lookupChoice($v)))
                        $values += $i;
                    elseif (!$k && $v)
                      return $v;
                }
            }
        }

        return $values;
    }

    function getJsValueGetter($id='%s') {
        return sprintf('%s.find(":selected").val()', $id);
    }

}

/**
 * A widget for the ChoiceField which will render a list of radio boxes or
 * checkboxes depending on the value of $config['multiple']. Complex choices
 * are also supported and will be rendered as divs.
 */
class BoxChoicesWidget extends Widget {
    function render($options=array()) {
        $this->emitChoices($this->field->getChoices());
    }

    function emitChoices($choices) {
      static $uid = 1;

      if (!isset($this->value))
          $this->value = $this->field->get('default');
      $config = $this->field->getConfiguration();
      $type = $config['multiple'] ? 'checkbox' : 'radio';

      $classes = array('checkbox');
      if (isset($config['classes']))
          $classes = array_merge($classes, (array) $config['classes']);

      foreach ($choices as $k => $v) {
          if (is_array($v)) {
              $this->renderSectionBreak($k);
              $this->emitChoices($v);
              continue;
          }
          $id = sprintf("%s-%s", $this->id, $uid++);
?>
        <label class="<?php echo implode(' ', $classes); ?>"
            for="<?php echo $id; ?>">
        <input id="<?php echo $id; ?>" type="<?php echo $type; ?>"
            name="<?php echo $this->name; ?>[]" <?php
            if ($this->value[$k]) echo 'checked="checked"'; ?> value="<?php
            echo Format::htmlchars($k); ?>"/>
        <?php
        if ($v) {
            echo Format::viewableImages($v);
        } ?>
        </label>
<?php   }
    }

    function renderSectionBreak($label) { ?>
        <div><?php echo Format::htmlchars($label); ?></div>
<?php
    }

    function getValue() {
        $data = $this->field->getSource();
        if (count($data)) {
            if (!isset($data[$this->name]))
                return array();
            return $this->collectValues($data[$this->name], $this->field->getChoices());
        }
        return parent::getValue();
    }

    function collectValues($data, $choices) {
        $value = array();
        foreach ($choices as $k => $v) {
            if (is_array($v))
                $value = array_merge($value, $this->collectValues($data, $v));
            elseif (@in_array($k, $data))
                $value[$k] = $v;
        }
        return $value;
    }
}

/**
 * An extension to the BoxChoicesWidget which will render complex choices in
 * tabs.
 */
class TabbedBoxChoicesWidget extends BoxChoicesWidget {
    function render($options=array()) {
        $tabs = array();
        foreach ($this->field->getChoices() as $label=>$group) {
            if (is_array($group)) {
                $tabs[$label] = $group;
            }
            else {
                $this->emitChoices(array($label=>$group));
            }
        }
        if ($tabs) {
            ?>
            <div>
            <ul class="alt tabs">
<?php       $i = 0;
            foreach ($tabs as $label => $group) {
                $active = $i++ == 0; ?>
                <li <?php if ($active) echo 'class="active"';
                  ?>><a href="#<?php echo sprintf('%s-%s', $this->name, Format::slugify($label));
                  ?>"><?php echo Format::htmlchars($label); ?></a></li>
<?php       } ?>
            </ul>
<?php       $i = 0;
            foreach ($tabs as $label => $group) {
                $first = $i++ == 0; ?>
                <div class="tab_content <?php if (!$first) echo 'hidden'; ?>" id="<?php
                  echo sprintf('%s-%s', $this->name, Format::slugify($label));?>">
<?php           $this->emitChoices($group); ?>
                </div>
<?php       } ?>
            </div>
<?php   }
    }
}

/**
* TimezoneWidget extends ChoicesWidget to add auto-detect and select2 search
* options
*
**/
class TimezoneWidget extends ChoicesWidget {

    function render($options=array()) {
        parent::render($options);
        $config = $this->field->getConfiguration();
        if (@$config['autodetect']) {
        ?>
        <button type="button" class="action-button" onclick="javascript:
            $('head').append($('<script>').attr('src', '<?php
            echo ROOT_PATH; ?>js/jstz.min.js'));
            var recheck = setInterval(function() {
                if (window.jstz !== undefined) {
                    clearInterval(recheck);
                    var zone = jstz.determine();
                    $('#<?php echo $this->id; ?>').val(zone.name()).trigger('change');

                }
            }, 100);
            return false;"
            style="vertical-align:middle">
            <i class="icon-map-marker"></i> <?php echo __('Auto Detect'); ?>
        </button>
        <?php
        } ?>
        <script type="text/javascript">
            $(function() {
                $('#<?php echo $this->id; ?>').select2({
                    allowClear: true,
                    width: '300px'
                });
            });
        </script>
      <?php
    }
}

class CheckboxWidget extends Widget {
    function __construct($field) {
        parent::__construct($field);
        $this->name = '_field-checkboxes';
    }

    function render($options=array()) {
        $config = $this->field->getConfiguration();
        if (!isset($this->value))
            $this->value = $this->field->get('default');
        $classes = array('checkbox');
        if (isset($config['classes']))
            $classes = array_merge($classes, (array) $config['classes']);
        ?>
        <label class="<?php echo implode(' ', $classes); ?>">
        <input id="<?php echo $this->id; ?>"
            type="checkbox" name="<?php echo $this->name; ?>[]" <?php
            if ($this->value) echo 'checked="checked"'; ?> value="<?php
            echo $this->field->get('id'); ?>"/>
        <?php
        if ($config['desc']) {
            echo Format::viewableImages($config['desc']);
        } ?>
        </label>
<?php
    }

    function getValue() {
        $data = $this->field->getSource();
        if (is_array($data)) {
            if (isset($data[$this->name]))
                return @in_array($this->field->get('id'),
                        $data[$this->name]);
            // initial value set on source
            if (isset($data[$this->field->get('id')]))
                return $data[$this->field->get('id')];
        }

        if (!$data && isset($this->value))
            return $this->value;


        return parent::getValue();
    }

    function getJsValueGetter($id='%s') {
        return sprintf('%s.is(":checked")', $id);
    }

}

class DatetimePickerWidget extends Widget {

    function render($options=array()) {
        global $cfg;

        $config = $this->field->getConfiguration();
        $timezone = $this->field->getTimezone();
        $dateFormat = $cfg->getDateFormat(true);
        $timeFormat = $cfg->getTimeFormat(true);
        if (!isset($this->value) && ($default=$this->field->get('default')))
            $this->value = $default;

        if ($this->value == 0)
            $this->value = '';

        if ($this->value) {
            $datetime = Format::parseDateTime($this->value);
            if ($config['time'])
                // Convert to user's timezone for update.
                $datetime->setTimezone($timezone);

            // Get formatted date
            $this->value = Format::date($datetime->getTimestamp(), false,
                        false, $timezone ? $timezone->getName() : 'UTC');
            // Get formatted time
            if ($config['time']) {
                 $this->value .=' '.Format::time($datetime->getTimestamp(),
                         false, $timeFormat, $timezone ?
                         $timezone->getName() : 'UTC');
            }

        } else {
            // For timezone display purposes
            $datetime = new DateTime('now');
            $datetime->setTimezone($timezone);
        }
        ?>
        <input type="text" name="<?php echo $this->name; ?>"
            id="<?php echo $this->id; ?>" style="display:inline-block;width:auto"
            value="<?php echo $this->value; ?>"
            size="<?php $config['time'] ? 20 : 12; ?>"
            autocomplete="off" class="dp" />
        <?php
        // Timezone hint
        // Show timzone hit by default but allow field to turn it off.
        $showtimezone = true;
        if (isset($config['showtimezone']))
            $showtimezone = $config['showtimezone'];

        if ($datetime && $showtimezone) {
            echo sprintf('&nbsp;<span class="faded">(<a href="#"
                        data-placement="top" data-toggle="tooltip"
                        title="%s">%s</a>)</span>',
                    $datetime->getTimezone()->getName(),
                    $datetime->format('T'));
        }
        ?>
        <script type="text/javascript">
            $(function() {
                $('input[name="<?php echo $this->name; ?>"]').<?php echo
                $config['time'] ? 'datetimepicker':'datepicker';?>({
                    <?php
                    if ($dt=$this->field->getMinDateTime())
                        echo sprintf("minDate: new Date(%s),\n", $dt->format('U')*1000);
                    if ($dt=$this->field->getMaxDateTime())
                        echo sprintf("maxDate: new Date(%s),\n", $dt->format('U')*1000);
                    elseif (!$config['future'])
                        echo "maxDate: new Date().getTime(),\n";

                    // Set time options
                    if ($config['time']) {
                        // Set Timezone
                        echo sprintf("timezone: %s,\n",
                                ($datetime->getOffset()/60));
                        echo sprintf("
                                controlType: 'select',\n
                                timeInput: true,\n
                                timeFormat: \"%s\",\n",
                                Format::dtfmt_php2js($timeFormat));
                    }
                    ?>
                    numberOfMonths: 2,
                    showButtonPanel: true,
                    buttonImage: './images/cal.png',
                    showOn:'both',
                    dateFormat: '<?php echo
                        Format::dtfmt_php2js($dateFormat); ?>'
                });
            });
        </script>
        <?php
    }

    /**
     * Function: getValue
     * Combines the datepicker date value and the time dropdown selected
     * time value into a single date and time string value in DateTime::W3C
     */
    function getValue() {
        global $cfg;

        if ($value = parent::getValue()) {
            if (($dt = Format::parseDateTime($value))) {
                // Effective timezone for the selection
                if (($timezone = $this->field->getTimezone()))
                    $dt->setTimezone($timezone);
                // Format date time to universal format
                $value = $dt->format('Y-m-d H:i:s T');
            }
        }

        return $value;
    }
}

class TimePickerWidget extends Widget {

    function render($options=array()) {
        $config = $this->field->getConfiguration();
        if (!isset($this->value) && ($default=$this->field->get('default')))
            $this->value = $default;

        // For timezone display purposes only - for now
        $datetime = new DateTime('now');
        // Selection timezone
        $datetime->setTimezone($this->field->getTimeZone());

        if ($this->value) {
            // TODO: Reformat time here to match settings
        }

        ?>
        <input type="text" name="<?php echo $this->name; ?>"
            id="<?php echo $this->id; ?>" style="display:inline-block;width:auto"
            value="<?php echo $this->value; ?>"
            size="10"
            autocomplete="off"  />
        <?php
        // Timezone hint
        // Show timzone hit by default but allow field to turn it off.
        $showtimezone = true;
        if (isset($config['showtimezone']))
            $showtimezone = $config['showtimezone'];

        if ($showtimezone) {
            echo sprintf('&nbsp;<span class="faded">(<a href="#"
                        data-placement="top" data-toggle="tooltip"
                        title="%s">%s</a>)</span>',
                    $datetime->getTimezone()->getName(),
                    $datetime->format('T'));
        }
        ?>
        <script type="text/javascript">
            $(function() {
                $('input[name="<?php echo $this->name; ?>"]').timepicker({
                    <?php
                    // Set time options
                    echo sprintf("
                            controlType: 'select',\n
                            timeInput: true,\n
                            timeFormat: \"%s\",\n",
                            "hh:mm tt");
                    echo sprintf("timezone: %s\n",
                            ($datetime->getOffset()/60));
                    ?>
                });
            });
        </script>
        <?php
    }

    /**
     * Function: getValue
     */
    function getValue() {
        global $cfg;

        if ($value = parent::getValue()) {
            // TODO: Return ISO format.
        }

        return $value;
    }
}
class SectionBreakWidget extends Widget {
    function render($options=array()) {
        ?><div class="form-header section-break"><h3><?php
        echo Format::htmlchars($this->field->getLocal('label'));
        ?></h3><em><?php echo Format::display($this->field->getLocal('hint'));
        ?></em></div>
        <?php
    }
}

class ThreadEntryWidget extends Widget {
    function render($options=array()) {

        $config = $this->field->getConfiguration();
        $object_id = false;
        if ($options['client']) {
            $namespace = $options['draft-namespace']
                ?: 'ticket.client';
             $object_id = substr(session_id(), -12);
        } else {
            $namespace = $options['draft-namespace'] ?: 'ticket.staff';
        }

        list($draft, $attrs) = Draft::getDraftAndDataAttrs($namespace, $object_id, $this->value);
        ?>
        <textarea style="width:100%;" name="<?php echo $this->name; ?>"
            placeholder="<?php echo Format::htmlchars($this->field->get('placeholder')); ?>"
            class="<?php if ($config['html']) echo 'richtext';
                ?> draft draft-delete" <?php echo $attrs; ?>
            cols="21" rows="8" style="width:80%;"><?php echo
            ThreadEntryBody::clean($this->value ?: $draft); ?></textarea>
    <?php
        if (!$config['attachments'])
            return;

        $attachments = $this->getAttachments($config);
        print $attachments->render($options);
        foreach ($attachments->getMedia() as $type=>$urls) {
            foreach ($urls as $url)
                Form::emitMedia($url, $type);
        }
    }

    function getAttachments($config=false) {
        if (!$config)
            $config = $this->field->getConfiguration();

        $field = new FileUploadField(array(
            'id'=>'attach',
            'name'=>'attach:' . $this->field->get('id'),
            'configuration'=>$config)
        );
        $field->setForm($this->field->getForm());
        return $field;
    }

    function parseValue() {
        parent::parseValue();
        if (isset($this->value)) {
            $value = $this->value;
            $config = $this->field->getConfiguration();
            // Trim spaces based on text input type.
            // Preserve original input if not empty.
            if ($config['html'])
                $this->value = trim($value, " <>br/\t\n\r") ? $value : '';
            else
                $this->value = trim($value) ? $value : '';
        }
    }

}

class FileUploadWidget extends Widget {
    static $media = array(
        'css' => array(
            '/css/filedrop.css',
        ),
    );

    function render($options=array()) {
        $config = $this->field->getConfiguration();
        $name = $this->field->getFormName();
        $id = substr(md5(spl_object_hash($this)), 10);
        $mimetypes = array_filter($config['__mimetypes'],
            function($t) { return strpos($t, '/') !== false; }
        );
        $maxfilesize = ($config['size'] ?: 1048576) / 1048576;
        $files = array();
        $new = $this->field->getClean(false);

        foreach ($this->field->getAttachments() as $att) {
            unset($new[$att->file_id]);
            $files[] = array(
                'id' => $att->file->getId(),
                'name' => $att->getFilename(),
                'type' => $att->file->getType(),
                'size' => $att->file->getSize(),
                'download_url' => $att->file->getDownloadUrl(),
            );
        }

        // Add in newly added files not yet saved (if redisplaying after an
        // error)
        if ($new) {
            $F = AttachmentFile::objects()
                ->filter(array('id__in' => array_keys($new)))
                ->all();

            foreach ($F as $f) {
                $f->tmp_name = $new[$f->getId()];
                $files[] = array(
                    'id' => $f->getId(),
                    'name' => $f->getName(),
                    'type' => $f->getType(),
                    'size' => $f->getSize(),
                    'download_url' => $f->getDownloadUrl(),
                );
            }
        }

        //see if the attachment is saved in the session for this specific field
        if ($sessionAttachment = $_SESSION[':form-data'][$this->field->get('name')]) {
            $F = AttachmentFile::objects()
                ->filter(array('id__in' => array_keys($sessionAttachment)))
                ->all();

            foreach ($F as $f) {
                $f->tmp_name = $sessionAttachment[$f->getId()];
                $files[] = array(
                    'id' => $f->getId(),
                    'name' => $f->getName(),
                    'type' => $f->getType(),
                    'size' => $f->getSize(),
                    'download_url' => $f->getDownloadUrl(),
                );
            }
        }

         // Set default $field_id
        $field_id = $this->field->get('id');
        // Get Form Type
        $type = $this->field->getForm()->type;
        // Determine if for Ticket/Task/Custom
        if ($type && !is_numeric($field_id)) {
            if ($type == 'T')
                $field_id = 'ticket/attach';
            elseif ($type == 'A')
                $field_id = 'task/attach';
        }

        ?><div id="<?php echo $id;
            ?>" class="filedrop"><div class="files"></div>
            <div class="dropzone"><i class="icon-upload"></i>
            <?php echo sprintf(
                __('Drop files here or %s choose them %s'),
                '<a href="#" class="manual">', '</a>'); ?>
        <input type="file" multiple="multiple"
            id="file-<?php echo $id; ?>" style="display:none;"
            accept="<?php echo implode(',', $config['__mimetypes']); ?>"/>
        </div></div>
        <script type="text/javascript">
        $(function(){$('#<?php echo $id; ?> .dropzone').filedropbox({
          url: 'ajax.php/form/upload/<?php echo $field_id; ?>',
          link: $('#<?php echo $id; ?>').find('a.manual'),
          paramname: 'upload[]',
          fallback_id: 'file-<?php echo $id; ?>',
          allowedfileextensions: <?php echo JsonDataEncoder::encode(
            $config['__extensions'] ?: array()); ?>,
          allowedfiletypes: <?php echo JsonDataEncoder::encode(
            $mimetypes); ?>,
          maxfiles: <?php echo $config['max'] ?: 20; ?>,
          maxfilesize: <?php echo str_replace(',', '.', $maxfilesize); ?>,
          name: '<?php echo $name; ?>[]',
          files: <?php echo JsonDataEncoder::encode($files); ?>
        });});
        </script>
<?php
    }

    function getValue() {
        $ids = array();
        // Handle manual uploads (IE<10)
        if ($_SERVER['REQUEST_METHOD'] == 'POST' && isset($_FILES[$this->name])) {
            foreach (AttachmentFile::format($_FILES[$this->name]) as $file) {
                try {
                    $F = $this->field->uploadFile($file);
                    $ids[$F->getId()] = $F->getName();
                }
                catch (FileUploadError $ex) {}
            }
            return $ids;
        }

        // Files uploaded here MUST have been uploaded by this user and
        // identified in the session
        //
        // If no value was sent, assume an empty list
        if (!($files = parent::getValue()))
            return array();

        if ($_SERVER['REQUEST_METHOD'] == 'POST') {
            $_files = array();
            foreach ($files as $info) {
                if (@list($id, $name) = explode(',', $info, 2))
                    $_files[$id] = $name;
            }
            $files = $_files;
        }

        $allowed = array();
        // Files already attached to the field are allowed
        foreach ($this->field->getFiles() as $F) {
            // FIXME: This will need special porting in v1.10
            $allowed[$F->id] = $F->getName();
        }

        // New files uploaded in this session are allowed
        if (isset($_SESSION[':uploadedFiles']))
            $allowed += $_SESSION[':uploadedFiles'];

        // Canned attachments initiated by this session
        if (isset($_SESSION[':cannedFiles']))
           $allowed += $_SESSION[':cannedFiles'];

        // Parse the files and make sure it's allowed.
        foreach ($files as $id => $name) {
            if (!isset($allowed[$id]))
                continue;

            // Keep the values as the IDs
            $ids[$id] = $name ?: $allowed[$id];
        }

        return $ids;
    }
}

class FileUploadError extends Exception {}

class FreeTextField extends FormField {
    static $widget = 'FreeTextWidget';
    protected $attachments;

    function getConfigurationOptions() {
        return array(
            'content' => new TextareaField(array(
                'configuration' => array('html' => true, 'size'=>'large'),
                'label'=>__('Content'), 'required'=>true, 'default'=>'',
                'hint'=>__('Free text shown in the form, such as a disclaimer'),
            )),
            'attachments' => new FileUploadField(array(
                'id'=>'attach',
                'label' => __('Attachments'),
                'name'=>'files',
                'configuration' => array('extensions'=>'')
            )),
        );
    }

    function hasData() {
        return false;
    }

    function isBlockLevel() {
        return true;
    }

    function isEditableToStaff() {
        return $this->isVisibleToStaff();
    }

    function isEditableToUsers() {
        return $this->isVisibleToUsers();
    }

    /* utils */

    function to_config($config) {
        if ($config && isset($config['attachments']))
            $keepers = $config['attachments'];
        $this->getAttachments()->keepOnlyFileIds($keepers);

        return $config;
    }

    function db_cleanup($field=false) {

        if ($field && $this->getFiles())
            $this->getAttachments()->deleteAll();
    }

    function getAttachments() {
        if (!isset($this->attachments))
            $this->attachments = GenericAttachments::forIdAndType($this->get('id'), 'I');

        return $this->attachments ?: array();
    }

    function getFiles() {
        if (!isset($this->files)) {
            $files = array();
            if (($attachments=$this->getAttachments()))
                foreach ($attachments->all() as $a)
                    $files[] = $a->getFile();
            $this->files = $files;
        }
        return $this->files;
    }
}

class FreeTextWidget extends Widget {
    function render($options=array()) {
        $config = $this->field->getConfiguration();
        $class = $config['classes'] ?: 'thread-body bleed';
        ?><div class="<?php echo $class; ?>"><?php
        if ($label = $this->field->getLocal('label')) { ?>
            <h3><?php
            echo Format::htmlchars($label);
        ?></h3><?php
        }
        if ($hint = $this->field->getLocal('hint')) { ?>
        <em><?php
            echo Format::display($hint);
        ?></em><?php
        } ?>
        <div><?php
            echo Format::viewableImages($config['content']); ?></div>
        </div>
        <?php
        if (($attachments = $this->field->getAttachments()) && count($attachments)) { ?>
            <section class="freetext-files">
            <div class="title"><?php echo __('Related Resources'); ?></div>
            <?php foreach ($attachments->all() as $attach) {
                $filename = Format::htmlchars($attach->getFilename());
                ?>
                <div class="file">
                <a href="<?php echo $attach->file->getDownloadUrl(); ?>"
                    target="_blank" download="<?php echo $filename; ?>"
                    class="truncate no-pjax">
                    <i class="icon-file"></i>
                    <?php echo $filename; ?>
                </a>
                </div>
            <?php } ?>
        </section>
        <?php }
    }
}

class ColorPickerWidget extends Widget {
    static $media = array(
        'css' => array(
            'css/spectrum.css',
        ),
        'js' => array(
            'js/spectrum.js',
        ),
    );

    function render($options=array()) {
        ?><input type="color"
            id="<?php echo $this->id; ?>"
            name="<?php echo $this->name; ?>"
            value="<?php echo Format::htmlchars($this->value); ?>"/><?php
    }
}

class VisibilityConstraint {
    static $operators = array(
        'eq' => 1,
        'neq' => 1,
    );

    const HIDDEN =      0x0001;
    const VISIBLE =     0x0002;

    var $initial;
    var $constraint;

    function __construct($constraint, $initial=self::VISIBLE) {
        $this->constraint = $constraint;
        $this->initial = $initial;
    }

    function emitJavascript($field) {

        if (!$this->constraint->constraints)
            return;

        $func = 'recheck_'.$field->getWidget()->id;
        $form = $field->getForm();
?>
    <script type="text/javascript">
      !(function() {
        var <?php echo $func; ?> = function() {
          var target = $('#field<?php echo $field->getWidget()->id; ?>');
<?php   $fields = $this->getAllFields($this->constraint);
        foreach ($fields as $f) {
            if (!($field = $form->getField($f)))
                continue;
            echo sprintf('var %1$s = x = $("#%1$s");',
                $field->getWidget()->id);
        }
        $expression = $this->compileQ($this->constraint, $form);
?>
          if (<?php echo $expression; ?>) {
            target.slideDown('fast', function (){
                $(this).trigger('show');
                });
          } else {
            target.slideUp('fast', function (){
                $(this).trigger('hide');
                });
          }
        };

<?php   foreach ($fields as $f) {
            if (!($field=$form->getField($f)))
                continue;
            $w = $field->getWidget();
?>
        $('#<?php echo $w->id; ?>').on('change', <?php echo $func; ?>);
        $('#field<?php echo $w->id; ?>').on('show hide', <?php
                echo $func; ?>);
<?php   } ?>
      })();
    </script><?php
    }

    /**
     * Determines if the field was visible when the form was submitted
     */
    function isVisible($field) {

        // Assume initial visibility if constraint is not provided.
        if (!$this->constraint->constraints)
            return $this->initial == self::VISIBLE;


        return $this->compileQPhp($this->constraint, $field);
    }

    static function splitFieldAndOp($field) {
        if (false !== ($last = strrpos($field, '__'))) {
            $op = substr($field, $last + 2);
            if (isset(static::$operators[$op]))
                $field = substr($field, 0, strrpos($field, '__'));
        }
        return array($field, $op);
    }

    function compileQPhp(Q $Q, $field) {
        if (!($form = $field->getForm())) {
            return $this->initial == self::VISIBLE;
        }
        $expr = array();
        foreach ($Q->constraints as $c=>$value) {
            if ($value instanceof Q) {
                $expr[] = $this->compileQPhp($value, $field);
            }
            else {
                @list($f, $op) = self::splitFieldAndOp($c);
                $field = $form->getField($f);
                $wval = $field ? $field->getClean() : null;
                $values = explode('|', $value);
                switch ($op) {
                case 'neq':
                    $expr[] = ($wval && !in_array($wval, $values) && $field->isVisible());
                    break;
                case 'eq':
                case null:
                    $expr[] = (in_array($wval, $values) && $field->isVisible());
                }
            }
        }
        $glue = $Q->isOred()
            ? function($a, $b) { return $a || $b; }
            : function($a, $b) { return $a && $b; };
        $initial = !$Q->isOred();
        $expression = array_reduce($expr, $glue, $initial);
        if ($Q->isNegated)
            $expression = !$expression;
        return $expression;
    }

    function getAllFields(Q $Q, &$fields=array()) {
        foreach ($Q->constraints as $c=>$value) {
            if ($c instanceof Q) {
                $this->getAllFields($c, $fields);
            }
            else {
                @list($f) = self::splitFieldAndOp($c);
                $fields[$f] = true;
            }
        }
        return array_keys($fields);
    }

    function compileQ($Q, $form) {
        $expr = array();
        foreach ($Q->constraints as $c=>$value) {
            if ($value instanceof Q) {
                $expr[] = $this->compileQ($value, $form);
            }
            else {
                list($f, $op) = self::splitFieldAndOp($c);
                if (!($field=$form->getField($f))) continue;
                $widget = $field->getWidget();
                $id = $widget->id;
                switch ($op) {
                case 'neq':
                    $expr[] = sprintf('(%s.is(":visible") && !(%s))',
                            $id, $widget->getJsComparator($value, $id));
                    break;
                case 'eq':
                case null:
                    $expr[] = sprintf('(%s.is(":visible") && (%s))',
                            $id, $widget->getJsComparator($value, $id));
                }
            }
        }
        $glue = $Q->isOred() ? ' || ' : ' && ';
        $expression = implode($glue, $expr);
        if (count($expr) > 1)
            $expression = '('.$expression.')';
        if ($Q->isNegated)
            $expression = '!'.$expression;
        return $expression;
    }
}

class AssignmentForm extends Form {

    static $id = 'assign';
    var $_assignee = null;
    var $_assignees = null;


    function getFields() {

        if ($this->fields)
            return $this->fields;

        $fields = array(
            'assignee' => new AssigneeField(array(
                    'id'=>1, 'label' => __('Assignee'),
                    'flags' => hexdec(0X450F3), 'required' => true,
                    'validator-error' => __('Assignee selection required'),
                    'configuration' => array(
                        'criteria' => array(
                            'available' => true,
                            ),
                       ),
                    )
                ),
            'refer' => new BooleanField(array(
                    'id'=>2, 'label'=>'', 'required'=>false,
                    'default'=>false,
                    'configuration'=>array(
                        'desc' => __('Maintain referral access to current assignees'))
                    )
                ),
            'comments' => new TextareaField(array(
                    'id' => 3, 'label'=> '', 'required'=>false, 'default'=>'',
                    'configuration' => array(
                        'html' => true,
                        'size' => 'small',
                        'placeholder' => __('Optional reason for the assignment'),
                        ),
                    )
                ),
            );


        if (isset($this->_assignees))
            $fields['assignee']->setChoices($this->_assignees);

        $this->setFields($fields);

        return $this->fields;
    }

    function getField($name) {

        if (($fields = $this->getFields())
                && isset($fields[$name]))
            return $fields[$name];
    }

    function isValid($include=false) {

        if (!parent::isValid($include) || !($f=$this->getField('assignee')))
            return false;

        // Do additional assignment validation
        if (!($assignee = $this->getAssignee())) {
            $f->addError(__('Unknown assignee'));
        } elseif ($assignee instanceof Staff) {
            // Make sure the agent is available
            if (!$assignee->isAvailable())
                $f->addError(__('Agent is unavailable for assignment'));
        } elseif ($assignee instanceof Team) {
            // Make sure the team is active and has members
            if (!$assignee->isActive())
                $f->addError(__('Team is disabled'));
            elseif (!$assignee->getNumMembers())
                $f->addError(__('Team does not have members'));
        }

        return !$this->errors();
    }

    function render($options=array()) {

        switch(strtolower($options['template'])) {
        case 'simple':
            $inc = STAFFINC_DIR . 'templates/dynamic-form-simple.tmpl.php';
            break;
        default:
            throw new Exception(sprintf(__('%s: Unknown template style %s'),
                        'FormUtils', $options['template']));
        }

        $form = $this;
        include $inc;
    }

    function setAssignees($assignees) {
        $this->_assignees = $assignees;
        $this->_fields = array();
    }

    function getAssignees() {
        return $this->_assignees;
    }

    function getAssignee() {

        if (!isset($this->_assignee))
            $this->_assignee = $this->getField('assignee')->getClean();

        return $this->_assignee;
    }

    function getComments() {
        return $this->getField('comments')->getClean();
    }

    function refer() {
        return $this->getField('refer')->getClean();
    }
}

class ClaimForm extends AssignmentForm {

    var $_fields;

    function setFields($fields) {
        $this->_fields = $fields;
        parent::setFields($fields);
    }

    function getFields() {

        if ($this->_fields)
            return $this->_fields;

        $fields = parent::getFields();

        // Disable && hide assignee field selection
        if (isset($fields['assignee'])) {
            $visibility = new VisibilityConstraint(
                    new Q(array()), VisibilityConstraint::HIDDEN);

            $fields['assignee']->set('visibility', $visibility);
        }

        // Change coments placeholder to reflect claim
        if (isset($fields['comments'])) {
            $fields['comments']->configure('placeholder',
                    __('Optional reason for the claim'));
        }


        $this->setFields($fields);

        return $this->fields;
    }

}

class ReleaseForm extends Form {
    static $id = 'unassign';

    function getFields() {
        if ($this->fields)
            return $this->fields;

        $fields = array(
            'comments' => new TextareaField(array(
                    'id' => 1, 'label'=> '', 'required'=>false, 'default'=>'',
                    'configuration' => array(
                        'html' => true,
                        'size' => 'small',
                        'placeholder' => __('Optional reason for releasing assignment'),
                        ),
                    )
                ),
            );


        $this->setFields($fields);

        return $this->fields;
    }

    function getField($name) {
        if (($fields = $this->getFields())
                && isset($fields[$name]))
            return $fields[$name];
    }

    function isValid($include=false) {
        if (!parent::isValid($include))
            return false;

        return !$this->errors();
    }

    function getComments() {
        return $this->getField('comments')->getClean();
    }
}

class MarkAsForm extends Form {
    static $id = 'markAs';

    function getFields() {
        if ($this->fields)
            return $this->fields;

        $fields = array(
            'comments' => new TextareaField(array(
                    'id' => 1, 'label'=> '', 'required'=>false, 'default'=>'',
                    'configuration' => array(
                        'html' => true,
                        'size' => 'small',
                        'placeholder' => __('Optional reason for marking ticket as (un)answered'),
                        ),
                    )
                ),
            );


        $this->setFields($fields);

        return $this->fields;
    }

    function getField($name) {
        if (($fields = $this->getFields())
                && isset($fields[$name]))
            return $fields[$name];
    }

    function isValid($include=false) {
        if (!parent::isValid($include))
            return false;

        return !$this->errors();
    }

    function getComments() {
        return $this->getField('comments')->getClean();
    }
}

class ReferralForm extends Form {

    static $id = 'refer';
    var $_target = null;
    var $_choices = null;
    var $_prompt = '';

    function getFields() {

        if ($this->fields)
            return $this->fields;

        $fields = array(
            'target' => new ChoiceField(array(
                    'id'=>1,
                    'label' => __('Referee'),
                    'flags' => hexdec(0X450F3),
                    'required' => true,
                    'validator-error' => __('Selection required'),
                    'choices' => array(
                    'agent' => __('Agent'),
                    'team'  => __('Team'),
                                'dept'  => __('Department'),
                               ),
                            )
                ),
            'agent' => new AgentSelectionField(array(
                    'id'=>2,
                    'label' => '',
                    'flags' => hexdec(0X450F3),
                    'required' => true,
                    'validator-error' => __('Agent selection required'),
                    'configuration'=>array('prompt'=>__('Select Agent')),
                            'visibility' => new VisibilityConstraint(
                                    new Q(array('target__eq'=>'agent')),
                                    VisibilityConstraint::HIDDEN
                              ),
                            )
                ),
            'team' => new ChoiceField(array(
                    'id'=>3,
                    'label' => '',
                    'flags' => hexdec(0X450F3),
                    'required' => true,
                    'validator-error' => __('Team selection required'),
                    'configuration'=>array('prompt'=>__('Select Team')),
                            'visibility' => new VisibilityConstraint(
                                    new Q(array('target__eq'=>'team')),
                                    VisibilityConstraint::HIDDEN
                              ),
                            )
                ),
            'dept' => new DepartmentField(array(
                    'id'=>4,
                    'label' => '',
                    'flags' => hexdec(0X450F3),
                    'required' => true,
                    'validator-error' => __('Dept. selection required'),
                    'configuration'=>array('prompt'=>__('Select Department')),
                            'visibility' => new VisibilityConstraint(
                                    new Q(array('target__eq'=>'dept')),
                                    VisibilityConstraint::HIDDEN
                              ),
                            )
                ),
            'comments' => new TextareaField(array(
                    'id' => 5,
                    'label'=> '',
                    'required'=>false,
                    'default'=>'',
                    'configuration' => array(
                        'html' => true,
                        'size' => 'small',
                        'placeholder' => __('Optional reason for the referral'),
                        ),
                    )
                ),
            );

        $this->setFields($fields);

        return $this->fields;
    }

    function getField($name) {

        if (($fields = $this->getFields())
                && isset($fields[$name]))
            return $fields[$name];
    }



    function isValid($include=false) {

        if (!parent::isValid($include) || !($f=$this->getField('target')))
            return false;

        // Do additional assignment validation
        $referee = $this->getReferee();
        switch (true) {
        case $referee instanceof Staff:
            // Make sure the agent is available
            if (!$referee->isAvailable())
                $f->addError(__('Agent is unavailable for assignment'));
        break;
        case $referee instanceof Team:
            // Make sure the team is active and has members
            if (!$referee->isActive())
                $f->addError(__('Team is disabled'));
            elseif (!$referee->getNumMembers())
                $f->addError(__('Team does not have members'));
        break;
        case $referee instanceof Dept:
        break;
        default:
            $f->addError(__('Unknown selection'));
        }

        return !$this->errors();
    }

    function render($options=array()) {

        switch(strtolower($options['template'])) {
        case 'simple':
            $inc = STAFFINC_DIR . 'templates/dynamic-form-simple.tmpl.php';
            break;
        default:
            throw new Exception(sprintf(__('%s: Unknown template style %s'),
                        'FormUtils', $options['template']));
        }

        $form = $this;
        include $inc;
    }

    function setChoices($field, $choices, $prompt='') {

        if (!($f= $this->getField($field)))
           return;

        $f->set('choices', $choices);

        return $f;
    }

    function getReferee() {

        $target = $this->getField('target')->getClean();
        if (!$target || !($f=$this->getField($target)))
            return null;

        $id = $f->getClean();
        switch($target) {
        case 'agent':
            return Staff::lookup($id);
        case 'team':
            return Team::lookup($id);
        case 'dept':
            return Dept::lookup($id);
        }
    }

    function getComments() {
        return $this->getField('comments')->getClean();
    }
}


class TransferForm extends Form {

    static $id = 'transfer';
    var $_dept = null;

    function __construct($source=null, $options=array()) {
        parent::__construct($source, $options);
    }

    function getFields() {

        if ($this->fields)
            return $this->fields;

        $fields = array(
            'dept' => new DepartmentField(array(
                    'id'=>1,
                    'label' => __('Department'),
                    'flags' => hexdec(0X450F3),
                    'required' => true,
                    'validator-error' => __('Department selection is required'),
                    )
                ),
            'refer' => new BooleanField(array(
                'id'=>2, 'label'=>'', 'required'=>false, 'default'=>false,
                'configuration'=>array(
                    'desc' => __('Maintain referral access to current department'))
            )),
            'comments' => new TextareaField(array(
                    'id' => 3,
                    'label'=> '',
                    'required'=>false,
                    'default'=>'',
                    'configuration' => array(
                        'html' => true,
                        'size' => 'small',
                        'placeholder' => __('Optional reason for the transfer'),
                        ),
                    )
                ),
            );

        $this->setFields($fields);

        return $this->fields;
    }

    function isValid($include=false) {

        if (!parent::isValid($include))
            return false;

        // Do additional validations
        if (!($dept = $this->getDept()))
            $this->getField('dept')->addError(
                    __('Unknown department'));

        return !$this->errors();
    }

    function render($options=array()) {

        switch(strtolower($options['template'])) {
        case 'simple':
            $inc = STAFFINC_DIR . 'templates/dynamic-form-simple.tmpl.php';
            break;
        default:
            throw new Exception(sprintf(__('%s: Unknown template style %s'),
                        get_class(), $options['template']));
        }

        $form = $this;
        include $inc;

    }

    function refer() {
        return $this->getField('refer')->getClean();
    }

    function getDept() {

        if (!isset($this->_dept)) {
            if (($id = $this->getField('dept')->getClean()))
                $this->_dept = Dept::lookup($id);
        }

        return $this->_dept;
    }

    function hideDisabled() {
        global $thisstaff;

        if ($f = $this->getField('dept')) {
            $f->configure('staff', $thisstaff);
            $f->configure('hideDisabled', true);
        }
    }
}

/**
 * FieldUnchanged
 *
 * Thrown in the to_database() method to indicate the value should not be
 * saved in the database (it wasn't changed in the request)
 */
class FieldUnchanged extends Exception {}
?>