<?php

declare(strict_types=1);

/*
 * Copyright (c) 2023-2024 François Kooman <fkooman@tuxed.net>
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 */

namespace fkooman\Radius;

use fkooman\Radius\Exception\AttributeCollectionException;

class AttributeCollection
{
    // @see https://www.iana.org/assignments/radius-types/radius-types.xhtml
    private const TEXT_ATTRIBUTE_TYPES = [1, 11, 18, 19, 20, 22, 30, 31, 32, 34, 35, 39, 63, 74, 77, 78, 87, 88];
    private const IP_FOUR_ATTRIBUTE_TYPES = [4, 8, 9, 14, 23];
    private const INTEGER_ATTRIBUTE_TYPES = [5, 12, 16, 27, 28, 37, 38, 62];

    // in case for certain attributes, different length requirements hold than
    // >= 1, <= 253, we can specify them here
    private const ATTRIBUTE_LENGTH = [
        // [minLen, maxLen]
        2 => [16, 128], // User-Password
        80 => [16, 16], // Message-Authenticator
    ];

    private const ATTRIBUTE_TYPES = [
        // @see https://www.rfc-editor.org/rfc/rfc2865.html
        1 => 'User-Name',
        2 => 'User-Password',
        3 => 'CHAP-Password',
        4 => 'NAS-IP-Address',
        5 => 'NAS-Port',
        6 => 'Service-Type',
        7 => 'Framed-Protocol',
        8 => 'Framed-IP-Address',
        9 => 'Framed-IP-Netmask',
        10 => 'Framed-Routing',
        11 => 'Filter-Id',
        12 => 'Framed-MTU',
        13 => 'Framed-Compression',
        14 => 'Login-IP-Host',
        15 => 'Login-Service',
        16 => 'Login-TCP-Port',
        18 => 'Reply-Message',
        19 => 'Callback-Number',
        20 => 'Callback-Id',
        22 => 'Framed-Route',
        23 => 'Framed-IPX-Network',
        24 => 'State',
        25 => 'Class',
        26 => 'Vendor-Specific',
        27 => 'Session-Timeout',
        28 => 'Idle-Timeout',
        29 => 'Termination-Action',
        30 => 'Called-Station-Id',
        31 => 'Calling-Station-Id',
        32 => 'NAS-Identifier',
        33 => 'Proxy-State',
        34 => 'Login-LAT-Service',
        35 => 'Login-LAT-Node',
        36 => 'Login-LAT-Group',
        37 => 'Framed-AppleTalk-Link',
        38 => 'Framed-AppleTalk-Network',
        39 => 'Framed-AppleTalk-Zone',
        60 => 'CHAP-Challenge',
        61 => 'NAS-Port-Type',
        62 => 'Port-Limit',
        63 => 'Login-LAT-Port',
        // @see https://www.rfc-editor.org/rfc/rfc2869.html
        52 => 'Acct-Input-Gigawords',
        53 => 'Acct-Output-Gigawords',
        55 => 'Event-Timestamp',
        70 => 'ARAP-Password',
        71 => 'ARAP-Features',
        72 => 'ARAP-Zone-Access',
        73 => 'ARAP-Security',
        74 => 'ARAP-Security-Data',
        75 => 'Password-Retry',
        76 => 'Prompt',
        77 => 'Connect-Info',
        78 => 'Configuration-Token',
        79 => 'EAP-Message',
        80 => 'Message-Authenticator',
        84 => 'ARAP-Challenge-Response',
        85 => 'Acct-Interim-Interval',
        87 => 'NAS-Port-Id',
        88 => 'Framed-Pool',
    ];

    /** @var array<int|string,array<string>> */
    private array $attributeList = [];

    public function __toString(): string
    {
        $outStr = [];
        foreach ($this->attributeList as $attributeId => $attributeValues) {
            if (array_key_exists($attributeId, self::ATTRIBUTE_TYPES)) {
                $attributeId = self::ATTRIBUTE_TYPES[$attributeId];
            }
            $outStr[] = sprintf("%s:", (string) $attributeId);
            foreach ($attributeValues as $attributeValue) {
                $outStr[] = "\t" . self::attributeValueToText($attributeId, $attributeValue);
            }
        }

        return implode("\n", $outStr);
    }

    /**
     * Add the attribute with value. If an attribute with this identifier
     * already exists, another one is added, the existing one remains.
     *
     * @param int|string $attributeId
     */
    public function add($attributeId, string $attributeValue): void
    {
        // if empty value is provided, we MUST completely ignore it
        if (0 === strlen($attributeValue)) {
            return;
        }
        [$attributeId, $attributeValue] = self::verifyAttributeIdAndAttributeValue($attributeId, $attributeValue);
        if (!array_key_exists($attributeId, $this->attributeList)) {
            $this->attributeList[$attributeId] = [];
        }
        $this->attributeList[$attributeId][] = $attributeValue;
    }

    /**
     * Set the value of an attribute. Replace it with just this value if the
     * attribute is already set. If the attribute does not yet exist. Add it.
     *
     * @param int|string $attributeId
     */
    public function set($attributeId, string $attributeValue): void
    {
        // if empty value is provided, we MUST completely ignore it
        if (0 === strlen($attributeValue)) {
            return;
        }
        [$attributeId, $attributeValue] = self::verifyAttributeIdAndAttributeValue($attributeId, $attributeValue);
        if (array_key_exists($attributeId, $this->attributeList)) {
            $this->attributeList[$attributeId] = [$attributeValue];

            return;
        }
        $this->add($attributeId, $attributeValue);
    }

    /**
     * @param int|string $attributeId
     *
     * @return array<string>
     */
    public function get($attributeId): array
    {
        $attributeId = self::normalizeAttributeId($attributeId);
        if (!array_key_exists($attributeId, $this->attributeList)) {
            return [];
        }

        return $this->attributeList[$attributeId];
    }

    /**
     * @param int|string $attributeId
     */
    public function getOne($attributeId): ?string
    {
        $attributeValues = $this->get($attributeId);
        if (0 === count($attributeValues)) {
            return null;
        }
        if (1 === count($attributeValues)) {
            return $attributeValues[0];
        }

        throw new AttributeCollectionException(sprintf('there are %d values for "%s"', count($attributeValues), $attributeId));
    }

    /**
     * @param int|string $attributeId
     */
    public function requireOne($attributeId): string
    {
        if (null === $attributeValue = $this->getOne($attributeId)) {
            throw new AttributeCollectionException(sprintf('there are no values for "%s"', $attributeId));
        }

        return $attributeValue;
    }

    /**
     * Parse RADIUS attribute values including "Vendor-Specific" attributes.
     */
    public static function fromBytes(string $attributeData): self
    {
        $attributeCollection = new self();
        $attrOffset = 0;
        while ($attrOffset + 2 < strlen($attributeData)) {
            $attrType = ord(Utils::safeSubstr($attributeData, $attrOffset, 1));
            $attrLength = ord(Utils::safeSubstr($attributeData, $attrOffset + 1, 1));
            $attrValue = Utils::safeSubstr($attributeData, $attrOffset + 2, $attrLength - 2);
            if (26 === $attrType) {
                $vendorId = Utils::bytesToLong(Utils::safeSubstr($attrValue, 0, 4));
                $vendorType = ord(Utils::safeSubstr($attrValue, 4, 1));
                $attrType = sprintf('%d.%d', $vendorId, $vendorType);
                $vendorLength = ord(Utils::safeSubstr($attrValue, 5, 1));
                $attrValue = Utils::safeSubstr($attrValue, 6, $vendorLength - 2);
            }
            $attributeCollection->add($attrType, $attrValue);
            $attrOffset += $attrLength;
        }

        return $attributeCollection;
    }

    /**
     * Encode attributes to RADIUS structure. Also encode "Vendor-Specific"
     * attributes if they are set.
     */
    public function toBytes(): string
    {
        $outStr = '';
        foreach ($this->attributeList as $attributeId => $attributeValueList) {
            foreach ($attributeValueList as $attributeValue) {
                if (is_int($attributeId)) {
                    $outStr .= chr($attributeId) . chr(2 + strlen($attributeValue)) . $attributeValue;

                    continue;
                }
                // "Vendor-Specific"
                [$vendorId, $vendorType] = Utils::requireVendorAttributeId($attributeId);
                $attrData = Utils::longToBytes($vendorId) . chr($vendorType) . chr(2 + strlen($attributeValue)) . $attributeValue;
                $outStr .= chr(26) . chr(2 + strlen($attrData)) . $attrData;
            }
        }

        return $outStr;
    }

    /**
     * @param int|string $attributeId
     */
    private static function attributeValueToText($attributeId, string $attributeValue): string
    {
        if (in_array($attributeId, self::TEXT_ATTRIBUTE_TYPES, true)) {
            return $attributeValue;
        }
        if (in_array($attributeId, self::INTEGER_ATTRIBUTE_TYPES, true)) {
            return (string) Utils::bytesToLong($attributeValue);
        }
        if (in_array($attributeId, self::IP_FOUR_ATTRIBUTE_TYPES, true)) {
            return Utils::bytesToIpAddress($attributeValue);
        }

        // we do not know how to make it human readable, so just return it
        // hex encoded...
        return '0x' . Utils::hexEncode($attributeValue);
    }

    /**
     * @param int|string $attributeId
     *
     * @return array{0:int|string,1:string}
     */
    private static function verifyAttributeIdAndAttributeValue($attributeId, string $attributeValue): array
    {
        $attributeId = self::normalizeAttributeId($attributeId);
        [$minLen, $maxLen] = [1, 253];
        if (in_array($attributeId, self::IP_FOUR_ATTRIBUTE_TYPES, true)) {
            [$minLen, $maxLen] = [4, 4];
        }
        if (in_array($attributeId, self::INTEGER_ATTRIBUTE_TYPES, true)) {
            [$minLen, $maxLen] = [4, 4];
        }
        if (array_key_exists($attributeId, self::ATTRIBUTE_LENGTH)) {
            [$minLen, $maxLen] = self::ATTRIBUTE_LENGTH[$attributeId];
        }
        $attributeValue = Utils::requireLengthRange($attributeValue, $minLen, $maxLen);

        return [$attributeId, $attributeValue];
    }

    /**
     * Obtain a valid "Attribute Type" (int) or a VSA in n.m notation.
     *
     * @param int|string $attributeId
     *
     * @return string|int
     */
    private static function normalizeAttributeId($attributeId)
    {
        if (in_array($attributeId, self::ATTRIBUTE_TYPES, true)) {
            $attributeType = array_search($attributeId, self::ATTRIBUTE_TYPES, true);
            if (is_int($attributeType)) {
                return $attributeType;
            }
        }
        if (is_int($attributeId)) {
            return Utils::requireRange($attributeId, 1, 255);
        }

        [$vendorId, $vendorType] = Utils::requireVendorAttributeId($attributeId);

        return sprintf('%d.%d', $vendorId, $vendorType);
    }
}
