<?php
/**
 * There is one global object used by SyncML:
 * 1) $GLOBALS['backend']
 *    Backend to handle the communication with the datastore.
 *
 * @todo: Main Todos:
 * - ensure that no server data is written for Horde_SyncMl::ALERT_ONE_WAY_FROM_SERVER
 *   even when client sends data (security!)
 * - consinstant naming of clientSyncDB (currently called targetLocURI, db
 *   or synctype)
 * - tackle the AddReplace issue: when a Replace is issued (i.e. during
 *   SlowSync) the server should first check if the entry already exists.
 *   Like: does a calendar entry with the same timeframe, same subject and
 *   location exist. If so, the replace should replace this value rather than
 *   create a new one as a duplicate.
 *
 * Copyright 2003-2016 Horde LLC (http://www.horde.org/)
 *
 * See the enclosed file COPYING for license information (LGPL). If you
 * did not receive this file, see http://www.horde.org/licenses/lgpl21.
 *
 * @author  Karsten Fourmont <karsten@horde.org>
 * @author  Anthony Mills <amills@pyramid6.com>
 * @package SyncMl
 */
class Horde_SyncMl_ContentHandler
{
    /**
     * Stack for holding the xml elements during creation of the object from
     * the xml event flow.
     *
     * @var array
     */
    protected $_Stack = array();

    /**
     * @var string
     */
    protected $_chars;

    /**
     * Instance of Horde_SyncMl_Command. Events are passed through to this
     * ContentHandler.
     *
     * @var Horde_SyncMl_Command
     */
    protected $_currentCommand;

    /**
     * Whether we received a final element in this message.
     */
    protected $_gotFinal = false;

    protected $_xmlWriter;

    protected $_wbxmlparser = null;

    /**
     * The response URI as sent by the server.
     *
     * This is the endpoint URL of the RPC server.
     *
     * @var string
     */
    protected $_respURI;

    public $debug = false;

    public function __construct()
    {
        /* Set to true to indicate that we expect another message from the
         * client. If this is still false at the end of processing, the sync
         * session is finished and we can close the session. */
        $GLOBALS['message_expectresponse'] = false;
    }

    /**
     * Here's were all the processing takes place: gets the SyncML request
     * data and returns a SyncML response. The only thing that needs to be in
     * place before invoking this public function is a working backend.
     *
     * @param string $request      The raw request string.
     * @param string $contentType  The MIME content type of the request. Should
     *                             be either application/vnd.syncml or
     *                             application/vnd.syncml+wbxml.
     * @param string $respURI      The url of the server endpoint. Will be
     *                             returned in the RespURI element.
     */
    public function process($request, $contentType, $respURI = null)
    {
        $isWBXML = $contentType =='application/vnd.syncml+wbxml';
        $this->_respURI = $respURI;

        /* Catch any errors/warnings/notices that may get thrown while
         * processing. Don't want to let anything go to the client that's not
         * part of the valid response. */
        ob_start();

        $GLOBALS['backend']->logFile(Horde_SyncMl_Backend::LOGFILE_CLIENTMESSAGE, $request, $isWBXML);

        if (!$isWBXML) {
            /* XML code. */

            /* try to extract charset from XML text */
            if (preg_match('/^\s*<\?xml[^>]*encoding\s*=\s*"([^"]*)"/i',
                           $request, $m)) {
                $charset = $m[1];
            } else {
                $charset = 'UTF-8';
            }

            $GLOBALS['backend']->setCharset($charset);

            /* Init output handler. */
            $this->_xmlWriter = &Horde_SyncMl_XmlOutput::singleton();
            /* Horde_Xml_Wbxml_ContentHandler Is a class that produces plain XML
             * output. */
            $this->_xmlWriter->init(new Horde_Xml_Wbxml_ContentHandler());

            /* Create the XML parser and set method references. */
            $parser = xml_parser_create_ns($charset);
            xml_set_object($parser, $this);
            xml_parser_set_option($parser, XML_OPTION_CASE_FOLDING, false);
            xml_set_element_handler($parser, '_startElement', '_endElement');
            xml_set_character_data_handler($parser, '_characters');
            xml_set_processing_instruction_handler($parser, '');
            xml_set_external_entity_ref_handler($parser, '');

            /* Here we go: fire off events: */
            if (!xml_parse($parser, $request)) {
                $s = sprintf('XML error: %s at line %d',
                             xml_error_string(xml_get_error_code($parser)),
                             xml_get_current_line_number($parser));
                $GLOBALS['backend']->logMessage($s, 'ERR');
                xml_parser_free($parser);
                return new PEAR_Error($s);
            }

            xml_parser_free($parser);

        } else {
            /* The decoder works like the parser in the XML code above: It
             * parses the input and calls the callback functions of $this. */
            $this->_wbxmlparser = new Horde_Xml_Wbxml_Decoder();
            $this->_wbxmlparser->setContentHandler($this);

            /* Init output handler. */
            $this->_xmlWriter = &Horde_SyncMl_XmlOutput::singleton();
            $this->_xmlWriter->init(new Horde_Xml_Wbxml_Encoder());

            /* Here we go: fire off events: */
            /* @todo catch exceptions */
            $this->_wbxmlparser->decode($request);
        }

        $id = @session_id();
        $sessionclose = empty($id);

        $output = $this->getOutput();
        if (!$isWBXML) {
            $output = '<?xml version="1.0" encoding="' . $charset . '"?>' . $output;
        }
        $GLOBALS['backend']->logFile(Horde_SyncMl_Backend::LOGFILE_SERVERMESSAGE, $output, $isWBXML, $sessionclose);

        /* Clear the output buffer that we started above, and log anything
         * that came up for later debugging. */
        $errorLogging = ob_get_clean();

        if (!empty($errorLogging)) {
            $GLOBALS['backend']->logMessage('Caught output: ' . $errorLogging, 'WARN');
        }

        return $output;
    }

    /*
     * CONTENTHANDLER CALLBACK FUNCTIONS
     * The following functions are callback functions that are called by the
     * XML parser. The XML and WBXML parsers use slightly different functions,
     * so the methods are duplicated.
     */

    /**
     * Returns the XML|WBXML output once processing is finished.
     *
     * @return string  The XML or WBXML output data.
     */
    public function getOutput()
    {
        return $this->_xmlWriter->getOutput();
    }

    /**
     * Callback public function called by XML parser.
     */
    protected function _startElement($parser, $tag, $attributes)
    {
        list($uri, $name) = $this->_splitURI($tag);
        $this->startElement($uri, $name, $attributes);
    }

    /**
     * Callback public function called by XML parser.
     */
    protected function _characters($parser, $chars)
    {
        $this->characters($chars);
    }

    /**
     * Callback public function called by XML parser.
     */
    protected function _endElement($parser, $tag)
    {
        list($uri, $name) = $this->_splitURI($tag);
        $this->endElement($uri, $name);
    }

    /**
     * Splits an URI as provided by the XML parser.
     */
    protected function _splitURI($tag)
    {
        $parts = explode(':', $tag);
        $name = array_pop($parts);
        $uri = implode(':', $parts);
        return array($uri, $name);
    }

    /**
     * Callback public function called by WBXML parser.
     */
    public function startElement($uri, $element, $attrs)
    {
        $this->_Stack[] = $element;

        // <SyncML>: don't do anyhting yet
        if (count($this->_Stack) == 1) {
            return;
        }

        // header or body?
        if ($this->_Stack[1] == 'SyncHdr') {
            if (count($this->_Stack) == 2) {
                $this->_currentCommand = new Horde_SyncMl_Command_SyncHdr($this->_xmlWriter);
            }
            $this->_currentCommand->startElement($uri, $element, $attrs);
        } else {
            switch (count($this->_Stack)) {
            case 2:
                 // <SyncBody>: do nothing yet
                 break;
            case 3:
                // new Command:
                // <SyncML><SyncBody><[Command]>
                $this->_currentCommand = &Horde_SyncMl_Command::factory($element,$this->_xmlWriter);
                $this->_currentCommand->startElement($uri, $element, $attrs);
                break;
            default:
                // pass on to current command handler:
                // <SyncML><SyncBody><Command><...>
                $this->_currentCommand->startElement($uri, $element, $attrs);
                break;
            }
        }
    }

    /**
     * Callback public function called by WBXML parser.
     */
    public function endElement($uri, $element)
    {
        // </SyncML>: everything done already by end of SyncBody
        if (count($this->_Stack) == 1) {
            return;
        }
        // header or body?
        if ($this->_Stack[1] == 'SyncHdr') {
            switch (count($this->_Stack)) {
            case 2:
                // </SyncHdr> end of header
                $this->handleHeader($this->_currentCommand);
                if ($this->debug) {
                    var_dump($this->_currentCommand);
                }
                unset($this->_currentCommand);
                break;
            default:
                // pass on to command handler:
                $this->_currentCommand->endElement($uri, $element);
                break;
            }
        } else {
            switch (count($this->_Stack)) {
            case 2:
                // </SyncBody> end of SyncBody. Finish everything:
                $this->handleEnd();
                break;
            case 3:
                // </[Command]></SyncBody></SyncML>
                // Command finished. Complete parsing and pass on to Handler
                $this->_currentCommand->endElement($uri, $element);
                $this->handleCommand($this->_currentCommand);
                if ($this->debug) {
                    var_dump($this->_currentCommand);
                }
                unset($this->_currentCommand);
                break;
            default:
                // </...></[Command]></SyncBody></SyncML>
                // pass on to command handler:
                $this->_currentCommand->endElement($uri, $element);
                break;
            }
        }

        if (isset($this->_chars)) {
            unset($this->_chars);
        }

        array_pop($this->_Stack);
    }

    /**
     * Callback public function called by WBXML parser.
     */
    public function characters($str)
    {
        if (isset($this->_currentCommand)) {
            $this->_currentCommand->characters($str);
        } else {
            if (isset($this->_chars)) {
                $this->_chars = $this->_chars . $str;
            } else {
                $this->_chars = $str;
            }
        }
    }

    /*
     * PROCESSING FUNCTIONS
     *
     * The following functions are called by the callback functions
     * and do the actual processing.
     */

    /**
     * Handles the header logic.
     *
     * Invoked after header is parsed.
     */
    public function handleHeader(&$hdr)
    {
        if (is_object($this->_wbxmlparser)) {
            /* The WBXML parser only knows about the charset once parsing is
             * started. So setup charset now. */
            $this->_xmlWriter->output->setVersion($this->_wbxmlparser->getVersion());
            $this->_xmlWriter->output->setCharset($this->_wbxmlparser->getCharsetStr());
            $GLOBALS['backend']->setCharset($this->_wbxmlparser->getCharsetStr());
        }

        /* Start the session. */
        $hdr->setupState();
        $state = $GLOBALS['backend']->state;
        $state->wbxml = $this->_xmlWriter->isWBXML();

        /* Check auth. */
        if (!$state->authenticated) {
            $auth = $GLOBALS['backend']->checkAuthentication(
                $hdr->user, $hdr->credData, $hdr->credFormat, $hdr->credType);
            if ($auth !== false) {
                $state->authenticated = true;
                $statuscode = Horde_SyncMl::RESPONSE_AUTHENTICATION_ACCEPTED;
                $state->user = $auth;
                $GLOBALS['backend']->setUser($auth);
            } else {
                if (!$hdr->credData) {
                    $statuscode = Horde_SyncMl::RESPONSE_CREDENTIALS_MISSING;
                } else {
                    $statuscode = Horde_SyncMl::RESPONSE_INVALID_CREDENTIALS;
                }
                $GLOBALS['backend']->logMessage('Invalid authentication', 'DEBUG');
            }
        } else {
            $statuscode = Horde_SyncMl::RESPONSE_OK;
            $GLOBALS['backend']->setUser($state->user);
        }

        /* Create <SyncML>. */
        $this->_xmlWriter->outputInit();

        /* Got the state; now write our SyncHdr header. */
        $this->_xmlWriter->outputHeader($this->_respURI);

        /* Creates <SyncBody>. */
        $this->_xmlWriter->outputBodyStart();

        /* Output status for SyncHdr. */
        $this->_xmlWriter->outputStatus('0', 'SyncHdr', $statuscode,
                                        $state->targetURI,
                                        $state->sourceURI);

        /* Debug logging string. */
        $str = 'Authenticated: ' . ($state->authenticated ? 'yes' : 'no')
            . '; version: ' . $state->getVerDTD()
            . '; message ID: ' . $state->messageID
            . '; source URI: ' . $state->sourceURI
            . '; target URI: ' . $state->targetURI
            . '; user: ' . $state->user
            . '; charset: ' . $GLOBALS['backend']->getCharset()
            . '; wbxml: ' . ($state->wbxml ? 'yes' : 'no');

        $GLOBALS['backend']->logMessage($str, 'DEBUG');
    }

    /**
     * Processes one command after it has been completely parsed.
     *
     * Invoked after a command is parsed.
     */
    public function handleCommand(&$cmd)
    {
        $name = $cmd->getCommandName();
        if ($name != 'Status' && $name != 'Map' && $name != 'Final' &&
            $name != 'Sync' && $name != 'Results') {
            /* We've got to do something! This can't be the last packet. */
            $GLOBALS['message_expectresponse'] = true;
        }
        if ($name == 'Final') {
            $this->_gotFinal = true;
        }
        /* Actual processing takes place here. */
        $cmd->handleCommand($this->debug);
    }

    /**
     * Finishes the response.
     *
     * Invoked after complete message is parsed.
     */
    public function handleEnd()
    {
        global $messageFull;

        $state = $GLOBALS['backend']->state;

        /* If there's pending sync data and space left in the message, send
         * data now. */
        if ($messageFull || $state->hasPendingSyncs()) {
            /* still something to do: don't close session. */
            $GLOBALS['message_expectresponse'] = true;
        }

        if (!$messageFull &&
            count($p = $state->getPendingSyncs()) > 0) {
            foreach ($p as $pendingSync) {
                if (!$messageFull) {
                   $GLOBALS['backend']->logMessage(
                       'Continuing sync for syncType ' . $pendingSync, 'DEBUG');
                    $sync = &$state->getSync($pendingSync);
                    $sync->createSyncOutput($this->_xmlWriter);
                }
            }
        }

        if (isset($state->curSyncItem)) {
            $this->_xmlWriter->outputAlert(
                Horde_SyncMl::ALERT_NO_END_OF_DATA,
                $state->curSyncItem->sync->getClientLocURI(),
                $state->curSyncItem->sync->getServerLocURI(),
                $state->curSyncItem->sync->getServerAnchorLast(),
                $state->curSyncItem->sync->getServerAnchorNext());
        }

        /* Don't send the final tag if we haven't sent all sync data yet. */
        if ($this->_gotFinal) {
            if (!$messageFull &&
                !$state->hasPendingSyncs()) {
                /* Create <Final></Final>. */
                $this->_xmlWriter->outputFinal();
                $GLOBALS['backend']->logMessage('Sending <Final> to client', 'DEBUG');
                $state->delayedFinal = false;
            } else {
                $GLOBALS['message_expectresponse'] = true;
                /* Remember to send a Final. */
                $state->delayedFinal = true;
            }
        } elseif ($state->delayedFinal) {
            if (!$messageFull &&
                !$state->hasPendingSyncs()) {
                /* Create <Final></Final>. */
                $this->_xmlWriter->outputFinal();
                $GLOBALS['backend']->logMessage(
                    'Sending delayed <Final> to client', 'DEBUG');
                $state->delayedFinal = false;
            } else {
                $GLOBALS['message_expectresponse'] = true;
            }
        }

        /* Create </SyncML>. Message is finished now! */
        $this->_xmlWriter->outputEnd();

        if ($this->_gotFinal &&
            !$GLOBALS['message_expectresponse'] &&
            $state->isAllSyncsComplete()) {
            /* This packet did not contain any real actions, just status and
             * map. This means we're done. The session can be closed and the
             * anchors saved for the next sync. */
            foreach ($state->getSyncs() as $sync) {
                $sync->closeSync();
            }
            $GLOBALS['backend']->logMessage('Session completed and closed', 'DEBUG');

            /* Session can be closed here. */
            $GLOBALS['backend']->sessionClose();
        } else {
            $GLOBALS['backend']->logMessage('Return message completed', 'DEBUG');
        }
    }
}
