<?php

namespace App\Services\FlowTester;

/**
 * 응답 검증기
 *
 * HTTP 응답을 기대값(expect)과 비교하여 검증합니다.
 *
 * 지원 연산자:
 * - 직접 값: "$.success": true
 * - @exists: 존재 체크
 * - @isNumber: 숫자 타입 체크
 * - @isString: 문자열 타입 체크
 * - @isArray: 배열 타입 체크
 * - @isObject: 객체(연관 배열) 타입 체크
 * - @isBoolean: 불리언 타입 체크
 * - @minLength:N: 최소 길이 (배열/문자열)
 * - @maxLength:N: 최대 길이 (배열/문자열)
 * - @regex:pattern: 정규식 매칭
 * - @gt:N: N보다 큼
 * - @gte:N: N 이상
 * - @lt:N: N보다 작음
 * - @lte:N: N 이하
 */
class ResponseValidator
{
    /**
     * 응답 검증
     *
     * @param  array  $response  HTTP 응답 ['status' => int, 'body' => array]
     * @param  array  $expect  기대값 정의
     * @return array ['success' => bool, 'errors' => array]
     */
    public function validate(array $response, array $expect): array
    {
        $errors = [];

        // 상태 코드 검증
        if (isset($expect['status'])) {
            $allowedStatus = (array) $expect['status'];
            $actualStatus = $response['status'] ?? 0;

            if (! in_array($actualStatus, $allowedStatus)) {
                $errors[] = sprintf(
                    'Expected status %s, got %d',
                    $this->formatList($allowedStatus),
                    $actualStatus
                );
            }
        }

        // JSONPath 검증
        if (isset($expect['jsonPath'])) {
            foreach ($expect['jsonPath'] as $path => $expected) {
                $actual = $this->getValueByPath($response['body'] ?? [], $path);
                $pathError = $this->validateValue($actual, $expected, $path);
                if ($pathError) {
                    $errors[] = $pathError;
                }
            }
        }

        return [
            'success' => empty($errors),
            'errors' => $errors,
        ];
    }

    /**
     * JSONPath로 값 추출
     *
     * @param  array  $data  데이터
     * @param  string  $path  경로 ($.data.id 형식)
     */
    private function getValueByPath(array $data, string $path): mixed
    {
        // $. 접두사 제거
        $path = ltrim($path, '$.');

        if (empty($path)) {
            return $data;
        }

        // JSONPath 배열 인덱스 표기법 [n]을 Laravel dot notation .n으로 변환
        // 예: data.data[0].id → data.data.0.id
        $path = preg_replace('/\[(\d+)\]/', '.$1', $path);

        return data_get($data, $path);
    }

    /**
     * 개별 값 검증
     */
    private function validateValue(mixed $actual, mixed $expected, string $path): ?string
    {
        // 직접 값 비교 (연산자가 아닌 경우)
        if (! is_string($expected) || ! str_starts_with($expected, '@')) {
            // 숫자 비교: 둘 다 숫자(또는 숫자 문자열)인 경우 타입 무관하게 비교
            // 예: "2" == 2, "123" == 123
            if ($this->areNumericEqual($actual, $expected)) {
                return null;
            }

            if ($actual !== $expected) {
                return sprintf(
                    'Path %s: expected %s, got %s',
                    $path,
                    json_encode($expected, JSON_UNESCAPED_UNICODE),
                    json_encode($actual, JSON_UNESCAPED_UNICODE)
                );
            }

            return null;
        }

        // 연산자 처리
        $operator = substr($expected, 1);

        return match (true) {
            $operator === 'exists' => $actual === null ? "Path {$path}: expected to exist" : null,
            $operator === 'notExists' => $actual !== null ? "Path {$path}: expected to not exist" : null,
            $operator === 'isNumber' => ! is_numeric($actual) ? "Path {$path}: expected number, got ".gettype($actual) : null,
            $operator === 'isString' => ! is_string($actual) ? "Path {$path}: expected string, got ".gettype($actual) : null,
            $operator === 'isArray' => ! is_array($actual) ? "Path {$path}: expected array, got ".gettype($actual) : null,
            $operator === 'isBoolean' => ! is_bool($actual) ? "Path {$path}: expected boolean, got ".gettype($actual) : null,
            $operator === 'isObject' => ! is_array($actual) || array_is_list($actual) ? "Path {$path}: expected object, got ".(is_array($actual) ? 'array' : gettype($actual)) : null,
            $operator === 'isNull' => $actual !== null ? "Path {$path}: expected null, got ".gettype($actual) : null,
            $operator === 'notNull' => $actual === null ? "Path {$path}: expected not null" : null,
            $operator === 'isEmpty' => ! empty($actual) ? "Path {$path}: expected empty" : null,
            $operator === 'notEmpty' => empty($actual) ? "Path {$path}: expected not empty" : null,
            str_starts_with($operator, 'minLength:') => $this->validateMinLength($actual, $operator, $path),
            str_starts_with($operator, 'maxLength:') => $this->validateMaxLength($actual, $operator, $path),
            str_starts_with($operator, 'regex:') => $this->validateRegex($actual, $operator, $path),
            str_starts_with($operator, 'gt:') => $this->validateGreaterThan($actual, $operator, $path),
            str_starts_with($operator, 'gte:') => $this->validateGreaterThanOrEqual($actual, $operator, $path),
            str_starts_with($operator, 'lt:') => $this->validateLessThan($actual, $operator, $path),
            str_starts_with($operator, 'lte:') => $this->validateLessThanOrEqual($actual, $operator, $path),
            str_starts_with($operator, 'contains:') => $this->validateContains($actual, $operator, $path),
            default => "Path {$path}: unknown operator @{$operator}",
        };
    }

    private function validateMinLength(mixed $actual, string $operator, string $path): ?string
    {
        $min = (int) substr($operator, 10); // "minLength:" 길이

        if (is_array($actual)) {
            if (count($actual) < $min) {
                return "Path {$path}: expected array with min length {$min}, got ".count($actual);
            }
        } elseif (is_string($actual)) {
            if (mb_strlen($actual) < $min) {
                return "Path {$path}: expected string with min length {$min}, got ".mb_strlen($actual);
            }
        } else {
            return "Path {$path}: minLength requires array or string";
        }

        return null;
    }

    private function validateMaxLength(mixed $actual, string $operator, string $path): ?string
    {
        $max = (int) substr($operator, 10); // "maxLength:" 길이

        if (is_array($actual)) {
            if (count($actual) > $max) {
                return "Path {$path}: expected array with max length {$max}, got ".count($actual);
            }
        } elseif (is_string($actual)) {
            if (mb_strlen($actual) > $max) {
                return "Path {$path}: expected string with max length {$max}, got ".mb_strlen($actual);
            }
        } else {
            return "Path {$path}: maxLength requires array or string";
        }

        return null;
    }

    private function validateRegex(mixed $actual, string $operator, string $path): ?string
    {
        $pattern = '/'.substr($operator, 6).'/'; // "regex:" 제거

        if (! is_string($actual)) {
            return "Path {$path}: regex requires string value";
        }

        if (! preg_match($pattern, $actual)) {
            return "Path {$path}: value does not match pattern {$pattern}";
        }

        return null;
    }

    private function validateGreaterThan(mixed $actual, string $operator, string $path): ?string
    {
        $threshold = (float) substr($operator, 3); // "gt:" 제거

        if (! is_numeric($actual)) {
            return "Path {$path}: gt requires numeric value";
        }

        if ($actual <= $threshold) {
            return "Path {$path}: expected > {$threshold}, got {$actual}";
        }

        return null;
    }

    private function validateGreaterThanOrEqual(mixed $actual, string $operator, string $path): ?string
    {
        $threshold = (float) substr($operator, 4); // "gte:" 제거

        if (! is_numeric($actual)) {
            return "Path {$path}: gte requires numeric value";
        }

        if ($actual < $threshold) {
            return "Path {$path}: expected >= {$threshold}, got {$actual}";
        }

        return null;
    }

    private function validateLessThan(mixed $actual, string $operator, string $path): ?string
    {
        $threshold = (float) substr($operator, 3); // "lt:" 제거

        if (! is_numeric($actual)) {
            return "Path {$path}: lt requires numeric value";
        }

        if ($actual >= $threshold) {
            return "Path {$path}: expected < {$threshold}, got {$actual}";
        }

        return null;
    }

    private function validateLessThanOrEqual(mixed $actual, string $operator, string $path): ?string
    {
        $threshold = (float) substr($operator, 4); // "lte:" 제거

        if (! is_numeric($actual)) {
            return "Path {$path}: lte requires numeric value";
        }

        if ($actual > $threshold) {
            return "Path {$path}: expected <= {$threshold}, got {$actual}";
        }

        return null;
    }

    private function validateContains(mixed $actual, string $operator, string $path): ?string
    {
        $needle = substr($operator, 9); // "contains:" 제거

        if (is_string($actual)) {
            if (! str_contains($actual, $needle)) {
                return "Path {$path}: expected to contain '{$needle}'";
            }
        } elseif (is_array($actual)) {
            if (! in_array($needle, $actual)) {
                return "Path {$path}: expected array to contain '{$needle}'";
            }
        } else {
            return "Path {$path}: contains requires string or array";
        }

        return null;
    }

    private function formatList(array $items): string
    {
        return '['.implode(', ', $items).']';
    }

    /**
     * 숫자 값 비교 (타입 무관)
     *
     * 둘 다 숫자(또는 숫자 문자열)인 경우 값이 같은지 비교합니다.
     * 예: "2" == 2 → true, "123" == 123 → true
     */
    private function areNumericEqual(mixed $actual, mixed $expected): bool
    {
        // 둘 다 숫자 또는 숫자 문자열인 경우에만 비교
        if (is_numeric($actual) && is_numeric($expected)) {
            // 정수 비교가 가능한 경우 정수로 비교
            if (is_int($actual) || is_int($expected) ||
                (is_string($actual) && ctype_digit(ltrim($actual, '-'))) ||
                (is_string($expected) && ctype_digit(ltrim($expected, '-')))) {
                return (int) $actual === (int) $expected;
            }

            // 그 외의 경우 float로 비교
            return (float) $actual === (float) $expected;
        }

        return false;
    }

    /**
     * 응답에서 값 추출 (extract 처리)
     *
     * @param  array  $body  응답 바디
     * @param  array  $extract  추출 정의 ['pageId' => '$.data.id']
     * @return array 추출된 값들
     */
    public function extractValues(array $body, array $extract): array
    {
        $result = [];

        foreach ($extract as $name => $path) {
            $result[$name] = $this->getValueByPath($body, $path);
        }

        return $result;
    }
}
