<?php

/**
 * Joomla! Content Management System
 *
 * @copyright  (C) 2013 Open Source Matters, Inc. <https://www.joomla.org>
 * @license    GNU General Public License version 2 or later; see LICENSE.txt
 */

namespace Joomla\CMS\Microdata;

// phpcs:disable PSR1.Files.SideEffects
\defined('JPATH_PLATFORM') or die;
// phpcs:enable PSR1.Files.SideEffects

/**
 * Joomla Platform class for interacting with Microdata semantics.
 *
 * @since  3.2
 */
class Microdata
{
    /**
     * Array with all available Types and Properties from the http://schema.org vocabulary
     *
     * @var    array
     * @since  3.2
     */
    protected static $types = null;

    /**
     * The Type
     *
     * @var    string
     * @since  3.2
     */
    protected $type = null;

    /**
     * The Property
     *
     * @var    string
     * @since  3.2
     */
    protected $property = null;

    /**
     * The Human content
     *
     * @var    string
     * @since  3.2
     */
    protected $content = null;

    /**
     * The Machine content
     *
     * @var    string
     * @since  3.2
     */
    protected $machineContent = null;

    /**
     * The Fallback Type
     *
     * @var    string
     * @since  3.2
     */
    protected $fallbackType = null;

    /**
     * The Fallback Property
     *
     * @var    string
     * @since  3.2
     */
    protected $fallbackProperty = null;

    /**
     * Used for checking if the library output is enabled or disabled
     *
     * @var    boolean
     * @since  3.2
     */
    protected $enabled = true;

    /**
     * Initialize the class and setup the default $Type
     *
     * @param   string   $type  Optional, fallback to 'Thing' Type
     * @param   boolean  $flag  Enable or disable the library output
     *
     * @since   3.2
     */
    public function __construct($type = '', $flag = true)
    {
        if ($this->enabled = (bool) $flag) {
            // Fallback to 'Thing' Type
            if (!$type) {
                $type = 'Thing';
            }

            $this->setType($type);
        }
    }

    /**
     * Load all available Types and Properties from the http://schema.org vocabulary contained in the types.json file
     *
     * @return  void
     *
     * @since   3.2
     */
    protected static function loadTypes()
    {
        // Load the JSON
        if (!static::$types) {
            $path          = __DIR__ . '/types.json';
            static::$types = json_decode(file_get_contents($path), true);
        }
    }

    /**
     * Reset all params
     *
     * @return void
     *
     * @since   3.2
     */
    protected function resetParams()
    {
        $this->content          = null;
        $this->machineContent   = null;
        $this->property         = null;
        $this->fallbackProperty = null;
        $this->fallbackType     = null;
    }

    /**
     * Enable or Disable the library output
     *
     * @param   boolean  $flag  Enable or disable the library output
     *
     * @return  Microdata  Instance of $this
     *
     * @since   3.2
     */
    public function enable($flag = true)
    {
        $this->enabled = (bool) $flag;

        return $this;
    }

    /**
     * Return 'true' if the library output is enabled
     *
     * @return  boolean
     *
     * @since   3.2
     */
    public function isEnabled()
    {
        return $this->enabled;
    }

    /**
     * Set a new http://schema.org Type
     *
     * @param   string  $type  The $Type to be setup
     *
     * @return  Microdata  Instance of $this
     *
     * @since   3.2
     */
    public function setType($type)
    {
        if (!$this->enabled) {
            return $this;
        }

        // Sanitize the Type
        $this->type = static::sanitizeType($type);

        // If the given $Type isn't available, fallback to 'Thing' Type
        if (!static::isTypeAvailable($this->type)) {
            $this->type = 'Thing';
        }

        return $this;
    }

    /**
     * Return the current $Type name
     *
     * @return  string
     *
     * @since   3.2
     */
    public function getType()
    {
        return $this->type;
    }

    /**
     * Setup a $Property
     *
     * @param   string  $property  The Property
     *
     * @return  Microdata  Instance of $this
     *
     * @since   3.2
     */
    public function property($property)
    {
        if (!$this->enabled) {
            return $this;
        }

        // Sanitize the $Property
        $property = static::sanitizeProperty($property);

        // Control if the $Property exists in the given $Type and setup it, otherwise leave it 'NULL'
        if (static::isPropertyInType($this->type, $property)) {
            $this->property = $property;
        }

        return $this;
    }

    /**
     * Return the current $Property name
     *
     * @return  string
     *
     * @since   3.2
     */
    public function getProperty()
    {
        return $this->property;
    }

    /**
     * Setup a Human content or content for the Machines
     *
     * @param   string  $content         The human content or machine content to be used
     * @param   string  $machineContent  The machine content
     *
     * @return  Microdata  Instance of $this
     *
     * @since   3.2
     */
    public function content($content, $machineContent = null)
    {
        $this->content        = $content;
        $this->machineContent = $machineContent;

        return $this;
    }

    /**
     * Return the current $content
     *
     * @return  string
     *
     * @since   3.2
     */
    public function getContent()
    {
        return $this->content;
    }

    /**
     * Return the current $machineContent
     *
     * @return  string
     *
     * @since   3.3
     */
    public function getMachineContent()
    {
        return $this->machineContent;
    }

    /**
     * Setup a Fallback Type and Property
     *
     * @param   string  $type      The Fallback Type
     * @param   string  $property  The Fallback Property
     *
     * @return  Microdata  Instance of $this
     *
     * @since   3.2
     */
    public function fallback($type, $property)
    {
        if (!$this->enabled) {
            return $this;
        }

        // Sanitize the $Type
        $this->fallbackType = static::sanitizeType($type);

        // If the given $Type isn't available, fallback to 'Thing' Type
        if (!static::isTypeAvailable($this->fallbackType)) {
            $this->fallbackType = 'Thing';
        }

        // Control if the $Property exist in the given $Type and setup it, otherwise leave it 'NULL'
        if (static::isPropertyInType($this->fallbackType, $property)) {
            $this->fallbackProperty = $property;
        } else {
            $this->fallbackProperty = null;
        }

        return $this;
    }

    /**
     * Return the current $fallbackType
     *
     * @return  string
     *
     * @since   3.2
     */
    public function getFallbackType()
    {
        return $this->fallbackType;
    }

    /**
     * Return the current $fallbackProperty
     *
     * @return  string
     *
     * @since   3.2
     */
    public function getFallbackProperty()
    {
        return $this->fallbackProperty;
    }

    /**
     * This function handles the display logic.
     * It checks if the Type, Property are available, if not check for a Fallback,
     * then reset all params for the next use and return the HTML.
     *
     * @param   string   $displayType  Optional, 'inline', available options ['inline'|'span'|'div'|meta]
     * @param   boolean  $emptyOutput  Return an empty string if the library output is disabled and there is a $content value
     *
     * @return  string
     *
     * @since   3.2
     */
    public function display($displayType = '', $emptyOutput = false)
    {
        // Initialize the HTML to output
        $html = ($this->content !== null && !$emptyOutput) ? $this->content : '';

        // Control if the library output is enabled, otherwise return the $content or an empty string
        if (!$this->enabled) {
            // Reset params
            $this->resetParams();

            return $html;
        }

        // If the $property is wrong for the current $Type check if a Fallback is available, otherwise return an empty HTML
        if ($this->property) {
            // Process and return the HTML the way the user expects to
            if ($displayType) {
                switch ($displayType) {
                    case 'span':
                        $html = static::htmlSpan($html, $this->property);
                        break;

                    case 'div':
                        $html = static::htmlDiv($html, $this->property);
                        break;

                    case 'meta':
                        $html = $this->machineContent ?? $html;
                        $html = static::htmlMeta($html, $this->property);
                        break;

                    default:
                        // Default $displayType = 'inline'
                        $html = static::htmlProperty($this->property);
                        break;
                }
            } else {
                /*
                 * Process and return the HTML in an automatic way,
                 * with the $Property expected Types and display everything in the right way,
                 * check if the $Property is 'normal', 'nested' or must be rendered in a metadata tag
                 */
                switch (static::getExpectedDisplayType($this->type, $this->property)) {
                    case 'nested':
                        // Retrieve the expected 'nested' Type of the $Property
                        $nestedType     = static::getExpectedTypes($this->type, $this->property);
                        $nestedProperty = '';

                        // If there is a Fallback Type then probably it could be the expectedType
                        if (\in_array($this->fallbackType, $nestedType)) {
                            $nestedType = $this->fallbackType;

                            if ($this->fallbackProperty) {
                                $nestedProperty = $this->fallbackProperty;
                            }
                        } else {
                            $nestedType = $nestedType[0];
                        }

                        // Check if a $content is available, otherwise fallback to an 'inline' display type
                        if ($this->content !== null) {
                            if ($nestedProperty) {
                                $html = static::htmlSpan(
                                    $this->content,
                                    $nestedProperty
                                );
                            }

                            $html = static::htmlSpan(
                                $html,
                                $this->property,
                                $nestedType,
                                true
                            );
                        } else {
                            $html = static::htmlProperty($this->property) . ' ' . static::htmlScope($nestedType);

                            if ($nestedProperty) {
                                $html .= ' ' . static::htmlProperty($nestedProperty);
                            }
                        }

                        break;

                    case 'meta':
                        // Check if a $content is available, otherwise fallback to an 'inline' display type
                        if ($this->content !== null) {
                            $html = $this->machineContent ?? $this->content;
                            $html = static::htmlMeta($html, $this->property) . $this->content;
                        } else {
                            $html = static::htmlProperty($this->property);
                        }

                        break;

                    default:
                        /*
                         * Default expected display type = 'normal'
                         * Check if a $content is available,
                         * otherwise fallback to an 'inline' display type
                         */
                        if ($this->content !== null) {
                            $html = static::htmlSpan($this->content, $this->property);
                        } else {
                            $html = static::htmlProperty($this->property);
                        }

                        break;
                }
            }
        } elseif ($this->fallbackProperty) {
            // Process and return the HTML the way the user expects to
            if ($displayType) {
                switch ($displayType) {
                    case 'span':
                        $html = static::htmlSpan($html, $this->fallbackProperty, $this->fallbackType);
                        break;

                    case 'div':
                        $html = static::htmlDiv($html, $this->fallbackProperty, $this->fallbackType);
                        break;

                    case 'meta':
                        $html = $this->machineContent ?? $html;
                        $html = static::htmlMeta($html, $this->fallbackProperty, $this->fallbackType);
                        break;

                    default:
                        // Default $displayType = 'inline'
                        $html = static::htmlScope($this->fallbackType) . ' ' . static::htmlProperty($this->fallbackProperty);
                        break;
                }
            } else {
                /*
                 * Process and return the HTML in an automatic way,
                 * with the $Property expected Types and display everything in the right way,
                 * check if the Property is 'nested' or must be rendered in a metadata tag
                 */
                switch (static::getExpectedDisplayType($this->fallbackType, $this->fallbackProperty)) {
                    case 'meta':
                        // Check if a $content is available, otherwise fallback to an 'inline' display Type
                        if ($this->content !== null) {
                            $html = $this->machineContent ?? $this->content;
                            $html = static::htmlMeta($html, $this->fallbackProperty, $this->fallbackType);
                        } else {
                            $html = static::htmlScope($this->fallbackType) . ' ' . static::htmlProperty($this->fallbackProperty);
                        }

                        break;

                    default:
                        /*
                         * Default expected display type = 'normal'
                         * Check if a $content is available,
                         * otherwise fallback to an 'inline' display Type
                         */
                        if ($this->content !== null) {
                            $html = static::htmlSpan($this->content, $this->fallbackProperty);
                            $html = static::htmlSpan($html, '', $this->fallbackType);
                        } else {
                            $html = static::htmlScope($this->fallbackType) . ' ' . static::htmlProperty($this->fallbackProperty);
                        }

                        break;
                }
            }
        } elseif (!$this->fallbackProperty && $this->fallbackType !== null) {
            $html = static::htmlScope($this->fallbackType);
        }

        // Reset params
        $this->resetParams();

        return $html;
    }

    /**
     * Return the HTML of the current Scope
     *
     * @return  string
     *
     * @since   3.2
     */
    public function displayScope()
    {
        // Control if the library output is enabled, otherwise return the $content or empty string
        if (!$this->enabled) {
            return '';
        }

        return static::htmlScope($this->type);
    }

    /**
     * Return the sanitized $Type
     *
     * @param   string  $type  The Type to sanitize
     *
     * @return  string
     *
     * @since   3.2
     */
    public static function sanitizeType($type)
    {
        return ucfirst(trim($type));
    }

    /**
     * Return the sanitized $Property
     *
     * @param   string  $property  The Property to sanitize
     *
     * @return  string
     *
     * @since   3.2
     */
    public static function sanitizeProperty($property)
    {
        return lcfirst(trim($property));
    }

    /**
     * Return an array with all available Types and Properties from the http://schema.org vocabulary
     *
     * @return  array
     *
     * @since   3.2
     */
    public static function getTypes()
    {
        static::loadTypes();

        return static::$types;
    }

    /**
     * Return an array with all available Types from the http://schema.org vocabulary
     *
     * @return  array
     *
     * @since   3.2
     */
    public static function getAvailableTypes()
    {
        static::loadTypes();

        return array_keys(static::$types);
    }

    /**
     * Return the expected Types of the given Property
     *
     * @param   string  $type      The Type to process
     * @param   string  $property  The Property to process
     *
     * @return  array
     *
     * @since   3.2
     */
    public static function getExpectedTypes($type, $property)
    {
        static::loadTypes();

        $tmp = static::$types[$type]['properties'];

        // Check if the $Property is in the $Type
        if (isset($tmp[$property])) {
            return $tmp[$property]['expectedTypes'];
        }

        // Check if the $Property is inherit
        $extendedType = static::$types[$type]['extends'];

        // Recursive
        if (!empty($extendedType)) {
            return static::getExpectedTypes($extendedType, $property);
        }

        return [];
    }

    /**
     * Return the expected display type: [normal|nested|meta]
     * In which way to display the Property:
     * normal -> itemprop="name"
     * nested -> itemprop="director" itemscope itemtype="https://schema.org/Person"
     * meta   -> `<meta itemprop="datePublished" content="1991-05-01">`
     *
     * @param   string  $type      The Type where to find the Property
     * @param   string  $property  The Property to process
     *
     * @return  string
     *
     * @since   3.2
     */
    protected static function getExpectedDisplayType($type, $property)
    {
        $expectedTypes = static::getExpectedTypes($type, $property);

        // Retrieve the first expected type
        $type = $expectedTypes[0];

        // Check if it's a 'meta' display
        if ($type === 'Date' || $type === 'DateTime' || $property === 'interactionCount') {
            return 'meta';
        }

        // Check if it's a 'normal' display
        if ($type === 'Text' || $type === 'URL' || $type === 'Boolean' || $type === 'Number') {
            return 'normal';
        }

        // Otherwise it's a 'nested' display
        return 'nested';
    }

    /**
     * Recursive function, control if the given Type has the given Property
     *
     * @param   string  $type      The Type where to check
     * @param   string  $property  The Property to check
     *
     * @return  boolean
     *
     * @since   3.2
     */
    public static function isPropertyInType($type, $property)
    {
        if (!static::isTypeAvailable($type)) {
            return false;
        }

        // Control if the $Property exists, and return 'true'
        if (\array_key_exists($property, static::$types[$type]['properties'])) {
            return true;
        }

        // Recursive: Check if the $Property is inherit
        $extendedType = static::$types[$type]['extends'];

        if (!empty($extendedType)) {
            return static::isPropertyInType($extendedType, $property);
        }

        return false;
    }

    /**
     * Control if the given Type class is available
     *
     * @param   string  $type  The Type to check
     *
     * @return  boolean
     *
     * @since   3.2
     */
    public static function isTypeAvailable($type)
    {
        static::loadTypes();

        return \array_key_exists($type, static::$types);
    }

    /**
     * Return Microdata semantics in a `<meta>` tag with content for machines.
     *
     * @param   string   $content   The machine content to display
     * @param   string   $property  The Property
     * @param   string   $scope     Optional, the Type scope to display
     * @param   boolean  $invert    Optional, default = false, invert the $scope with the $property
     *
     * @return  string
     *
     * @since   3.2
     */
    public static function htmlMeta($content, $property, $scope = '', $invert = false)
    {
        return static::htmlTag('meta', $content, $property, $scope, $invert);
    }

    /**
     * Return Microdata semantics in a `<span>` tag.
     *
     * @param   string   $content   The human content
     * @param   string   $property  Optional, the human content to display
     * @param   string   $scope     Optional, the Type scope to display
     * @param   boolean  $invert    Optional, default = false, invert the $scope with the $property
     *
     * @return  string
     *
     * @since   3.2
     */
    public static function htmlSpan($content, $property = '', $scope = '', $invert = false)
    {
        return static::htmlTag('span', $content, $property, $scope, $invert);
    }

    /**
     * Return Microdata semantics in a `<div>` tag.
     *
     * @param   string   $content   The human content
     * @param   string   $property  Optional, the human content to display
     * @param   string   $scope     Optional, the Type scope to display
     * @param   boolean  $invert    Optional, default = false, invert the $scope with the $property
     *
     * @return  string
     *
     * @since   3.2
     */
    public static function htmlDiv($content, $property = '', $scope = '', $invert = false)
    {
        return static::htmlTag('div', $content, $property, $scope, $invert);
    }

    /**
     * Return Microdata semantics in a specified tag.
     *
     * @param   string   $tag       The HTML tag
     * @param   string   $content   The human content
     * @param   string   $property  Optional, the human content to display
     * @param   string   $scope     Optional, the Type scope to display
     * @param   boolean  $invert    Optional, default = false, invert the $scope with the $property
     *
     * @return  string
     *
     * @since   3.3
     */
    public static function htmlTag($tag, $content, $property = '', $scope = '', $invert = false)
    {
        // Control if the $Property has already the 'itemprop' prefix
        if (!empty($property) && stripos($property, 'itemprop') !== 0) {
            $property = static::htmlProperty($property);
        }

        // Control if the $Scope have already the 'itemscope' prefix
        if (!empty($scope) && stripos($scope, 'itemscope') !== 0) {
            $scope = static::htmlScope($scope);
        }

        // Depending on the case, the $scope must precede the $property, or otherwise
        if ($invert) {
            $tmp = implode(' ', [$property, $scope]);
        } else {
            $tmp = implode(' ', [$scope, $property]);
        }

        $tmp = trim($tmp);
        $tmp = ($tmp) ? ' ' . $tmp : '';

        // Control if it is an empty element without a closing tag
        if ($tag === 'meta') {
            return "<meta$tmp content='$content'>";
        }

        return '<' . $tag . $tmp . '>' . $content . '</' . $tag . '>';
    }

    /**
     * Return the HTML Scope
     *
     * @param   string  $scope  The Scope to process
     *
     * @return  string
     *
     * @since   3.2
     */
    public static function htmlScope($scope)
    {
        return "itemscope itemtype='https://schema.org/" . static::sanitizeType($scope) . "'";
    }

    /**
     * Return the HTML Property
     *
     * @param   string  $property  The Property to process
     *
     * @return  string
     *
     * @since   3.2
     */
    public static function htmlProperty($property)
    {
        return "itemprop='$property'";
    }
}
