<?php

/*
 * This file is part of the Kimai time-tracking app.
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace App\Tests\API;

use App\DataFixtures\UserFixtures;
use App\Entity\User;
use App\Tests\Controller\ControllerBaseTest;
use PHPUnit\Framework\Constraint\IsType;
use Symfony\Component\DomCrawler\Crawler;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\HttpKernelBrowser;

/**
 * Adds some useful functions for writing API integration tests.
 */
abstract class APIControllerBaseTest extends ControllerBaseTest
{
    protected function getClientForAuthenticatedUser(string $role = User::ROLE_USER): HttpKernelBrowser
    {
        switch ($role) {
            case User::ROLE_SUPER_ADMIN:
                $client = self::createClient([], [
                    'HTTP_X_AUTH_USER' => UserFixtures::USERNAME_SUPER_ADMIN,
                    'HTTP_X_AUTH_TOKEN' => UserFixtures::DEFAULT_API_TOKEN,
                ]);
                break;

            case User::ROLE_ADMIN:
                $client = self::createClient([], [
                    'HTTP_X_AUTH_USER' => UserFixtures::USERNAME_ADMIN,
                    'HTTP_X_AUTH_TOKEN' => UserFixtures::DEFAULT_API_TOKEN,
                ]);
                break;

            case User::ROLE_TEAMLEAD:
                $client = self::createClient([], [
                    'HTTP_X_AUTH_USER' => UserFixtures::USERNAME_TEAMLEAD,
                    'HTTP_X_AUTH_TOKEN' => UserFixtures::DEFAULT_API_TOKEN,
                ]);
                break;

            case User::ROLE_USER:
                $client = self::createClient([], [
                    'HTTP_X_AUTH_USER' => UserFixtures::USERNAME_USER,
                    'HTTP_X_AUTH_TOKEN' => UserFixtures::DEFAULT_API_TOKEN,
                ]);
                break;

            default:
                throw new \Exception(sprintf('Unknown role "%s"', $role));
        }

        return $client;
    }

    protected function createUrl(string $url): string
    {
        return '/' . ltrim($url, '/');
    }

    protected function assertRequestIsSecured(HttpKernelBrowser $client, string $url, $method = 'GET')
    {
        $this->request($client, $url, $method);
        $this->assertResponseIsSecured($client->getResponse(), $url);
    }

    /**
     * @param Response $response
     * @param string $url
     */
    protected function assertResponseIsSecured(Response $response, string $url)
    {
        $data = ['message' => 'Authentication required, missing headers: X-AUTH-USER, X-AUTH-TOKEN'];

        self::assertEquals(
            $data,
            json_decode($response->getContent(), true),
            sprintf('The secure URL %s is not protected.', $url)
        );

        self::assertEquals(
            Response::HTTP_FORBIDDEN,
            $response->getStatusCode(),
            sprintf('The secure URL %s has the wrong status code %s.', $url, $response->getStatusCode())
        );
    }

    /**
     * @param string $role
     * @param string $url
     * @param string $method
     */
    protected function assertUrlIsSecuredForRole(string $role, string $url, string $method = 'GET')
    {
        $client = $this->getClientForAuthenticatedUser($role);
        $client->request($method, $this->createUrl($url));

        self::assertFalse(
            $client->getResponse()->isSuccessful(),
            sprintf('The secure URL %s is not protected for role %s', $url, $role)
        );

        $this->assertApiException($client->getResponse(), [
            'code' => 403,
            'message' => 'Access denied.'
        ]);
    }

    protected function request(HttpKernelBrowser $client, string $url, $method = 'GET', array $parameters = [], string $content = null): Crawler
    {
        $server = ['HTTP_CONTENT_TYPE' => 'application/json', 'CONTENT_TYPE' => 'application/json'];

        return $client->request($method, $this->createUrl($url), $parameters, [], $server, $content);
    }

    protected function assertEntityNotFound(string $role, string $url, string $method = 'GET', ?string $message = null)
    {
        $client = $this->getClientForAuthenticatedUser($role);
        $this->request($client, $url, $method);
        $this->assertApiException($client->getResponse(), [
            'code' => 404,
            'message' => $message ?? 'Not found'
        ]);
    }

    protected function assertNotFoundForDelete(HttpKernelBrowser $client, string $url)
    {
        $this->assertExceptionForMethod($client, $url, 'DELETE', [], [
            'code' => 404,
            'message' => 'Not found'
        ]);
    }

    protected function assertEntityNotFoundForDelete(string $role, string $url, ?string $message = null)
    {
        $this->assertExceptionForDeleteAction($role, $url, [], [
            'code' => 404,
            'message' => $message ?? 'Not found'
        ]);
    }

    protected function assertEntityNotFoundForPatch(string $role, string $url, array $data, ?string $message = null)
    {
        $this->assertExceptionForPatchAction($role, $url, $data, [
            'code' => 404,
            'message' => $message ?? 'Not found',
        ]);
    }

    protected function assertEntityNotFoundForPost(string $role, string $url, array $data, ?string $message = null)
    {
        $this->assertExceptionForPostAction($role, $url, $data, [
            'code' => 404,
            'message' => $message ?? 'Not found',
        ]);
    }

    protected function assertExceptionForDeleteAction(string $role, string $url, array $data, array $expectedErrors)
    {
        $this->assertExceptionForRole($role, $url, 'DELETE', $data, $expectedErrors);
    }

    protected function assertExceptionForPatchAction(string $role, string $url, array $data, array $expectedErrors)
    {
        $this->assertExceptionForRole($role, $url, 'PATCH', $data, $expectedErrors);
    }

    protected function assertExceptionForPostAction(string $role, string $url, array $data, array $expectedErrors)
    {
        $this->assertExceptionForRole($role, $url, 'POST', $data, $expectedErrors);
    }

    protected function assertExceptionForMethod(HttpKernelBrowser $client, string $url, string $method, array $data, array $expectedErrors)
    {
        $this->request($client, $url, $method, [], json_encode($data));
        $this->assertApiException($client->getResponse(), $expectedErrors);
    }

    protected function assertApiException(Response $response, array $expectedErrors)
    {
        self::assertFalse($response->isSuccessful());
        self::assertEquals($expectedErrors['code'], $response->getStatusCode());
        self::assertEquals($expectedErrors, json_decode($response->getContent(), true));
    }

    protected function assertExceptionForRole(string $role, string $url, string $method, array $data, array $expectedErrors)
    {
        $client = $this->getClientForAuthenticatedUser($role);
        $this->assertExceptionForMethod($client, $url, $method, $data, $expectedErrors);
    }

    protected function assertApi500Exception(Response $response, string $message)
    {
        $this->assertApiException($response, ['code' => 500, 'message' => $message]);
    }

    protected function assertApiAccessDenied(HttpKernelBrowser $client, string $url, string $message)
    {
        $this->request($client, $url);
        $this->assertApiResponseAccessDenied($client->getResponse(), $message);
    }

    protected function assertApiResponseAccessDenied(Response $response, string $message)
    {
        $this->assertApiException($response, [
            'code' => Response::HTTP_FORBIDDEN,
            'message' => $message
        ]);
    }

    /**
     * @param Response $response
     * @param array<int, string>|array<string, mixed> $failedFields
     * @param bool $extraFields test for the error "This form should not contain extra fields"
     */
    protected function assertApiCallValidationError(Response $response, array $failedFields, bool $extraFields = false)
    {
        self::assertFalse($response->isSuccessful());
        $result = json_decode($response->getContent(), true);

        self::assertArrayHasKey('errors', $result);

        if ($extraFields) {
            self::assertArrayHasKey('errors', $result['errors']);
            self::assertEquals($result['errors']['errors'][0], 'This form should not contain extra fields.');
        }

        self::assertArrayHasKey('children', $result['errors']);
        $data = $result['errors']['children'];

        $foundErrors = [];

        foreach ($failedFields as $key => $value) {
            $messages = [];
            $fieldName = $value;
            if (\is_string($key)) {
                $fieldName = $key;
                $messages = $value;
                if (!\is_array($messages)) {
                    $messages = [$value];
                }
            }

            while (stripos($fieldName, '.') !== false) {
                $parts = explode('.', $fieldName);
                $tmp = array_shift($parts);
                self::assertArrayHasKey($tmp, $data, sprintf('Could not find field "%s" in result', $tmp));
                $data = $data[$tmp];
                if (\count($data) === 1 && \array_key_exists('children', $data)) {
                    $data = $data['children'];
                }
                $fieldName = implode('.', $parts);
            }

            self::assertArrayHasKey($fieldName, $data, sprintf('Could not find validation error for field "%s" in list: %s', $fieldName, implode(', ', $failedFields)));
            self::assertArrayHasKey('errors', $data[$fieldName], sprintf('Field %s has no validation problem', $fieldName));
            foreach ($messages as $i => $message) {
                self::assertEquals($message, $data[$fieldName]['errors'][$i]);
            }
            if (\array_key_exists('errors', $data[$fieldName]) && \count($data[$fieldName]['errors']) > 0) {
                $foundErrors[$fieldName] = \count($data[$fieldName]['errors']);
            }
        }

        self::assertEquals(\count($failedFields), \count($foundErrors), 'Expected and actual validation error amount differs');
    }

    protected static function getExpectedResponseStructure(string $type): array
    {
        switch ($type) {
            case 'TagEntity':
                return [
                    'id' => 'int',
                    'name' => 'string',
                    'color' => '@string',
                ];

            // embedded meta data
            case 'UserPreference':
                return [
                    'name' => 'string',
                    'value' => '@string',
                ];

            case 'CustomerMeta':
            case 'ProjectMeta':
            case 'ActivityMeta':
            case 'TimesheetMeta':
                return [
                    'name' => 'string',
                    'value' => 'string',
                ];

            // if a user is embedded in other objects
            case 'User':
            // if a list of users is loaded
            case 'UserCollection':
                return [
                    'id' => 'int',
                    'username' => 'string',
                    'enabled' => 'bool',
                    'color' => '@string',
                    'alias' => '@string',
                    'accountNumber' => '@string',
                ];

            // if a user is loaded explicitly
            case 'UserEntity':
                return [
                    'id' => 'int',
                    'username' => 'string',
                    'enabled' => 'bool',
                    'alias' => '@string',
                    'title' => '@string',
                    'avatar' => '@string',
                    'color' => '@string',
                    'teams' => ['result' => 'array', 'type' => 'Team'],
                    'roles' => ['result' => 'array', 'type' => 'string'],
                    'language' => 'string',
                    'timezone' => 'string',
                    'accountNumber' => '@string',
                    'memberships' => ['result' => 'array', 'type' => 'TeamMembership'],
                    'preferences' => ['result' => 'array', 'type' => 'UserPreference'],
                ];

            // if a team is embedded
            case 'Team':
            // if a collection of teams is requested
            case 'TeamCollection':
                return [
                    'id' => 'int',
                    'name' => 'string',
                    'color' => '@string',
                ];

            // explicitly requested team
            case 'TeamEntity':
                return [
                    'id' => 'int',
                    'name' => 'string',
                    'color' => '@string',
                    'teamlead' => ['result' => 'object', 'type' => '@User'],
                    'members' => ['result' => 'array', 'type' => 'TeamMember'],
                    'users' => ['result' => 'array', 'type' => 'User'],
                    'customers' => ['result' => 'array', 'type' => '@Customer'],
                    'projects' => ['result' => 'array', 'type' => '@Project'],
                    'activities' => ['result' => 'array', 'type' => '@Activity'],
                ];

            // if the team is used inside the team context
            case 'TeamMember':
                return [
                    'user' => ['result' => 'object', 'type' => 'User'],
                    'teamlead' => 'bool',
                ];

            // if the team is used inside the user context
            case 'TeamMembership':
                return [
                    'team' => ['result' => 'object', 'type' => 'Team'],
                    'teamlead' => 'bool',
                ];

            // if a customer is embedded in other objects
            case 'Customer':
                return [
                    'id' => 'int',
                    'name' => 'string',
                    'visible' => 'bool',
                    'billable' => 'bool',
                    'color' => '@string',
                    'number' => '@string',
                    'comment' => '@string',
                ];

            // if a list of customers is loaded
            case 'CustomerCollection':
                return [
                    'id' => 'int',
                    'name' => 'string',
                    'visible' => 'boolean',
                    'billable' => 'bool',
                    'color' => '@string',
                    'number' => '@string',
                    'comment' => '@string',
                    'metaFields' => ['result' => 'array', 'type' => 'CustomerMeta'],
                    'teams' => ['result' => 'array', 'type' => 'Team'],
                    'currency' => 'string', // since 1.10
                ];

            // if a customer is loaded explicitly
            case 'CustomerEntity':
                return [
                    'id' => 'int',
                    'name' => 'string',
                    'visible' => 'bool',
                    'billable' => 'bool',
                    'color' => '@string',
                    'metaFields' => ['result' => 'array', 'type' => 'CustomerMeta'],
                    'teams' => ['result' => 'array', 'type' => 'Team'],
                    'homepage' => '@string',
                    'number' => '@string',
                    'comment' => '@string',
                    'company' => '@string',
                    'contact' => '@string',
                    'address' => '@string',
                    'country' => 'string',
                    'currency' => 'string',
                    'phone' => '@string',
                    'fax' => '@string',
                    'mobile' => '@string',
                    'email' => '@string',
                    'timezone' => 'string',
                    'budget' => 'float',
                    'timeBudget' => 'int',
                    'vatId' => '@string', // since 1.10
                    'budgetType' => '@string', // since 1.15
                ];

            // if a project is embedded
            case 'Project':
                return [
                    'id' => 'int',
                    'name' => 'string',
                    'visible' => 'bool',
                    'billable' => 'bool',
                    'color' => '@string',
                    'customer' => 'int',
                    'comment' => '@string',
                ];

            // if a project is embedded in an expanded collection (here timesheet)
            case 'ProjectExpanded':
                return [
                    'id' => 'int',
                    'name' => 'string',
                    'visible' => 'bool',
                    'billable' => 'bool',
                    'color' => '@string',
                    'customer' => ['result' => 'object', 'type' => 'Customer'],
                    'comment' => '@string',
                ];

            // if a collection of projects is loaded
            case 'ProjectCollection':
                return [
                    'id' => 'int',
                    'name' => 'string',
                    'visible' => 'bool',
                    'billable' => 'bool',
                    'customer' => 'int',
                    'color' => '@string',
                    'metaFields' => ['result' => 'array', 'type' => 'ProjectMeta'],
                    'parentTitle' => 'string',
                    'start' => '@datetime',
                    'end' => '@datetime',
                    'teams' => ['result' => 'array', 'type' => 'Team'],
                    'comment' => '@string',
                ];

            // if a project is explicitly loaded
            case 'ProjectEntity':
                return [
                    'id' => 'int',
                    'name' => 'string',
                    'visible' => 'bool',
                    'billable' => 'bool',
                    'customer' => 'int',
                    'color' => '@string',
                    'metaFields' => ['result' => 'array', 'type' => 'ProjectMeta'],
                    'parentTitle' => 'string',
                    'start' => '@datetime',
                    'end' => '@datetime',
                    'teams' => ['result' => 'array', 'type' => 'Team'],
                    'comment' => '@string',
                    'budget' => 'float',
                    'timeBudget' => 'int',
                    'orderNumber' => '@string',
                    'orderDate' => '@datetime',
                    'budgetType' => '@string', // since 1.15
                ];

            // embedded activities
            case 'Activity':
                return [
                    'id' => 'int',
                    'name' => 'string',
                    'visible' => 'bool',
                    'billable' => 'bool',
                    'project' => '@int',
                    'color' => '@string',
                    'comment' => '@string',
                ];

            case 'ActivityExpanded':
                return [
                    'id' => 'int',
                    'name' => 'string',
                    'visible' => 'bool',
                    'billable' => 'bool',
                    'project' => ['result' => 'object', 'type' => '@ProjectExpanded'],
                    'color' => '@string',
                    'comment' => '@string',
                ];

            // collection of activities
            case 'ActivityCollection':
                return [
                    'id' => 'int',
                    'name' => 'string',
                    'visible' => 'bool',
                    'billable' => 'bool',
                    'project' => '@int',
                    'color' => '@string',
                    'metaFields' => ['result' => 'array', 'type' => 'ProjectMeta'],
                    'parentTitle' => '@string',
                    'comment' => '@string',
                    'teams' => ['result' => 'array', 'type' => 'Team'],
                ];

            // if a activity is explicitly loaded
            case 'ActivityEntity':
                return [
                    'id' => 'int',
                    'name' => 'string',
                    'visible' => 'bool',
                    'billable' => 'bool',
                    'project' => '@int',
                    'color' => '@string',
                    'metaFields' => ['result' => 'array', 'type' => 'ProjectMeta'],
                    'parentTitle' => '@string',
                    'comment' => '@string',
                    'budget' => 'float',
                    'timeBudget' => 'int',
                    'teams' => ['result' => 'array', 'type' => 'Team'],
                    'budgetType' => '@string', // since 1.15
                ];

            case 'TimesheetEntity':
                return [
                    'id' => 'int',
                    'begin' => 'DateTime',
                    'end' => '@DateTime',
                    'duration' => '@int',
                    'description' => '@string',
                    'rate' => 'float',
                    'activity' => 'int',
                    'project' => 'int',
                    'tags' => ['result' => 'array', 'type' => 'string'],
                    'user' => 'int',
                    'metaFields' => ['result' => 'array', 'type' => 'TimesheetMeta'],
                    'internalRate' => 'float',
                    'exported' => 'bool',
                    'billable' => 'bool',
                    'fixedRate' => '@float',
                    'hourlyRate' => '@float',
                    // TODO new fields: billable, category
                ];

            case 'TimesheetEntityFull':
                return [
                    'id' => 'int',
                    'begin' => 'DateTime',
                    'end' => '@DateTime',
                    'duration' => '@int',
                    'description' => '@string',
                    'rate' => 'float',
                    'activity' => ['result' => 'object', 'type' => 'ActivityExpanded'],
                    'project' => ['result' => 'object', 'type' => 'ProjectExpanded'],
                    'tags' => ['result' => 'array', 'type' => 'string'],
                    'user' => 'int',
                    'metaFields' => ['result' => 'array', 'type' => 'TimesheetMeta'],
                    'internalRate' => 'float',
                    'exported' => 'bool',
                    'billable' => 'bool',
                    'fixedRate' => '@float',
                    'hourlyRate' => '@float',
                    // TODO new fields: billable, category
                ];

            case 'TimesheetCollection':
                return [
                    'id' => 'int',
                    'begin' => 'DateTime',
                    'end' => '@DateTime',
                    'duration' => '@int',
                    'description' => '@string',
                    'rate' => 'float',
                    'activity' => 'int',
                    'project' => 'int',
                    'tags' => ['result' => 'array', 'type' => 'string'],
                    'user' => 'int',
                    'metaFields' => ['result' => 'array', 'type' => 'TimesheetMeta'],
                    'internalRate' => 'float',
                    'exported' => 'bool',
                    'billable' => 'bool',
                ];

            case 'TimesheetCollectionFull':
                return [
                    'id' => 'int',
                    'begin' => 'DateTime',
                    'end' => '@DateTime',
                    'duration' => '@int',
                    'description' => '@string',
                    'rate' => 'float',
                    'activity' => ['result' => 'object', 'type' => 'Activity'],
                    'project' => ['result' => 'object', 'type' => 'ProjectExpanded'],
                    'tags' => ['result' => 'array', 'type' => 'string'],
                    'user' => 'int',
                    'metaFields' => ['result' => 'array', 'type' => 'TimesheetMeta'],
                    'internalRate' => 'float',
                    'exported' => 'bool',
                    'billable' => 'bool',
                ];

            default:
                throw new \Exception(sprintf('Unknown API response type: %s', $type));
        }
    }

    /**
     * The $type is either one of the types configured in config/packages/nelmio_api_doc.yaml or the class name.
     *
     * @param string $type
     * @param array $result
     * @throws \Exception
     */
    protected function assertApiResponseTypeStructure(string $type, array $result)
    {
        $expected = self::getExpectedResponseStructure($type);
        $expectedKeys = array_keys($expected);

        $actual = array_keys($result);
        sort($actual);
        sort($expectedKeys);

        self::assertEquals($expectedKeys, $actual, sprintf('Structure for API response type "%s" does not match', $type));

        self::assertEquals(
            \count($actual),
            \count($expectedKeys),
            sprintf('Mismatch between expected and result keys for API response type "%s". Expected %s keys but found %s.', $type, \count($expected), \count($actual))
        );

        foreach ($expected as $key => $value) {
            if (\is_array($value)) {
                switch ($value['result']) {
                    case 'array':
                        foreach ($result[$key] as $subResult) {
                            if ($value['type'] === 'string') {
                                self::assertIsString($subResult);
                            } else {
                                self::assertIsArray($subResult);

                                if ($value['type'][0] === '@') {
                                    if (empty($result[$key])) {
                                        continue;
                                    }
                                    $value['type'] = substr($value['type'], 1);
                                }

                                self::assertApiResponseTypeStructure($value['type'], $subResult);
                            }
                        }
                        break;

                    case 'object':
                        if ($value['type'][0] === '@') {
                            if (empty($result[$key])) {
                                break;
                            }
                            $value['type'] = substr($value['type'], 1);
                        }

                        self::assertIsArray($result[$key], sprintf('Key "%s" in type "%s" is not an array', $key, $type));

                        self::assertApiResponseTypeStructure($value['type'], $result[$key]);
                        break;

                    default:
                        throw new \Exception(sprintf('Invalid result type "%s" for subresource given', $value['result']));
                }

                continue;
            }

            if ($value[0] === '@') {
                if (\is_null($result[$key])) {
                    continue;
                }
                $value = substr($value, 1);
            }

            if (strtolower($value) === 'datetime') {
                // TODO
                $value = 'string';
            }

            static::assertThat(
                $result[$key],
                new IsType($value),
                sprintf('Found type mismatch in structure for API response type %s. Expected type "%s" for key "%s".', $type, $value, $key)
            );
        }
    }
}
