<?php

/**
 * @package    Grav\Common\User
 *
 * @copyright  Copyright (c) 2015 - 2026 Trilby Media, LLC. All rights reserved.
 * @license    MIT License; see LICENSE file for details.
 */

namespace Grav\Common\User\DataUser;

use Grav\Common\Data\Blueprint;
use Grav\Common\Data\Blueprints;
use Grav\Common\Data\Data;
use Grav\Common\File\CompiledYamlFile;
use Grav\Common\Grav;
use Grav\Common\Media\Interfaces\MediaCollectionInterface;
use Grav\Common\Page\Media;
use Grav\Common\Page\Medium\Medium;
use Grav\Common\Page\Medium\MediumFactory;
use Grav\Common\User\Authentication;
use Grav\Common\User\Interfaces\UserInterface;
use Grav\Common\User\Traits\UserTrait;
use Grav\Common\Utils;
use Grav\Framework\Flex\Flex;
use RocketTheme\Toolbox\File\File;
use RocketTheme\Toolbox\File\FileInterface;
use function is_array;

/**
 * Class User
 * @package Grav\Common\User\DataUser
 */
class User extends Data implements UserInterface
{
    use UserTrait;

    /** @var MediaCollectionInterface */
    protected $_media;
    /** @var array|null Content of the account file this object was built from, null if unknown (e.g. restored from the session). */
    protected $_original;

    /**
     * User constructor.
     * @param array $items
     * @param Blueprint|null $blueprints
     */
    public function __construct(array $items = [], $blueprints = null)
    {
        // User can only be authenticated via login.
        unset($items['authenticated'], $items['authorized']);

        // Always set blueprints.
        if (null === $blueprints) {
            $blueprints = (new Blueprints)->get('user/account');
        }

        parent::__construct($items, $blueprints);
    }

    /**
     * @param string $offset
     * @return bool
     */
    #[\ReturnTypeWillChange]
    public function offsetExists($offset)
    {
        $value = parent::offsetExists($offset);

        // Handle special case where user was logged in before 'authorized' was added to the user object.
        if (false === $value && $offset === 'authorized') {
            $value = $this->offsetExists('authenticated');
        }

        return $value;
    }

    /**
     * @param string $offset
     * @return mixed
     */
    #[\ReturnTypeWillChange]
    public function offsetGet($offset)
    {
        $value = parent::offsetGet($offset);

        // Handle special case where user was logged in before 'authorized' was added to the user object.
        if (null === $value && $offset === 'authorized') {
            $value = $this->offsetGet('authenticated');
            $this->offsetSet($offset, $value);
        }

        return $value;
    }

    /**
     * @return bool
     */
    public function isValid(): bool
    {
        return $this->items !== null;
    }

    /**
     * Update object with data
     *
     * @param array $data
     * @param array $files
     * @return $this
     */
    public function update(array $data, array $files = [])
    {
        // Note: $this->merge() would cause infinite loop as it calls this method.
        parent::merge($data);

        return $this;
    }

    /**
     * Set or get the account file.
     *
     * @param FileInterface|null $storage Optionally enter a new storage.
     * @return FileInterface|null
     */
    public function file(?FileInterface $storage = null)
    {
        if ($storage) {
            // The file object keeps what it has read: this is the account the object was built from.
            $this->_original = $storage->filename() ? (array)$storage->content() : [];
        }

        return parent::file($storage);
    }

    /**
     * Save user
     *
     * @return void
     * @throws \RuntimeException
     */
    public function save()
    {
        /** @var CompiledYamlFile|null $file */
        $file = $this->file();
        if (!$file || !$file->filename()) {
            user_error(self::class . ': calling \$user = new ' . self::class . "() is deprecated since Grav 1.6, use \$grav['accounts']->load(\$username) or \$grav['accounts']->load('') instead", E_USER_DEPRECATED);
        }

        if ($file) {
            $username = $this->filterUsername((string)$this->get('username'));

            // Validate username to prevent path traversal attacks
            if (!self::isValidUsername($username)) {
                throw new \RuntimeException('Invalid username: contains invalid characters or sequences');
            }

            if (!$file->filename()) {
                $locator = Grav::instance()['locator'];

                // Check if a user with this username already exists (prevent overwriting)
                $existingFile = $locator->findResource('account://' . $username . YAML_EXT);
                if ($existingFile) {
                    throw new \RuntimeException('User account with this username already exists');
                }

                $file->filename($locator->findResource('account://' . $username . YAML_EXT, true, true));
            }

            // if plain text password, hash it and remove plain text
            $password = $this->get('password') ?? $this->get('password1');
            if (null !== $password && '' !== $password) {
                $password2 = $this->get('password2');
                if (!\is_string($password) || ($password2 && $password !== $password2)) {
                    throw new \RuntimeException('Passwords did not match.');
                }

                $this->set('hashed_password', Authentication::create($password));
            }
            $this->undef('password');
            $this->undef('password1');
            $this->undef('password2');

            $data = $this->items;
            if ($username === $data['username']) {
                unset($data['username']);
            }
            unset($data['authenticated'], $data['authorized']);

            // $data is the whole account as this object knows it, not only the fields which were changed.
            // If somebody else has saved or removed the account since this object was loaded, writing it
            // would undo their change: an account which was just disabled, demoted or deleted would come
            // back. Hold a lock shared by all writers of this account, make sure the file still has the
            // content the object was built from, and only then write.
            $lock = static::lockAccountFile($file->filename());
            try {
                if (null !== $this->_original) {
                    clearstatcache(true, $file->filename());
                    $file->load();
                    if ((array)$file->content() !== $this->_original) {
                        throw new \RuntimeException('User account has been changed since it was loaded', 409);
                    }
                }

                $file->save($data);

                $this->_original = (array)$file->content();
            } finally {
                $lock->free();
            }

            // We need to signal Flex Users about the change.
            /** @var Flex|null $flex */
            $flex = Grav::instance()['flex'] ?? null;
            $users = $flex ? $flex->getDirectory('user-accounts') : null;
            if (null !== $users) {
                $users->clearCache();
            }
        }
    }

    /**
     * Take the lock shared by everything which writes or removes the account file.
     *
     * @param string $filename
     * @return File  The lock, release it with free().
     * @internal
     */
    public static function lockAccountFile(string $filename): File
    {
        $lock = File::instance(CACHE_DIR . 'compiled/files/' . md5($filename) . '.lock');
        try {
            $lock->lock();
        } catch (\RuntimeException $e) {
            // Carry on without the lock if it cannot be created, like CompiledFile::save() does.
        }

        return $lock;
    }

    /**
     * @return MediaCollectionInterface|Media
     */
    public function getMedia()
    {
        if (null === $this->_media) {
            // Media object should only contain avatar, nothing else.
            $media = new Media($this->getMediaFolder() ?? '', $this->getMediaOrder(), false);

            $path = $this->getAvatarFile();
            if ($path && is_file($path)) {
                $medium = MediumFactory::fromFile($path);
                if ($medium) {
                    $media->add(Utils::basename($path), $medium);
                }
            }

            $this->_media = $media;
        }

        return $this->_media;
    }

    /**
     * @return string
     */
    public function getMediaFolder()
    {
        return $this->blueprints()->fields()['avatar']['destination'] ?? 'account://avatars';
    }

    /**
     * @return array
     */
    public function getMediaOrder()
    {
        return [];
    }

    /**
     * Serialize user.
     *
     * @return string[]
     */
    public function __sleep()
    {
        return [
            'items',
            'storage',
            '_original'
        ];
    }

    /**
     * Unserialize user.
     */
    public function __wakeup()
    {
        $this->gettersVariable = 'items';
        $this->nestedSeparator = '.';

        if (null === $this->items) {
            $this->items = [];
        }

        // Always set blueprints.
        if (null === $this->blueprints) {
            $this->blueprints = (new Blueprints)->get('user/account');
        }
    }

    /**
     * Merge two configurations together.
     *
     * @param array $data
     * @return $this
     * @deprecated 1.6 Use `->update($data)` instead (same but with data validation & filtering, file upload support).
     */
    public function merge(array $data)
    {
        user_error(self::class . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use ->update($data) method instead', E_USER_DEPRECATED);

        return $this->update($data);
    }

    /**
     * Return media object for the User's avatar.
     *
     * @return Medium|null
     * @deprecated 1.6 Use ->getAvatarImage() method instead.
     */
    public function getAvatarMedia()
    {
        user_error(self::class . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use getAvatarImage() method instead', E_USER_DEPRECATED);

        return $this->getAvatarImage();
    }

    /**
     * Return the User's avatar URL
     *
     * @return string
     * @deprecated 1.6 Use ->getAvatarUrl() method instead.
     */
    public function avatarUrl()
    {
        user_error(self::class . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use getAvatarUrl() method instead', E_USER_DEPRECATED);

        return $this->getAvatarUrl();
    }

    /**
     * Checks user authorization to the action.
     * Ensures backwards compatibility
     *
     * @param  string $action
     * @return bool
     * @deprecated 1.5 Use ->authorize() method instead.
     */
    public function authorise($action)
    {
        user_error(self::class . '::' . __FUNCTION__ . '() is deprecated since Grav 1.5, use authorize() method instead', E_USER_DEPRECATED);

        return $this->authorize($action) ?? false;
    }

    /**
     * Implements Countable interface.
     *
     * @return int
     * @deprecated 1.6 Method makes no sense for user account.
     */
    #[\ReturnTypeWillChange]
    public function count()
    {
        user_error(self::class . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6', E_USER_DEPRECATED);

        return parent::count();
    }

    /**
     * {@inheritdoc}
     * Override to filter out sensitive fields like password hashes
     */
    public function jsonSerialize(): array
    {
        $items = parent::jsonSerialize();

        // Security: Remove sensitive fields that should never be exposed to frontend
        return array_diff_key($items, array_flip(static::SECRET_FIELDS));
    }

    /**
     * {@inheritdoc}
     * Override to filter out sensitive fields like password hashes
     */
    public function toArray()
    {
        return $this->jsonSerialize();
    }

    /**
     * @param string $username
     * @return string
     */
    protected function filterUsername(string $username): string
    {
        return mb_strtolower($username);
    }

    /**
     * Validates a username to prevent path traversal and other attacks.
     *
     * @param string $username
     * @return bool
     */
    public static function isValidUsername(string $username): bool
    {
        // Username must not be empty
        if (!$username) {
            return false;
        }

        // Username must not contain filesystem-dangerous characters: \ / ? * : ; { } or newlines
        if (!preg_match('/^[^\\/?*:;{}\\\\\\n]+$/u', $username)) {
            return false;
        }

        // Username must not contain path traversal sequences (..)
        if (str_contains($username, '..')) {
            return false;
        }

        // Username must not start with a dot (hidden files)
        if (str_starts_with($username, '.')) {
            return false;
        }

        return true;
    }

    /**
     * @return string|null
     */
    protected function getAvatarFile(): ?string
    {
        $avatars = $this->get('avatar');
        if (is_array($avatars) && $avatars) {
            $avatar = array_shift($avatars);
            return $avatar['path'] ?? null;
        }

        return null;
    }
}
