<?php

namespace Tuf\Metadata;

use Symfony\Component\Validator\Constraints\All;
use Symfony\Component\Validator\Constraints\Callback;
use Symfony\Component\Validator\Constraints\Collection;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
use Symfony\Component\Validator\Constraints\GreaterThanOrEqual;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\Optional;
use Symfony\Component\Validator\Constraints\Required;
use Symfony\Component\Validator\Constraints\Type;
use Tuf\DelegatedRole;
use Tuf\Exception\NotFoundException;
use Tuf\Key;

class TargetsMetadata extends MetadataBase
{

    /**
     * {@inheritdoc}
     */
    public const TYPE = 'targets';

    /**
     * The role name if different from the type.
     *
     * @var string
     */
    private ?string $role;

    /**
     * {@inheritdoc}
     *
     * @param string|null $roleName
     *   The role name if not the same as the type.
     */
    public static function createFromJson(string $json, ?string $roleName = null): static
    {
        $newMetadata = parent::createFromJson($json);
        $newMetadata->role = $roleName;
        return $newMetadata;
    }

    /**
     * Validates that delegated role names are unique.
     *
     * @todo Use Symfony's Unique constraint for this when at least Symfony
     *   6.1 is required in https://github.com/php-tuf/php-tuf/issues/317.
     *
     * @param mixed $delegations
     *   The value to be validated.
     * @param \Symfony\Component\Validator\Context\ExecutionContextInterface $context
     *   The validation context.
     */
    public static function validateDelegatedRoles(mixed $delegations, ExecutionContextInterface $context): void
    {
        if (!is_array($delegations)) {
            return;
        }
        $names = array_column($delegations['roles'] ?? [], 'name');
        if ($names !== array_unique($names)) {
            $context->addViolation('Delegated role names must be unique.');
        }
    }

    /**
     * Returns a canonical JSON representation of this metadata object.
     *
     * @return string
     *   The canonical JSON representation of this object.
     */
    public function toCanonicalJson(): string
    {
        $metadata = $this->getSigned();

        // Apply sorting
        self::sortKeys($metadata);

        foreach ($metadata['targets'] as $path => $target) {
            // Custom target info should always encode to an object, even if
            // it's empty.
            if (array_key_exists('custom', $target)) {
                $metadata['targets'][$path]['custom'] = (object) $target['custom'];
            }
        }

        // Ensure that these will encode as objects even if they're empty.
        $metadata['targets'] = (object) $metadata['targets'];
        if (array_key_exists('delegations', $metadata)) {
            $metadata['delegations']['keys'] = (object) $metadata['delegations']['keys'];
        }

        return static::encodeJson($metadata);
    }

    /**
     * {@inheritdoc}
     */
    protected static function getSignedCollectionOptions(): array
    {
        $options = parent::getSignedCollectionOptions();
        $options['fields']['delegations'] = new Optional([
            new Collection([
                'keys' => new Required([
                    new Type('array'),
                    new All([
                        static::getKeyConstraints(),
                    ]),
                ]),
                'roles' => new All([
                    new Type('array'),
                    new Collection([
                        'fields' => [
                            'name' => [
                                new NotBlank(),
                                new Type('string'),
                            ],
                            'paths' => new Optional([
                                new Type('array'),
                                new All([
                                    new Type('string'),
                                    new NotBlank(),
                                ]),
                            ]),
                            'path_hash_prefixes' => new Optional([
                                new Type('array'),
                                new All([
                                    new Type('string'),
                                    new NotBlank(),
                                ]),
                            ]),
                            'terminating' => [
                                new Type('boolean'),
                            ],
                        ] + static::getKeyidsConstraints() + static::getThresholdConstraints(),
                    ]),
                ]),
            ]),
            new Callback([static::class, 'validateDelegatedRoles']),
        ]);
        $options['fields']['targets'] = new Required([
            new All([
                new Collection([
                    'length' => [
                        new Type('integer'),
                        new GreaterThanOrEqual(1),
                    ],
                    'custom' => new Optional([
                        new Type('array'),
                    ]),
                ] + static::getHashesConstraints()),
            ]),

        ]);
        return $options;
    }

    /**
     * Returns the length, in bytes, of a specific target.
     *
     * @param string $target
     *   The target path.
     *
     * @return integer
     *   The length (size) of the target, in bytes.
     */
    public function getLength(string $target): int
    {
        return $this->getInfo($target)['length'];
    }

    /**
     * {@inheritdoc}
     */
    public function getRole(): string
    {
        return $this->role ?? $this->getType();
    }

    /**
     * Returns the known hashes for a specific target.
     *
     * @param string $target
     *   The target path.
     *
     * @return array
     *   The known hashes for the object. The keys are the hash algorithm (e.g.
     *   'sha256') and the values are the hash digest.
     */
    public function getHashes(string $target): array
    {
        return $this->getInfo($target)['hashes'];
    }

    /**
     * Determines if a target is specified in the current metadata.
     *
     * @param string $target
     *   The target path.
     *
     * @return bool
     *   True if the target is specified, or false otherwise.
     */
    public function hasTarget(string $target): bool
    {
        try {
            $this->getInfo($target);
            return true;
        } catch (NotFoundException $exception) {
            return false;
        }
    }

    /**
     * Gets info about a specific target.
     *
     * @param string $target
     *   The target path.
     *
     * @return array
     *   The target's info.
     *
     * @throws \Tuf\Exception\NotFoundException
     *   Thrown if the target is not mentioned in this metadata.
     */
    protected function getInfo(string $target): array
    {
        $signed = $this->getSigned();
        if (isset($signed['targets'][$target])) {
            return $signed['targets'][$target];
        }
        throw new NotFoundException($target, 'Target');
    }

    /**
     * Gets the delegated keys if any.
     *
     * @return \Tuf\Key[]
     *   The delegated keys.
     */
    public function getDelegatedKeys(): array
    {
        $keys = [];
        foreach ($this->getSigned()['delegations']['keys'] ?? [] as $keyId => $keyInfo) {
            $keys[$keyId] = Key::createFromMetadata($keyInfo);
        }
        return $keys;
    }

    /**
     * Gets the delegated roles if any.
     *
     * @return \Tuf\DelegatedRole[]
     *   The delegated roles.
     */
    public function getDelegatedRoles(): array
    {
        $roles = [];
        foreach ($this->getSigned()['delegations']['roles'] ?? [] as $roleInfo) {
            $role = DelegatedRole::createFromMetadata($roleInfo);
            $roles[$role->getName()] = $role;
        }
        return $roles;
    }
}
