<?php

/**
 * Trait for issuing warnings on deprecated access.
 *
 * Adapted from https://github.com/wikimedia/mediawiki/blob/4aedefdbfd193f323097354bf581de1c93f02715/includes/debug/DeprecationHelper.php
 *
 */

namespace dokuwiki\Debug;

/**
 * Use this trait in classes which have properties for which public access
 * is deprecated. Set the list of properties in $deprecatedPublicProperties
 * and make the properties non-public. The trait will preserve public access
 * but issue deprecation warnings when it is needed.
 *
 * Example usage:
 *     class Foo {
 *         use DeprecationHelper;
 *         protected $bar;
 *         public function __construct() {
 *             $this->deprecatePublicProperty( 'bar', '1.21', __CLASS__ );
 *         }
 *     }
 *
 *     $foo = new Foo;
 *     $foo->bar; // works but logs a warning
 *
 * Cannot be used with classes that have their own __get/__set methods.
 *
 */
trait PropertyDeprecationHelper
{
    /**
     * List of deprecated properties, in <property name> => <class> format
     * where <class> is the the name of the class defining the property
     *
     * E.g. [ '_event' => '\dokuwiki\Cache\Cache' ]
     * @var string[]
     */
    protected $deprecatedPublicProperties = [];

    /**
     * Mark a property as deprecated. Only use this for properties that used to be public and only
     *   call it in the constructor.
     *
     * @param string $property The name of the property.
     * @param null $class name of the class defining the property
     * @see DebugHelper::dbgDeprecatedProperty
     */
    protected function deprecatePublicProperty(
        $property,
        $class = null
    ) {
        $this->deprecatedPublicProperties[$property] = $class ?: $this::class;
    }

    public function __get($name)
    {
        if (isset($this->deprecatedPublicProperties[$name])) {
            $class = $this->deprecatedPublicProperties[$name];
            DebugHelper::dbgDeprecatedProperty($class, $name);
            return $this->$name;
        }

        $qualifiedName = self::class . '::$' . $name;
        if ($this->deprecationHelperGetPropertyOwner($name)) {
            // Someone tried to access a normal non-public property. Try to behave like PHP would.
            throw new \RuntimeException("Cannot access non-public property $qualifiedName");
        } else {
            // Non-existing property. Try to behave like PHP would.
            trigger_error("Undefined property: $qualifiedName", E_USER_NOTICE);
        }
        return null;
    }

    public function __set($name, $value)
    {
        if (isset($this->deprecatedPublicProperties[$name])) {
            $class = $this->deprecatedPublicProperties[$name];
            DebugHelper::dbgDeprecatedProperty($class, $name);
            $this->$name = $value;
            return;
        }

        $qualifiedName = self::class . '::$' . $name;
        if ($this->deprecationHelperGetPropertyOwner($name)) {
            // Someone tried to access a normal non-public property. Try to behave like PHP would.
            throw new \RuntimeException("Cannot access non-public property $qualifiedName");
        } else {
            // Non-existing property. Try to behave like PHP would.
            $this->$name = $value;
        }
    }

    /**
     * Like property_exists but also check for non-visible private properties and returns which
     * class in the inheritance chain declared the property.
     * @param string $property
     * @return string|bool Best guess for the class in which the property is defined.
     */
    private function deprecationHelperGetPropertyOwner($property)
    {
        // Easy branch: check for protected property / private property of the current class.
        if (property_exists($this, $property)) {
            // The class name is not necessarily correct here but getting the correct class
            // name would be expensive, this will work most of the time and getting it
            // wrong is not a big deal.
            return self::class;
        }
        // property_exists() returns false when the property does exist but is private (and not
        // defined by the current class, for some value of "current" that differs slightly
        // between engines).
        // Since PHP triggers an error on public access of non-public properties but happily
        // allows public access to undefined properties, we need to detect this case as well.
        // Reflection is slow so use array cast hack to check for that:
        $obfuscatedProps = array_keys((array)$this);
        $obfuscatedPropTail = "\0$property";
        foreach ($obfuscatedProps as $obfuscatedProp) {
            // private props are in the form \0<classname>\0<propname>
            if (str_contains(substr($obfuscatedProp, 1), $obfuscatedPropTail)) {
                $classname = substr($obfuscatedProp, 1, -strlen($obfuscatedPropTail));
                if ($classname === '*') {
                    // sanity; this shouldn't be possible as protected properties were handled earlier
                    $classname = self::class;
                }
                return $classname;
            }
        }
        return false;
    }
}
