<?php
/**
 * p01-contact - A simple contact forms manager.
 *
 * @see https://github.com/nliautaud/p01contact
 *
 * @author Nicolas Liautaud
 */

namespace P01C;

require 'P01contact_Field.php';

class P01contactForm
{
    public $lang;
    public $sent;
    private $manager;

    private $id;
    private $status;
    private $targets;
    private $fields;

    /**
     * @param P01contact $P01contact
     * @param int        $id         the form id
     */
    public function __construct($P01contact)
    {
        static $id;
        ++$id;

        $this->manager = $P01contact;

        $this->id = $id;
        $this->status = '';
        $this->targets = [];
        $this->fields = [];
    }

    /**
     * Find tag parameters, populate fields and targets.
     *
     * @param string $params the params
     */
    public function parseTag($params)
    {
        // assure formating
        $params = str_replace('&nbsp;', ' ', $params);
        $params = strip_tags(str_replace("\n", '', $params));
        $params = html_entity_decode($params, ENT_QUOTES, 'UTF-8');

        // explode
        $sep = $this->config('separator');
        $params = array_filter(explode($sep, $params));

        // emails
        foreach ($params as $id => $param) {
            $param = trim($param);
            if (filter_var($param, FILTER_VALIDATE_EMAIL)) {
                $this->addTarget($param);
                unset($params[$id]);
            }
        }
        // default params
        if (empty($params)) {
            $default = $this->config('default_params');
            $params = array_filter(explode($sep, $default));
        }
        // create fields
        foreach (array_values($params) as $id => $param) {
            $this->parseParam($id, trim($param));
        }
        // default email addresses
        $default_emails = $this->getValidEmails($this->config('default_email'));
        foreach ($default_emails as $email) {
            $this->addTarget($email);
        }
    }

    /**
     * Update POSTed form and try to send mail.
     *
     * Check posted data, update form data,
     * define fields errors and form status.
     * At least, if there is no errors, try to send mail.
     */
    public function post()
    {
        if (empty($_POST['p01-contact_form'])
         || $_POST['p01-contact_form']['id'] != $this->id) {
            return;
        }

        // check token
        if (!$this->checkToken()) {
            $this->setStatus('sent_already');
            $this->setToken();
            $this->reset();

            return;
        }

        $posted = $_POST['p01-contact_fields'];

        // populate fields values and check errors
        $hasFieldsErrors = false;
        $fields = $this->getFields();
        foreach ($fields as $field) {
            if (!isset($posted[$field->id])) {
                continue;
            }
            $posted_val = $posted[$field->id];
            $field->setValue($posted_val);
            $hasFieldsErrors = !$field->validate() || $hasFieldsErrors;
        }

        // check errors and set status
        if ($this->config('disable')) {
            $this->setStatus('disable');

            return;
        }
        if (0 == count($this->targets)) {
            $this->setStatus('error_notarget');

            return;
        }
        if ($hasFieldsErrors || true !== $this->checkSpam($posted)) {
            return;
        }

        $this->sendMail();
        $this->setToken();
        $this->reset();
    }

    /**
     * Get the token in Session (create it if not exists).
     *
     * @return string
     */
    public function getToken()
    {
        if (!Session::get('token', false)) {
            $this->setToken();
        }

        return Session::get('token');
    }

    // RENDER

    /**
     * Return the html display of the form.
     *
     * @return string the <form>
     */
    public function html()
    {
        $html = '<div class="section">';
        $html .= '<form action="'.PAGEURL.'#p01-contact'.$this->id.'" autocomplete="off" ';
        $html .= 'id="p01-contact'.$this->id.'" class="p01-contact" method="post">';

        if ($this->status) {
            $html .= $this->htmlStatus();
        }
        if (!$this->sent) {
            foreach ($this->fields as $field) {
                $html .= $field->html();
            }
            if ($this->config('use_honeypot')) {
                $html .= '<input type="checkbox" name="p01-contact_fields[totally_legit]" value="1" style="display:none !important" tabindex="-1" autocomplete="false">';
            }
            $html .= '<div><input name="p01-contact_form[id]" type="hidden" value="'.$this->id.'" />';
            $html .= '<input name="p01-contact_form[token]" type="hidden" value="'.$this->getToken().'" />';
            $html .= '<input class="submit" type="submit" value="'.$this->lang('send').'" /></div>';
        }
        $html .= '</form>';
        $html .= '</div>';

        if ($this->config('debug')) {
            $html .= $this->debug(false);
        }

        return $html;
    }

    /**
     * Return P01contact_form infos.
     *
     * @param mixed $set_infos
     *
     * @return string
     */
    public function debug($set_infos)
    {
        $out = '<div class="debug debug_form">';
        static $post;
        if ($set_infos) {
            $post = $set_infos;

            return;
        }
        if ($post) {
            list($headers, $targets, $subject, $text_content, $html_content) = $post;
            $out .= '<h3>Virtually sent mail :</h3>';
            $out .= '<pre>'.htmlspecialchars($headers).'</pre>';
            $out .= "<pre>Targets: {$targets}\nSubject: {$subject}</pre>";
            $out .= "Text content : <pre>{$text_content}</pre>";
            $out .= "HTML content : <div style=\"border:1px solid #ccc;\">{$html_content}</div>";
        }
        $infos = $this;
        unset($infos->manager);
        $out .= "<h3>p01contact form {$this->id} :</h3>";
        $out .= preint($infos, true);
        $out .= '</div>';

        return $out;
    }

    // MAIL

    /**
     * Send a mail based on form.
     *
     * Create the mail content and headers along to settings, form
     * and fields datas; and update the form status (sent|error).
     */
    public function sendMail()
    {
        $email = $name = $subject = $askcopy = null;
        $tpl_data = (object) null;
        $tpl_data->date = date('r');
        $tpl_data->ip = $_SERVER['REMOTE_ADDR'];
        $tpl_data->contact = $this->targets[0];
        // fields
        $tpl_data->fields = '';
        foreach ($this->fields as $field) {
            $tpl_data->fields .= $field->htmlMail();
            switch ($field->type) {
                case 'name':
                    $name = $field->value;

                    break;
                case 'email':
                    $email = $field->value;

                    break;
                case 'subject':
                    $subject = $field->value;

                    break;
                case 'askcopy':
                    $askcopy = true;

                    break;
            }
        }
        $html = $this->manager->renderTemplate('mail_template', $tpl_data);
        $text = strip_tags($html);

        if (empty($name)) {
            $name = $this->lang('anonymous');
        }
        if (empty($subject)) {
            $subject = $this->lang('nosubject');
        }

        // targets, subject, headers and multipart content
        $targets = implode(',', $this->targets);
        $encoded_subject = $this->encodeHeader($subject);

        $mime_boundary = '----'.md5(time());
        $headers = $this->mailHeaders($name, $email, $mime_boundary);

        $content = $this->mailContent($text, 'plain', $mime_boundary);
        $content .= $this->mailContent($html, 'html', $mime_boundary);
        $content .= "--{$mime_boundary}--\n\n";

        // debug
        if ($this->config('debug')) {
            $this->debug([$headers, $targets, $subject, $text, $html]);

            return $this->setStatus('sent_debug');
        }

        // send mail
        $success = mail($targets, $encoded_subject, $content, $headers);

        // log
        $this->manager->log([
            date('d/m/Y H:i:s'), $targets, $subject, $name, $success ? 'success' : 'error',
        ]);

        if (!$success) {
            return $this->setStatus('error');
        }
        if (!$email || !$askcopy) {
            return $this->setStatus('sent');
        }

        // mail copy
        $copy = mail($email, $encoded_subject, $content, $headers);
        $this->setStatus($copy ? 'sent_copy' : 'sent_copy_error');
    }

    /**
     * Return array of valid emails from a comma separated string.
     *
     * @param string $emails
     *
     * @return array
     */
    public static function getValidEmails($emails)
    {
        return array_filter(explode(',', $emails), function ($email) {
            return filter_var($email, FILTER_VALIDATE_EMAIL);
        });
    }

    /**
     * GETTERS / SETTERS.
     */

    // Reset all fields values and errors
    public function reset()
    {
        foreach ($this->fields as $field) {
            $field->value = '';
            $field->error = '';
        }
    }

    public function getTargets()
    {
        return $this->targets;
    }

    public function addTarget($tget)
    {
        if (false === in_array($tget, $this->targets)) {
            $this->targets[] = $tget;
        }
    }

    public function getField($id)
    {
        return $this->fields[$id];
    }

    public function getFields()
    {
        return $this->fields;
    }

    public function addField($field)
    {
        $this->fields[] = $field;
    }

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

    public function setStatus($status)
    {
        if (!is_string($status)) {
            return;
        }
        $this->status = $status;
        if ('sent' == substr($status, 0, 4)) {
            $this->sent = true;
        }
    }

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

    public function config($key)
    {
        return $this->manager->config($key);
    }

    public function lang($key)
    {
        return $this->manager->lang($key, $this->lang);
    }

    /**
     * Create a field by parsing a tag parameter.
     *
     * Find emails and parameters, create and setup form object.
     *
     * @param int    $id    the field id
     * @param string $param the param to parse
     */
    private function parseParam($id, $param)
    {
        $param_pattern = '`\s*([^ ,"=!]+)';     // type
        $param_pattern .= '\s*(!)?';             // required!
        $param_pattern .= '\s*(?:"([^"]*)")?';   // "title"
        $param_pattern .= '\s*(?:\(([^"]*)\))?'; // (description)
        $param_pattern .= '\s*(?:(=[><]?)?';     // =value, =>locked, =<placeholder
        $param_pattern .= '\s*(.*))?\s*`';       // value

        preg_match($param_pattern, $param, $param);
        list(, $type, $required, $title, $desc, $assign, $values) = $param;

        $field = new P01contactField($this, $id, $type);

        // values
        switch ($type) {
            case 'select':
            case 'radio':
            case 'checkbox':
                $field->value = explode('|', $values);
                $field->resetSelectedValues();

                break;
            case 'askcopy':
                // checkbox-like structure
                $field->value = [$this->lang('askcopy')];

                break;
            case 'password':
                // password value is required value
                $field->required = $values;

                break;
            default:
                if ('=<' == $assign) {
                    $field->placeholder = $values;
                } else {
                    // simple value
                    $field->value = $values;
                }
        }
        // required
        if ('password' != $type) {
            $field->required = '!' == $required;
        }
        if ('captcha' == $type) {
            $field->required = true;
        }
        $field->title = $title;
        $field->description = $desc;
        $field->locked = '=>' == $assign;

        $this->addField($field);
    }

    // SECURITY

    /**
     * Check if the honeypot field is untouched and if the time between this post,
     * the page load and previous posts and the hourly post count are valid
     * according to the settings, and set the form status accordingly.
     *
     * @param P01contact_form $form The submitted form
     * @param array           $post Sanitized p01-contact data of $_POST
     *
     * @return bool the result status
     */
    private function checkSpam($post)
    {
        if (isset($post['totally_legit'])) {
            $this->setStatus('error_honeypot');

            return false;
        }
        $loads = Session::get('pageloads');
        if (count($loads) > 1 && $loads[1] - $loads[0] < $this->config('min_sec_after_load')) {
            $this->setStatus('error_pageload');

            return false;
        }
        $lastpost = Session::get('lastpost', false);
        if ($lastpost && time() - $lastpost < $this->config('min_sec_between_posts')) {
            $this->setStatus('error_lastpost');

            return false;
        }
        $postcount = Session::get('postcount', 0);
        if (!$this->config('debug') && $postcount > $this->config('max_posts_by_hour')) {
            $this->setStatus('error_postcount');

            return false;
        }

        Session::set('lastpost', time());
        Session::set('postcount', $postcount + 1);

        return true;
    }

    /**
     * Create an unique hash in Session.
     */
    private static function setToken()
    {
        Session::set('token', uniqid(md5(microtime()), true));
    }

    /**
     * Compare the POSTed token to the Session one.
     *
     * @return bool
     */
    private function checkToken()
    {
        return $this->getToken() === $_POST['p01-contact_form']['token'];
    }

    /**
     * Return an html display of the form status.
     *
     * @return string the <div>
     */
    private function htmlStatus()
    {
        $statusclass = $this->sent ? 'alert success' : 'alert failed';

        return '<div class="'.$statusclass.'">'.$this->lang($this->status).'</div>';
    }

    /**
     * Return the mail headers.
     *
     * @param string $name
     * @param string $email
     * @param string $mime_boundary
     *
     * @return string
     */
    private function mailHeaders($name, $email, $mime_boundary)
    {
        $encoded_name = $this->encodeHeader($name);
        $headers = "From: {$encoded_name} <no-reply@".SERVERNAME.">\n";
        if ($email) {
            $headers .= "Reply-To: {$encoded_name} <{$email}>\n";
            $headers .= "Return-Path: {$encoded_name} <{$email}>";
        }
        $headers .= "\n";
        $headers .= "MIME-Version: 1.0\n";
        $headers .= "Content-type: multipart/alternative; boundary=\"{$mime_boundary}\"\n";
        $headers .= 'X-Mailer: PHP/'.phpversion()."\n";

        return $headers;
    }

    /**
     * Return a multipart/alternative content part.
     *
     * @param string $content
     * @param string $type          the content type (plain, html)
     * @param string $mime_boundary
     *
     * @return string
     */
    private function mailContent($content, $type, $mime_boundary)
    {
        $head = "--{$mime_boundary}\n";
        $head .= "Content-Type: text/{$type}; charset=UTF-8\n";
        $head .= "Content-Transfer-Encoding: 7bit\n\n";

        return $head.$content."\n";
    }

    /**
     * Format a string for UTF-8 email headers.
     *
     * @param string $string
     *
     * @return string
     */
    private function encodeHeader($string)
    {
        $string = base64_encode(html_entity_decode($string, ENT_COMPAT, 'UTF-8'));

        return "=?UTF-8?B?{$string}?=";
    }
}
