<?PHP
#
#   FILE:  Email.php
#
#   Part of the ScoutLib application support library
#   Copyright 2012-2015 Edward Almasy and Internet Scout Research Group
#   http://scout.wisc.edu
#

/**
* Electronic mail message.
* \nosubgrouping
*/
class Email
{

    # ---- PUBLIC INTERFACE --------------------------------------------------

    /** @name Sending */ /*@(*/

    /**
    * Mail the message.  If no recipients have been specified or all
    * recipients were disallowed via whitelisting, successful execution is
    * still reported by returning TRUE.
    * @return TRUE if message was successfully accepted for delivery,
    *       otherwise FALSE.
    */
    public function Send()
    {
        # if whitelist set
        if (count(self::$RecipientWhitelist))
        {
            # save recipient list and then pare it down based on whitelist
            $SavedTo = $this->To;
            $NewTo = array();
            foreach ($this->To as $To)
            {
                foreach (self::$RecipientWhitelist as $White)
                {
                    $White = trim($White);
                    if ($White[0] != substr($White, 0, -1))
                    {
                        $White = "/".$White."/";
                    }
                    if (preg_match($White, $To))
                    {
                        $NewTo[] = $To;
                        continue 2;
                    }
                }
            }
            $this->To = $NewTo;
        }

        # if there are recipients
        $Result = TRUE;
        if (count($this->To))
        {
            switch (self::$DeliveryMethod)
            {
                case self::METHOD_PHPMAIL:
                    # use PHPMailer to send multipart alternative messages because
                    # they can be tricky to construct properly
                    if ($this->HasAlternateBody())
                    {
                        $Result = $this->SendViaPhpMailerLib();
                    }

                    # otherwise, just use the built-in mail() function
                    else
                    {
                        $Result = $this->SendViaPhpMailFunc();
                    }
                    break;

                case self::METHOD_SMTP:
                    $Result = $this->SendViaSmtp();
                    break;
            }
        }

        # if recipient list saved
        if (isset($SavedTo))
        {
            # restore recipient list
            $this->To = $SavedTo;
        }

        # report to caller whether message was sent
        return $Result;
    }

    /**
    * Set whitelist of acceptable recipient addresses.  If no whitelist
    * patterns are set, all addresses are acceptable.  (Pass in an empty
    * array to clear whitelist.)
    * @param array $NewValue Array of regular expression patterns to match
    *       acceptable email addresses.  (OPTIONAL)
    * @return array Array of current whitelist entries.
    */
    public static function ToWhitelist($NewValue = NULL)
    {
        if ($NewValue !== NULL)
        {
            self::$RecipientWhitelist = $NewValue;
        }
        return self::$RecipientWhitelist;
    }


    /** @name Message Attributes */ /*@(*/

    /**
    * Get/set message body.
    * @param string $NewValue New message body.  (OPTIONAL)
    * @return Current message body.
    */
    public function Body($NewValue = NULL)
    {
        if ($NewValue !== NULL) {  $this->Body = $NewValue;  }
        return $this->Body;
    }

    /**
    * Get/set the plain-text alternative to the body.
    * @param string $NewValue New plain-text alternative.  (OPTIONAL)
    * @return Returns the current plain-text alternative, if any.
    */
    public function AlternateBody($NewValue = NULL)
    {
        # set the plain-text alternative if a parameter is given
        if (func_num_args() > 0)
        {
            $this->AlternateBody = $NewValue;
        }

        return $this->AlternateBody;
    }

    /**
    * Get/set message subject.
    * @param string $NewValue New message subject.  (OPTIONAL)
    * @return Current message subject.
    */
    public function Subject($NewValue = NULL)
    {
        if ($NewValue !== NULL) {  $this->Subject = $NewValue;  }
        return $this->Subject;
    }

    /**
    * Get/set message sender.
    * @param string $NewAddress New message sender address.  (OPTIONAL, but
    *       required if NewName is specified.)
    * @param string $NewName New message sender name.  (OPTIONAL)
    * @return Current message sender in RFC-2822 format ("user@example.com"
    *       or "User <user@example.com>" if name available).
    */
    public function From($NewAddress = NULL, $NewName = NULL)
    {
        if ($NewAddress !== NULL)
        {
            $NewAddress = trim($NewAddress);
            if ($NewName !== NULL)
            {
                $NewName = trim($NewName);
                $this->From = $NewName." <".$NewAddress.">";
            }
            else
            {
                $this->From = $NewAddress;
            }
        }
        return $this->From;
    }

    /**
    * Get/set default "From" address.  This address is used when no "From"
    * address is specified for a message.
    * @param string $NewValue New default address.  (OPTIONAL)
    * @return string Current default address.
    */
    public static function DefaultFrom($NewValue = NULL)
    {
        if ($NewValue !== NULL) {  self::$DefaultFrom = $NewValue;  }
        return self::$DefaultFrom;
    }

    /**
    * Get/set message "Reply-To" address.
    * @param string $NewAddress New message "Reply-To" address.  (OPTIONAL, but
    *       required if NewName is specified.)
    * @param string $NewName New message "Reply-To" name.  (OPTIONAL)
    * @return Current message "Reply-To" address in RFC-2822 format
    *       ("user@example.com" or "User <user@example.com>" if name available).
    */
    public function ReplyTo($NewAddress = NULL, $NewName = NULL)
    {
        if ($NewAddress !== NULL)
        {
            $NewAddress = trim($NewAddress);
            if ($NewName !== NULL)
            {
                $NewName = trim($NewName);
                $this->ReplyTo = $NewName." <".$NewAddress.">";
            }
            else
            {
                $this->ReplyTo = $NewAddress;
            }
        }
        return $this->ReplyTo;
    }

    /**
    * Get/set message recipient(s).
    * @param array|string $NewValue New message recipient or array of recipients,
    *       in RFC-2822 format ("user@example.com" or "User <user@example.com>"
    *       if name included).  (OPTIONAL)
    * @return Array of current message recipient(s) in RFC-2822 format.
    */
    public function To($NewValue = NULL)
    {
        if ($NewValue !== NULL)
        {
            if (!is_array($NewValue))
            {
                $this->To = array($NewValue);
            }
            else
            {
                $this->To = $NewValue;
            }
        }
        return $this->To;
    }

    /**
    * Get/set message CC list.
    * @param array|string $NewValue New message CC recipient or array of CC
    *       recipients, in RFC-2822 format ("user@example.com" or "User
    *       <user@example.com>" if name included).  (OPTIONAL)
    * @return Array of current message CC recipient(s) in RFC-2822 format.
    */
    public function CC($NewValue = NULL)
    {
        if ($NewValue !== NULL)
        {
            if (!is_array($NewValue))
            {
                $this->CC = array($NewValue);
            }
            else
            {
                $this->CC = $NewValue;
            }
        }
        return $this->CC;
    }

    /**
    * Get/set message BCC list.
    * @param array|string $NewValue New message BCC recipient or array of BCC
    *       recipients, in RFC-2822 format ("user@example.com" or "User
    *       <user@example.com>" if name included).  (OPTIONAL)
    * @return Array of current message BCC recipient(s) in RFC-2822 format.
    */
    public function BCC($NewValue = NULL)
    {
        if ($NewValue !== NULL)
        {
            if (!is_array($NewValue))
            {
                $this->BCC = array($NewValue);
            }
            else
            {
                $this->BCC = $NewValue;
            }
        }
        return $this->BCC;
    }

    /**
    * Specify additional message headers to be included.
    * @param array $NewHeaders Array of header lines.
    */
    public function AddHeaders($NewHeaders)
    {
        # add new headers to list
        $this->Headers = array_merge($this->Headers, $NewHeaders);
    }

    /**
    * Specify a character encoding for the message. This is used to set the
    * PHPMailer::CharSet property.
    * @param string $NewValue New character encoding (OPTIONAL)
    * @return Returns the current character encoding.
    */
    public function CharSet($NewValue = NULL)
    {
        # set the plain-text alternative if a parameter is given
        if (func_num_args() > 0)
        {
            $this->CharSet = $NewValue;
        }

        return $this->CharSet;
    }

    /**
    * Specify the character sequence that should be used to end lines.
    * @param string $NewValue Character sequence used to end lines.
    * @return Returns the current character sequence used to end lines.
    */
    public static function LineEnding($NewValue = NULL)
    {
        if (!is_null($NewValue))
        {
            self::$LineEnding = $NewValue;
        }

        return self::$LineEnding;
    }

    /**
    * Wrap HTML in an e-mail as necessary to get its lines less than some max
    * length. This does not guarantee that every line will be less than the max
    * length because it guarantees instead that the sematics of the HTML remain
    * unchanged.
    * @param string $Html HTML to wrap.
    * @param int $MaxLineLength Maximum length of each line. This parameter is
    *      optional.
    * @param string $LineEnding Line ending character sequence. This parameter
    *      is optional.
    * @return Returns HTML that is wrapped as necessary.
    */
    public static function WrapHtmlAsNecessary(
            $Html, $MaxLineLength=998, $LineEnding="\r\n")
    {
        # the regular expression used to find long lines
        $LongLineRegExp = '/[^\r\n]{'.($MaxLineLength+1).',}/';

        # find all lines that are too long
        preg_match_all($LongLineRegExp, $Html, $Matches,
                PREG_PATTERN_ORDER|PREG_OFFSET_CAPTURE);

        # no changes are necessary
        if (!count($Matches))
        {
            return $Html;
        }

        # go backwards so that the HTML can be edited in place without messing
        # with the offsets
        for ($i = count($Matches[0]) - 1; $i >= 0; $i--)
        {
            # extract the line text and its offset within the string
            list($Line, $Offset) = $Matches[0][$i];

            # first try to get the line under the limit without being too
            # aggressive
            $BetterLine = self::ConvertHtmlWhiteSpace($Line, FALSE, $LineEnding);
            $WasAggressive = "No";

            # if the line is still too long, be more aggressive with replacing
            # horizontal whitespace
            if (preg_match($LongLineRegExp, $BetterLine))
            {
                $BetterLine = self::ConvertHtmlWhiteSpace($Line, TRUE, $LineEnding);
                $WasAggressive = "Yes";
            }

            # tack on an HTML comment stating that the line was wrapped and give
            # some additional info
            $BetterLine = $LineEnding."<!-- Line was wrapped. Aggressive: "
                .$WasAggressive.", Max: ".$MaxLineLength.", Actual: "
                .strlen($Line)." -->".$LineEnding.$BetterLine;

            # replace the line within the HTML
            $Html = substr_replace($Html, $BetterLine, $Offset, strlen($Line));
        }

        return $Html;
    }

    /**
    * Test the line endings in a value to see if they all match the given line
    * ending. This only works with \\r (CR), \\n (LF), and \\r\\n (CRLF).
    * @param string $Value String to check.
    * @param string $LineEnding Line ending character sequence.
    * @return Returns TRUE if all the line endings match and FALSE otherwise.
    */
    public static function TestLineEndings($Value, $LineEnding)
    {
        # the number of \r in the string
        $NumCR = substr_count($Value, "\r");

        # LF
        if ($LineEnding == "\n")
        {
            return $NumCR === 0;
        }

        # the number of \n in the string
        $NumLF = substr_count($Value, "\n");

        # CR
        if ($LineEnding == "\r")
        {
            return $NumLF === 0;
        }

        # the number of \r\n in the string
        $NumCRLF = substr_count($Value, "\r\n");

        # CRLF. also check CRLF to make sure CR and LF appear together and in
        # the correct order
        return $NumCR === $NumLF && $NumLF === $NumCRLF;
    }

    /**
    * Try as best as possible to convert HTML to plain text.
    * @param string $Html The HTML to convert.
    * @return Returns the HTML as plain text.
    */
    public static function ConvertHtmlToPlainText($Html)
    {
        # remove newlines
        $Text = str_replace(array("\r", "\n"), "", $Html);

        # convert HTML breaks to newlines
        $Text = preg_replace('/<br\s*\/?>/', "\n", $Text);

        # strip remaining tags
        $Text = strip_tags($Text);

        # convert HTML entities to their plain-text equivalents
        $Text = html_entity_decode($Text);

        # single quotes aren't always handled
        $Text = str_replace('&#39;', "'", $Text);

        # remove HTML entities that have no equivalents
        $Text = preg_replace('/&(#[0-9]{1,6}|[a-zA-Z0-9]{1,6});/', "", $Text);

        # return the plain text version
        return $Text;
    }

    /** @name Mail Delivery Method */ /*@(*/

    /**
    * Get/set mail delivery method.  If specified, the method must be one of
    * the predefined "METHOD_" constants.
    * @param int $NewValue New delivery method.  (OPTIONAL)
    * @return Current delivery method.
    */
    public static function DeliveryMethod($NewValue = NULL)
    {
        if ($NewValue !== NULL)
        {
            self::$DeliveryMethod = $NewValue;
        }
        return self::$DeliveryMethod;
    }
    /** Deliver using PHP's internal mail() mechanism. */
    const METHOD_PHPMAIL = 1;
    /** Deliver using SMTP.  (Requires specifying SMTP settings.) */
    const METHOD_SMTP = 2;

    /**
    * Get/set server for mail delivery.
    * @param string $NewValue New server.  (OPTIONAL)
    * @return Current server.
    */
    public static function Server($NewValue = NULL)
    {
        if ($NewValue !== NULL) {  self::$Server = $NewValue;  }
        return self::$Server;
    }

    /**
    * Get/set port number for mail delivery.
    * @param int $NewValue New port number.  (OPTIONAL)
    * @return Current port number.
    */
    public static function Port($NewValue = NULL)
    {
        if ($NewValue !== NULL) {  self::$Port = $NewValue;  }
        return self::$Port;
    }

    /**
    * Get/set user name for mail delivery.
    * @param string $NewValue New user name.  (OPTIONAL)
    * @return Current user name.
    */
    public static function UserName($NewValue = NULL)
    {
        if ($NewValue !== NULL) {  self::$UserName = $NewValue;  }
        return self::$UserName;
    }

    /**
    * Get/set password for mail delivery.
    * @param string $NewValue New password.  (OPTIONAL)
    * @return Current password.
    */
    public static function Password($NewValue = NULL)
    {
        if ($NewValue !== NULL) {  self::$Password = $NewValue;  }
        return self::$Password;
    }

    /**
    * Get/set whether to use authentication for mail delivery.
    * @param bool $NewValue New authentication setting.  (OPTIONAL)
    * @return Current authentication setting.
    */
    public static function UseAuthentication($NewValue = NULL)
    {
        if ($NewValue !== NULL) {  self::$UseAuthentication = $NewValue;  }
        return self::$UseAuthentication;
    }

    /**
    * Get/set serialized (opaque text) version of delivery settings.  This
    * method is intended to be used to store and retrieve all email delivery
    * settings for the class, in a form suitable to be saved to a database.
    * @param array $NewSettings New delivery settings values.
    * @return Current delivery settings values.
    */
    public static function DeliverySettings($NewSettings = NULL)
    {
        if ($NewSettings !== NULL)
        {
            $Settings = unserialize($NewSettings);
            self::$DeliveryMethod = $Settings["DeliveryMethod"];
            self::$Server = $Settings["Server"];
            self::$Port = $Settings["Port"];
            self::$UserName = $Settings["UserName"];
            self::$Password = $Settings["Password"];
            self::$UseAuthentication = $Settings["UseAuthentication"];
        }
        else
        {
            $Settings["DeliveryMethod"] = self::$DeliveryMethod;
            $Settings["Server"] = self::$Server;
            $Settings["Port"] = self::$Port;
            $Settings["UserName"] = self::$UserName;
            $Settings["Password"] = self::$Password;
            $Settings["UseAuthentication"] = self::$UseAuthentication;
        }
        return serialize($Settings);
    }

    /**
    * Test delivery settings and report their validity.  For example, if
    * the deliver method is set to SMTP it would test the server, port,
    * and (if authentication is indicated) user name and password.  If
    * delivery settings are not okay, then DeliverySettingErrors() can be
    * used to determine (if known) which settings may have problems.
    * @return TRUE if delivery settings are okay, otherwise FALSE.
    */
    public static function DeliverySettingsOkay()
    {
        # start out with error list clear
        self::$DeliverySettingErrorList = array();

        # test based on delivery method
        switch (self::$DeliveryMethod)
        {
            case self::METHOD_PHPMAIL:
                # always report success
                $SettingsOkay = TRUE;
                break;

            case self::METHOD_SMTP:
                # set up PHPMailer for test
                $PMail = new PHPMailer(TRUE);
                $PMail->IsSMTP();
                $PMail->SMTPAuth = self::$UseAuthentication;
                $PMail->Host = self::$Server;
                $PMail->Port = self::$Port;
                $PMail->Username = self::$UserName;
                $PMail->Password = self::$Password;

                # test settings
                try
                {
                    $SettingsOkay = $PMail->SmtpConnect();
                }
                # if test failed
                catch (phpmailerException $Except)
                {
                    # translate PHPMailer error message to possibly bad settings
                    switch ($Except->getMessage())
                    {
                        case 'SMTP Error: Could not authenticate.':
                            self::$DeliverySettingErrorList = array(
                                    "UseAuthentication",
                                    "UserName",
                                    "Password",
                                    );
                            break;

                        case 'SMTP Error: Could not connect to SMTP host.':
                            self::$DeliverySettingErrorList = array(
                                    "Server",
                                    "Port",
                                    );
                            break;

                        case 'Language string failed to load: tls':
                            self::$DeliverySettingErrorList = array("TLS");
                            break;

                        default:
                            self::$DeliverySettingErrorList = array("UNKNOWN");
                            break;
                    }

                    # make sure failure is reported
                    $SettingsOkay = FALSE;
                }
                break;
        }

        # report result to caller
        return $SettingsOkay;
    }

    /**
    * Return array with list of delivery setting errors (if any).
    * @return Array with settings that are possibly bad.
    */
    public static function DeliverySettingErrors()
    {
        return self::$DeliverySettingErrorList;
    }


    # ---- PRIVATE INTERFACE -------------------------------------------------

    private $AlternateBody = "";
    private $BCC = array();
    private $Body = "";
    private $CC = array();
    private $CharSet;
    private $From = "";
    private $Headers = array();
    private $ReplyTo = "";
    private $Subject = "";
    private $To = array();
    private $Whitelist = array();

    private static $DefaultFrom = "";
    private static $DeliveryMethod = self::METHOD_PHPMAIL;
    private static $DeliverySettingErrorList = array();
    private static $LineEnding = "\r\n";
    private static $Password = "";
    private static $Port = 25;
    private static $RecipientWhitelist = array();
    private static $Server;
    private static $UseAuthentication = FALSE;
    private static $UserName = "";

    /**
    * Assemble and send message via PHP internal mail function.
    * @return bool TRUE if message appeared successfully sent, otherwise FALSE.
    */
    private function SendViaPhpMailFunc()
    {
        # Contrary to the PHP documentation, line endings for PHP's
        # mail function should be the system native line endings.
        #
        # see https://bugs.php.net/bug.php?id=15841 for details

        # Use the system line endings
        $LE = PHP_EOL;

        # build basic headers list
        $From = strlen($this->From) ? $this->From : self::$DefaultFrom;
        $Headers = "From: ".self::CleanHeaderValue($From).$LE;
        $Headers .= $this->BuildAddresseeLine("Cc", $this->CC);
        $Headers .= $this->BuildAddresseeLine("Bcc", $this->BCC);
        $Headers .= "Reply-To: ".self::CleanHeaderValue(
                strlen($this->ReplyTo) ? $this->ReplyTo : $this->From).$LE;

        # add additional headers
        foreach ($this->Headers as $ExtraHeader)
        {
            $Headers .= $ExtraHeader.$LE;
        }

        # build recipient list
        $To = "";
        $Separator = "";
        foreach ($this->To as $Recipient)
        {
            $To .= $Separator.$Recipient;
            $Separator = ", ";
        }

        # normalize message body line endings
        $Body = $this->NormalizeLineEndings($this->Body, $LE);

        # send message
        $Result = mail($To, $this->Subject, $Body, $Headers);

        # report to caller whether attempt to send succeeded
        return $Result;
    }

    /**
    * Assemble and send the message via PHPMailer.
    * @return bool Returns TRUE if message appeared successfully sent, otherwise
    *      FALSE.
    */
    private function SendViaPhpMailerLib()
    {
        # create and initialize PHPMailer
        $PMail = new PHPMailer();
        $PMail->LE = self::$LineEnding;
        $PMail->Subject = $this->Subject;
        $PMail->Body = $this->Body;
        $PMail->IsHTML(FALSE);

        # default values for the sender's name and address
        $Name = "";
        $Address = $this->From;

        # if the address contains a name and address, they need to extracted
        # because PHPMailer requires that they are set as two different
        # parameters
        if (preg_match("/ </", $this->From))
        {
            $Pieces = explode(" ", $this->From);
            $Address = array_pop($Pieces);
            $Address = preg_replace("/[<>]+/", "", $Address);
            $Name = trim(implode($Pieces, " "));
        }

        # add the sender
        $PMail->SetFrom($Address, $Name);

        # add each recipient
        foreach ($this->To as $Recipient)
        {
            $PMail->AddAddress($Recipient);
        }

        # add any extra header lines
        foreach ($this->Headers as $ExtraHeader)
        {
            $PMail->AddCustomHeader($ExtraHeader);
        }

        # add the charset if it's set
        if (isset($this->CharSet))
        {
            $PMail->CharSet = strtolower($this->CharSet);
        }

        # add the alternate plain-text body if it's set
        if ($this->HasAlternateBody())
        {
            $PMail->AltBody = $this->AlternateBody;
        }

        # set up SMTP if necessary
        if (self::$DeliveryMethod == self::METHOD_SMTP)
        {
            $PMail->IsSMTP();
            $PMail->SMTPAuth = self::$UseAuthentication;
            $PMail->Host = self::$Server;
            $PMail->Port = self::$Port;
            $PMail->Username = self::$UserName;
            $PMail->Password = self::$Password;
        }

        # send message
        $Result = $PMail->Send();

        # report to caller whether attempt to send succeeded
        return $Result;
    }

    /**
    * Assemble and send message via SMTP.
    * @return bool TRUE if message appeared successfully sent, otherwise FALSE.
    */
    private function SendViaSmtp()
    {
        # send via PHPMailer because it's capable of handling SMTP
        return $this->SendViaPhpMailerLib();
    }

    /**
    * Build addressee (CC/BCC/Reply-To/etc) line for mail header.
    * @param string $Label Keyword for beginning of line (without ":").
    * @param array $Recipients Array of addresses to put on line.
    * @return string Generated header line.
    */
    private function BuildAddresseeLine($Label, $Recipients)
    {
        $Line = "";
        if (count($Recipients))
        {
            $Line .= $Label.": ";
            $Separator = "";
            foreach ($Recipients as $Recipient)
            {
                $Line .= $Separator.self::CleanHeaderValue($Recipient);
                $Separator = ", ";
            }
            $Line .= self::$LineEnding;
        }
        return $Line;
    }

    /**
    * Determine if the object has a plain-text alternative set.
    * @return Returns TRUE if an plain-text alternative to the body is set.
    */
    private function HasAlternateBody()
    {
        return isset($this->AlternateBody) && strlen(trim($this->AlternateBody)) > 0;
    }

    /**
    * Remove problematic content from values to be used in message header.
    * @param string $Value Value to be sanitized.
    * @return Sanitized value.
    */
    private static function CleanHeaderValue($Value)
    {
        # (regular expression taken from sanitizeHeaders() function in
        #       Mail PEAR package)
        return preg_replace('=((<CR>|<LF>|0x0A/%0A|0x0D/%0D|\\n|\\r)\S).*=i',
                "", $Value);
    }

    /**
    * Normalize all line endings in a string.
    * @param string $Value Value in which all line endings will be normalized.
    * @param string $LineEnding Character sequence used as a line ending.
    * @return Returns the string with all line endings normalized.
    */
    private static function NormalizeLineEndings($Value, $LineEnding)
    {
        return preg_replace('/\r\n|\r|\n/', $LineEnding, $Value);
    }

    /**
    * Convert horizontal white space with no semantic value to vertical white
    * space when possible. Only converts white space between tag attributes by
    * default, but can also convert white space within tags if specified.
    * @param mixed $Html HTML string in which white space should be converted.
    * @param bool $Aggressive TRUE to also convert white space within tags in
    *      which horizontal whitespace has no semantic value. This should only
    *      be used when absolutely necessary because it can make the HTML hard
    *      to read. This parameter is optional.
    * @param string $LineEnding Character sequence to use as the line ending.
    *      This parameter is optional.
    * @return Returns the HTML with its horizontal white space converted to
    *      vertical white space as specified in the parameters.
    */
    protected static function ConvertHtmlWhiteSpace(
            $Html, $Aggressive=FALSE, $LineEnding="\r\n")
    {
        $HtmlLength = strlen($Html);

        # tags that should have their inner HTML left alone
        $IgnoredTags = array('script', 'style', 'textarea', 'title');

        # values for determining context
        $InTag = FALSE;
        $InClosingTag = FALSE;
        $InIgnoredTag = FALSE;
        $InAttribute = FALSE;
        $TagName = NULL;
        $IgnoredTagName = NULL;
        $AttributeDelimiter = NULL;

        # loop through each character of the string
        for ($i = 0; $i < $HtmlLength; $i++)
        {
            $Char = $Html{$i};

            # beginning of a tag
            if ($Char == "<" && !$InTag)
            {
                $InTag = TRUE;
                $InAttribute = FALSE;
                $AttributeDelimiter = NULL;

                # do some lookaheads to get the tag name and to see if the tag
                # is a closing tag
                list($InClosingTag, $TagName) = self::GetTagInfo($Html, $i);

                # moving into an ignored tag
                if (!$InClosingTag && in_array($TagName, $IgnoredTags))
                {
                    $InIgnoredTag = TRUE;
                    $IgnoredTagName = $TagName;
                }

                continue;
            }

            # end of a tag
            if ($Char == ">" && $InTag && !$InAttribute)
            {
                # moving out of an ignored tag
                if ($InClosingTag && $InIgnoredTag && $TagName == $IgnoredTagName)
                {
                    $InIgnoredTag = FALSE;
                    $IgnoredTagName = NULL;
                }

                $InTag = FALSE;
                $InClosingTag = FALSE;
                $InAttribute = FALSE;
                $TagName = NULL;
                $AttributeDelimiter = NULL;

                continue;
            }

            # attribute delimiter characters
            if ($Char == "'" || $Char == '"')
            {
                # beginning of an attribute
                if (!$InAttribute)
                {
                    $InAttribute = TRUE;
                    $AttributeDelimiter = $Char;
                    continue;
                }

                # end of the attribute
                if ($InAttribute && $Char == $AttributeDelimiter)
                {
                    $InAttribute = FALSE;
                    $AttributeDelimiter = NULL;
                    continue;
                }
            }

            # whitespace inside of a tag but outside of an attribute can be
            # safely converted to a newline
            if ($InTag && !$InAttribute && preg_match('/\s/', $Char))
            {
                $Html{$i} = $LineEnding;
                continue;
            }

            # whitespace outside of a tag can be safely converted to a newline
            # when not in one of the ignored tags, but only do so if horizontal
            # space is at a premium because it can make the resulting HTML
            # difficult to read
            if ($Aggressive && !$InTag && !$InIgnoredTag && preg_match('/\s/', $Char))
            {
                $Html{$i} = $LineEnding;
                continue;
            }
        }

        return $Html;
    }

    /**
    * Get the tag name and whether it's a closing tag from a tag that begins at
    * a specific offset within some HTML. This is really only useful to
    * ConvertHtmlWhiteSpace().
    * @param string $Html HTML string from which to get the information.
    * @param int $TagBegin Offset of where the tag begins.
    * @return Returns an array containing the tag name and if it's a closing tag.
    */
    protected static function GetTagInfo($Html, $TagBegin)
    {
        $HtmlLength = strlen($Html);

        # default return values
        $InClosingTag = FALSE;
        $TagName = NULL;

        # if at the end of the string and lookaheads aren't possible
        if ($TagBegin + 1 >= $HtmlLength)
        {
            return array($InClosingTag, $TagName);
        }

        # do a lookahead for whether it's a closing tag
        if ($Html{$TagBegin+1} == "/")
        {
            $InClosingTag = TRUE;
        }

        # determine whether to offset by one or two to get the tag name
        $TagStart = $InClosingTag ? $TagBegin + 2 : $TagBegin + 1;

        # do a lookahead for the tag name
        for ($i = $TagStart; $i < $HtmlLength; $i++)
        {
            $Char = $Html{$i};

            # stop getting the tag name if whitespace is found and something is
            # available for the tag name
            if (strlen($TagName) && preg_match('/[\r\n\s]/', $Char))
            {
                break;
            }

            # stop getting the tag name if the character is >
            if ($Char == ">")
            {
                break;
            }

            $TagName .= $Char;
        }

        # comment "tag"
        if (substr($TagName, 0, 3) == "!--")
        {
            return array($InClosingTag, "!--");
        }

        # remove characters that aren't part of a valid tag name
        $TagName = preg_replace('/[^a-zA-Z0-9]/', '', $TagName);

        return array($InClosingTag, $TagName);
    }
}

