<?php
/**
 * Omeka
 * 
 * @copyright Copyright 2007-2012 Roy Rosenzweig Center for History and New Media
 * @license http://www.gnu.org/licenses/gpl-3.0.txt GNU GPLv3
 */

/**
 * View Helper for displaying files through Omeka.
 * 
 * This will determine how to display any given file based on the MIME type 
 * (Internet media type) of that file. Individual rendering agents are defined 
 * by callbacks that are either contained within this class or defined by 
 * plugins. Callbacks defined by plugins will override native class methods if 
 * defined for existing MIME types. In order to define a rendering callback that 
 * should be in the core of Omeka, define a method in this class and then make 
 * sure that it responds to all the correct MIME types by modifying other 
 * properties in this class.
 * 
 * @package Omeka\View\Helper
 */
class Omeka_View_Helper_FileMarkup extends Zend_View_Helper_Abstract
{
    /**
     * Fallback image used when no other fallbacks are appropriate.
     *
     * @var string
     */
    const GENERIC_FALLBACK_IMAGE = 'fallback-file.png';

    /**
     * Array of MIME types and the callbacks that can process it.
     *
     * Example:
     * array('video/avi'=>'wmv');
     *
     * @var array
     */
    protected static $_callbacks = [
        'audio/ogg' => 'audio',
        'audio/x-ogg' => 'audio',
        'audio/aac' => 'audio',
        'audio/x-aac' => 'audio',
        'audio/aiff' => 'audio',
        'audio/x-aiff' => 'audio',
        'audio/mp3' => 'audio',
        'audio/mpeg' => 'audio',
        'audio/mpeg3' => 'audio',
        'audio/mpegaudio' => 'audio',
        'audio/mpg' => 'audio',
        'audio/x-mp3' => 'audio',
        'audio/x-mpeg' => 'audio',
        'audio/x-mpeg3' => 'audio',
        'audio/x-mpegaudio' => 'audio',
        'audio/x-mpg' => 'audio',
        'audio/mp4' => 'audio',
        'audio/x-mp4' => 'audio',
        'audio/x-m4a' => 'audio',
        'audio/wav' => 'audio',
        'audio/x-wav' => 'audio',
        'video/mp4' => 'video',
        'video/x-m4v' => 'video',
        'video/ogg' => 'video',
        'video/webm' => 'video',
        'video/quicktime' => 'video',
    ];

    /**
     * Array of file extensions and the callbacks that can process them.
     * 
     * Taken from http://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types
     * 
     * @var array
     */
    private static $_fileExtensionCallbacks = [
        // application/ogg
        'ogx' => 'audio',
        // audio/x-aac
        'aac' => 'audio',
        // audio/x-aiff
        'aif' => 'audio',
        'aiff' => 'audio',
        'aifc' => 'audio',
        // audio/mpeg
        'mpga' => 'audio',
        'mp2' => 'audio',
        'mp2a' => 'audio',
        'mp3' => 'audio',
        'm2a' => 'audio',
        'm3a' => 'audio',
        // audio/mp4
        'mp4a' => 'audio',
        'm4a' => 'audio',
        // audio/ogg
        'oga' => 'audio',
        'ogg' => 'audio',
        'spx' => 'audio',
        'opus' => 'audio',
        // audio/x-wav
        'wav' => 'audio',
        // video/mp4
        'mp4' => 'video',
        'mp4v' => 'video',
        'mpg4' => 'video',
        'm4v' => 'video',
        // video/ogg
        'ogv' => 'video',
        // video/webm
        'webm' => 'video',
        // video/quicktime
        'mov' => 'video',
    ];

    /**
     * The array consists of the default options which are passed to the 
     * callback.
     *
     * @var array
     */
    protected static $_callbackOptions = [
        'defaultDisplay' => [
            'linkToFile' => true,
            'linkToMetadata' => false,
            'linkText' => null,
            ],
        'derivativeImage' => [
            'imageSize' => null,
            'linkToFile' => true,
            'linkToMetadata' => false,
            'imgAttributes' => []
            ],
        'video' => [
            'width' => '320',
            'height' => '240',
            'autoplay' => false,
            'controller' => true,
            'loop' => false,
            ],
        'audio' => [
            'width' => '200',
            'height' => '20',
            'autoplay' => false,
            'controller' => true,
            'loop' => false
        ],
        'icon' => [
            'showFilename' => true,
            'icons' => [],
            'linkToFile' => true,
            'linkToMetadata' => false,
            'linkAttributes' => [],
            'imgAttributes' => [],
            'filenameAttributes' => []
        ]
    ];

    /**
     * Images to show when a file has no derivative.
     *
     * @var array
     */
    protected static $_fallbackImages = [
        'audio' => 'fallback-audio.png',
        'image' => 'fallback-image.png',
        'video' => 'fallback-video.png',
        '*'     => self::GENERIC_FALLBACK_IMAGE
    ];

    /**
     * Add MIME types and/or file extensions and associated callbacks to the 
     * list.
     * 
     * This allows plugins to override/define ways of displaying specific files. 
     * The most obvious example of where this would come in handy is to define 
     * ways of displaying uncommon files, such as QTVR, or novel ways of 
     * displaying more common files, such as using iPaper to display PDFs.
     *
     * @see add_mime_display_type()
     * @internal This method (and the properties upon which it operates) are 
     * static because it gets called prior to instantiation of the view, i.e.
     * in the plugin loading phase.  Since there is no way to inject view
     * helpers into the view object, this helper object cannot be instantiated
     * and registered for use by the add_mime_display_type() function.
     * 
     * @param array|string $fileIdentifiers Set of MIME types (Internet media 
     * types) and/or file extensions that this specific callback will respond 
     * to. Accepts the following:
     * <ul>
     *     <li>A string containing one MIME type: 
     *     <code>'application/msword'</code></li>
     *     <li>A simple array containing MIME types: 
     *     <code>array('application/msword', 'application/doc')</code></li>
     *     <li>A keyed array containing MIME types: 
     *     <code>array('mimeTypes' => array('application/msword', 'application/doc'))</code></li>
     *     <li>A keyed array containing file extensions: 
     *     <code>array('fileExtensions' => array('doc', 'docx''DOC', 'DOCX'))</code></li>
     *     <li>A keyed array containing MIME types and file extensions: <code>
     *     array(
     *         'mimeTypes' => array(
     *             'application/msword', 
     *             'application/doc', 
     *             'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 
     *         ), 
     *         'fileExtensions' => array('doc', 'docx', 'DOC', 'DOCX'), 
     *     )
     *     </code></li>
     * </ul>
     * Note that file extensions are case sensitive.
     * @param callback Any valid callback.  This function should return a string 
     * containing valid XHTML, which will be used to display the file.
     * @param array $defaultOptions
     * @param array $fileExtensions
     */
    public static function addMimeTypes($fileIdentifiers, $callback, array $defaultOptions = [])
    {
        // Create the keyed list of mimeType => callback and fileExtension =>
        // callback format, and merge them with the current lists.
        $callbackListMimeTypes = [];
        $callbackListFileExtensions = [];

        // Interpret string as MIME type.
        if (is_string($fileIdentifiers)) {
            $fileIdentifiers = (array) $fileIdentifiers;
            $fillArray = array_fill(0, count($fileIdentifiers), $callback);
            $callbackListMimeTypes = array_combine($fileIdentifiers, $fillArray);
        } elseif (is_array($fileIdentifiers)) {
            // Interpret unkeyed array as MIME types.
            if (array_key_exists(0, $fileIdentifiers)) {
                $fillArray = array_fill(0, count($fileIdentifiers), $callback);
                $callbackListMimeTypes = array_combine($fileIdentifiers, $fillArray);
            // Interpret keyed array as MIME types and/or file extensions.
            } else {
                if (array_key_exists('mimeTypes', $fileIdentifiers)) {
                    $fillArray = array_fill(0, count($fileIdentifiers['mimeTypes']), $callback);
                    $callbackListMimeTypes = array_combine($fileIdentifiers['mimeTypes'], $fillArray);
                }
                if (array_key_exists('fileExtensions', $fileIdentifiers)) {
                    $fillArray = array_fill(0, count($fileIdentifiers['fileExtensions']), $callback);
                    $callbackListFileExtensions = array_combine($fileIdentifiers['fileExtensions'], $fillArray);
                }
            }
        }

        self::$_callbacks = array_merge(self::$_callbacks, $callbackListMimeTypes);
        self::$_fileExtensionCallbacks = array_merge(self::$_fileExtensionCallbacks, $callbackListFileExtensions);

        // Add this callback's default options to the list.
        $key = self::_getCallbackKey($callback);
        self::$_callbackOptions[$key] = $defaultOptions;
    }

    /**
     * Add a fallback image for the given mime type or type family.
     *
     * @param string $mimeType The mime type this fallback is for, or the mime
     *  "prefix" it is for (video, audio, etc.)
     * @param string $image The name of the image to use, as would be passed to
     *  img()
     */
    public static function addFallbackImage($mimeType, $image)
    {
        self::$_fallbackImages[$mimeType] = $image;
    }

    /**
     * Default display for MIME types that do not have a valid rendering 
     * callback.  
     *
     * This wraps the original filename in a link to download that file, with a 
     * class of "download-file".  Any behavior more complex than that should be 
     * processed with a valid callback.
     * 
     * @param File $file
     * @param array $options
     * @return string HTML
     */
    public function defaultDisplay($file, array $options = [])
    {
        $html = null;
        if ($options['linkText']) {
            $html = $options['linkText'];
        }
        return $this->_linkToFile($file, $options, $html);
    }

    /**
     * Add a link for the file based on the given set of options.
     * 
     * If the 'linkToMetadata' option is true, then link to the file
     * metadata page (files/show).  If 'linkToFile' is true,
     * link to the original file, and if 'linkToFile' is a string, try
     * to link to that specific derivative. Otherwise just return the
     * $html without wrapping in a link.
     * 
     * The attributes for the link will be based off the 'linkAttributes' 
     * option, which should be an array.
     * 
     * If $html is null, it defaults to original filename of the file.
     * 
     * @param File $file
     * @param array $options
     * @param string $html
     * @return string
     */
    protected function _linkToFile($file, $options, $html = null)
    {
        if ($html === null) {
            $html = metadata($file, 'rich_title', ['no_escape' => true]);
        }

        $linkAttributes = $options['linkAttributes'] ?? [];

        if ($options['linkToMetadata']) {
            $html = link_to_file_show($linkAttributes, $html, $file);
        } elseif (($linkToFile = $options['linkToFile'])) {
            // If you've manually specified a derivative type to link
            // to, and this file actually has derivatives, we'll use
            // that, otherwise, the link is to the "original" file.
            if (is_string($linkToFile) && $file->hasThumbnail()) {
                $derivative = $linkToFile;
            } else {
                $derivative = 'original';
            }

            // Wrap in a link that will download the file directly.
            $defaultLinkAttributes = [
                'class' => 'download-file',
                'href' => $file->getWebPath($derivative)
                ];
            $linkAttributes = array_merge($defaultLinkAttributes, $linkAttributes);
            $html = '<a ' . tag_attributes($linkAttributes) . '>' . $html . '</a>';
        }
        return $html;
    }

    /**
     * Retrieve valid XHTML for displaying Quicktime video files
     * 
     * @param File $file
     * @param array $options The set of default options for this includes:
     *  width, height, autoplay, controller, loop
     * @return string
     */
    public function video($file, array $options = [])
    {
        return $this->_media('video', $file, $options);
    }

    /**
     * Default display of audio files via <audio> tag.
     * 
     * @param File $file
     * @param array $options The set of default options for this includes:
     *  width, height, autoplay, controller, loop
     * @return string
     */
    public function audio($file, array $options)
    {
        return $this->_media('audio', $file, $options);
    }

    protected function _media($type, $file, array $options)
    {
        if ($type !== 'audio' && $type !== 'video') {
            $type = 'video';
        }
        $url = $file->getWebPath('original');
        $escapedUrl = html_escape($url);
        $attrs = [
            'src' => $url,
            'class' => 'omeka-media',
            'width' => $options['width'],
            'height' => $options['height'],
            'controls' => (bool) $options['controller'],
            'autoplay' => (bool) $options['autoplay'],
            'loop' => (bool) $options['loop'],
        ];
        $html = '<' . $type . ' ' . tag_attributes($attrs) . '>'
            . '<a href="' . $escapedUrl . '">' . metadata($file, 'rich_title', ['no_escape' => true]) . '</a>'
            . '</'. $type .'>';
        return $html;
    }

    /**
     * Default display of an icon to represent a file.
     * 
     * Example usage:
     * 
     * echo files_for_item(array(
     *            'showFilename'=>false,
     *            'linkToFile'=>false,
     *            'linkAttributes'=>array('rel'=>'lightbox'),
     *            'filenameAttributes'=>array('class'=>'error'),
     *            'imgAttributes'=>array('id'=>'foobar'),
     *            'icons' => array('audio/mpeg'=>img('audio.gif'))));
     * 
     * @param File
     * @param array $options Available options include: 
     *      'showFilename' => boolean, 
     *      'linkToFile' => boolean,
     *      'linkAttributes' => array, 
     *      'filenameAttributes' => array (for the filename div), 
     *      'imgAttributes' => array, 
     *      'icons' => array.
     * @return string
     */
    public function icon($file, array $options = [])
    {
        $mimeType = $file->mime_type;
        $imgAttributes = (array) $options['imgAttributes'];
        // The path to the icon is keyed to the MIME type of the file.
        $imgAttributes['src'] = (string) $options['icons'][$mimeType];

        $html = '<img ' . tag_attributes($imgAttributes) . ' />';

        if ($options['showFilename']) {
            // Add a div with arbitrary attributes.
            $html .= '<div ' . tag_attributes((array) $options['filenameAttributes'])
                   . '>' . html_escape($file->original_filename) . '</div>';
        }

        return $this->_linkToFile($file, $options, $html);
    }

    /**
     * Returns valid XHTML markup for displaying an image that has been stored 
     * in Omeka.
     * 
     * @param File $file
     * @param array $file Options for customizing the display of images. Current
     * options include: 'imageSize'
     * @return string HTML for display
     */
    public function derivativeImage($file, array $options = [])
    {
        $html = '';
        $imgHtml = '';

        // Should we ever include more image sizes by default, this will be
        // easier to modify.
        $imgClasses = [
            null => 'thumb',
            'thumbnail' => 'thumb',
            'square_thumbnail' => 'thumb',
            'fullsize' => 'full'];
        $imageSize = $options['imageSize'];

        // If we can make an image from the given image size.
        if (array_key_exists($imageSize, $imgClasses)) {

            // A class is given to all of the images by default to make it
            // easier to style. This can be modified by passing it in as an
            // option, but recommended against. Can also modify alt text via an
            // option.
            $imgClass = $imgClasses[$imageSize];
            $imgAttributes = array_merge(['class' => $imgClass],
                                (array) $options['imgAttributes']);
            $imgHtml = $this->image_tag($file, $imgAttributes, $imageSize);
        }
        $html .= !empty($imgHtml) ? $imgHtml : html_escape($file->original_filename);
        $html = $this->_linkToFile($file, $options, $html);
        return $html;
    }
    // END DEFINED DISPLAY CALLBACKS

    protected function getCallback($file, $options)
    {
        $mimeType = $file->mime_type;
        $fileExtension = $file->getExtension();

        // Displaying icons overrides the default lookup mechanism.
        if (array_key_exists('icons', $options) and
                array_key_exists($mimeType, $options['icons'])) {
            return 'icon';
        }

        if (array_key_exists($mimeType, self::$_callbacks)) {
            $name = self::$_callbacks[$mimeType];
        } elseif (array_key_exists($fileExtension, self::$_fileExtensionCallbacks)) {
            $name = self::$_fileExtensionCallbacks[$fileExtension];
        } elseif ($file->hasThumbnail()) {
            $name = 'derivativeImage';
        } else {
            $name = 'defaultDisplay';
        }

        return $name;
    }

    /**
     * @see Omeka_Plugin_Broker::addMediaAdapter()
     * @param mixed $callback
     * @return array
     */
    protected function getDefaultOptions($callback)
    {
        $key = self::_getCallbackKey($callback);
        if (array_key_exists($key, self::$_callbackOptions)) {
            return (array) self::$_callbackOptions[$key];
        } else {
            return [];
        }
    }

    /**
     * Retrieve the HTML for a given file from the callback.   
     * 
     * @param File $file
     * @param callback $renderer Any valid callback that will display the HTML.
     * @param array $options Set of options passed to the rendering callback.
     * @return string HTML for displaying the file.
     */
    protected function getHtml($file, $renderer, array $options)
    {
        //Format the callback based on whether we can actually run it

        //If the callback is native to this object, get it valid and run it
        if (is_string($renderer) and method_exists($this, $renderer)) {
            $renderer = [$this, $renderer];
        }

        return call_user_func_array($renderer, [$file, $options]);
    }

    /**
     * Bootstrap for the helper class.  This will retrieve the HTML for
     * displaying the file and by default wrap it in a <div class="item-file">.
     * 
     * @param File $file
     * @param array $options Set of options passed by a theme writer to the
     * customize the display of any given callback.
     * @param array $wrapperAttributes
     * @return string HTML
     */
    public function fileMarkup($file, array $options = [], $wrapperAttributes = [])
    {
        $options = apply_filters('file_markup_options', $options, ['file' => $file]);

        // There is a chance that $options passed in could modify the callback
        // that is used.  Currently used to determine whether or not to display
        // an icon.
        $callback = $this->getCallback($file, $options);

        $options = array_merge($this->getDefaultOptions($callback), $options);

        $html = $this->getHtml($file, $callback, $options);

        // Append a class name that corresponds to the MIME type.
        if ($wrapperAttributes) {
            $mimeTypeClassName = str_replace(['/', '+', '.'], '-', $file->mime_type);
            if (array_key_exists('class', $wrapperAttributes)) {
                $wrapperAttributes['class'] .= ' ' . $mimeTypeClassName;
            } else {
                $wrapperAttributes['class'] = $mimeTypeClassName;
            }
        }

        //Wrap the HTML in a div with a class (if class is not set to null)
        $wrapper = !empty($wrapperAttributes) ? '<div ' . tag_attributes($wrapperAttributes) . '>' : '';
        $html = !empty($wrapper) ? $wrapper . $html . "</div>" : $html;

        return apply_filters(
            'file_markup',
            $html,
            [
                'file' => $file,
                'callback' => $callback,
                'options' => $options,
                'wrapper_attributes' => $wrapperAttributes,
            ]
        );
    }

    /**
     * Get the image URL for a record.
     *
     * The returned URL is the same as that used for image_tag.
     *
     * @param Omeka_Record_AbstractRecord $record
     * @param string|null $format Derivative image type (thumbnail, etc.)
     *  Pass null to use the admin-configured default type
     * @return string|false The URL. Returns false if invalid record passed or
     *  if record has no file associated.
     */
    public function image_url($record, $format = null)
    {
        $file = $this->_getFileForRecord($record);
        if (!$file) {
            return false;
        }

        if (!$format) {
            $format = (get_option('use_square_thumbnail') == 1) ? 'square_thumbnail' : 'thumbnail';
        }

        if ($file->hasThumbnail()) {
            return $file->getWebPath($format);
        }
        return img($this->_getFallbackImage($file));
    }

    /**
     * Return a valid img tag for an image.
     *
     * @param Omeka_Record_AbstractRecord $record
     * @param array $attrs Image tag attributes
     * @param string|null $format Derivative image type (thumbnail, etc.)
     *  Pass null to use the admin-configured default type
     * @return string
     */
    public function image_tag($record, $attrs, $format)
    {
        $file = $this->_getFileForRecord($record);
        if (!$file) {
            return false;
        }

        $uri = $this->image_url($record, $format);

        if ($uri === false) {
            return false;
        }

        $attrs['src'] = $uri;

        /** 
         * Determine alt attribute for images
         * Should use the following in this order:
         * 1. passed 'alt' prop
         * 2. first Dublin Core Title for $file
         * 3. original filename for $file
         */
        $alt = '';
        if (isset($attrs['alt'])) {
            $alt = $attrs['alt'];
        } else {
            $alt = ($file->getAltText()) ? $file->getAltText() : '';
        }
        $attrs['alt'] = $alt;

        $attrs = apply_filters('image_tag_attributes', $attrs, [
            'record' => $record,
            'file' => $file,
            'format' => $format,
        ]);
        // Build the img tag
        return '<img ' . tag_attributes($attrs) . '>';
    }

    /**
     * Get the name of a fallback image to use for this file.
     *
     * The fallback used depends on the file's mime type.
     *
     * @see self::addFallbackImage()
     * @param File $file The file to get a fallback for.
     * @return string Name of the image to use.
     */
    protected function _getFallbackImage($file)
    {
        $mimeType = $file->mime_type;
        if (isset(self::$_fallbackImages[$mimeType])) {
            return self::$_fallbackImages[$mimeType];
        }

        $mimePrefix = substr($mimeType, 0, strpos($mimeType, '/'));
        if (isset(self::$_fallbackImages[$mimePrefix])) {
            return self::$_fallbackImages[$mimePrefix];
        }

        return self::$_fallbackImages['*'];
    }

    /**
     * Get the representative File for a record
     *
     * @param Omeka_Record_AbstractRecord $record
     * @return File|null
     */
    protected function _getFileForRecord($record)
    {
        if (!($record && $record instanceof Omeka_Record_AbstractRecord)) {
            return null;
        }

        // Use the default representative file.
        return $record->getFile();
    }

    /**
     * Get a string key to represent a given callback.
     *
     * This key can be used to store and retrieve data about the
     * callback, like default options.
     *
     * @param callback $callback
     * @return string
     */
    protected static function _getCallbackKey($callback)
    {
        if (is_string($callback)) {
            return $callback;
        } elseif (is_callable($callback, false, $name)) {
            return $name;
        } else {
            throw new InvalidArgumentException('Invalid file display callback.');
        }
    }
}
